feat(core/eckhart): add label keyboard layout
What changed, and why it matters
This commit adds a new on-device screen for changing the device's name (label) on the upcoming Trezor 'Eckhart' model. It also makes the passphrase input code more flexible by allowing the caller to specify the prompt text and maximum length, and it wires up a new 'Device name' menu item. There is no obvious security bug in the diff, but it is a large feature patch touching keyboard input, menu navigation, and device settings, so it carries normal implementation risks.
Treat as a normal feature commit. No immediate security response is warranted from the diff alone. Recommend standard QA validation of the new label keyboard (buffer bounds, max-length enforcement, empty-label confirmation, prefill handling) and verification that `apply_settings` is only reachable through authenticated UI interaction.
Security signals we found
New user input surface added (label keyboard) with max-length and empty-label handling
Passphrase keyboard max length changed from a hard-coded constant to a runtime parameter
Device settings mutation path added: menu result -> request_string -> apply_settings
Prefill value is accepted for the label keyboard and passed into a TextBox
Large feature patch (+1056/-140) across 33 files increases review surface area
Evidence from the diff
The change introduces a request_string UI API and an Eckhart-specific LabelKeyboard for entering a device label, plus a RequestString flow with an empty-label confirmation screen. It refactors existing passphrase keyboards across Bolt, Caesar, Delizia, and Eckhart layouts to accept a caller-supplied prompt, prompt_empty, and max_len instead of hard-coding 50 characters and fixed translation strings. The device menu now returns a DeviceName action, and device_menu.py calls apply_settings(ApplySettings(label=...)) after the user enters a new name. Other layouts return ERROR_NOT_IMPLEMENTED for request_string.
Changed components
core/embed/rust/src/ui/api/firmware_micropython.rscore/embed/rust/src/ui/ui_firmware.rscore/embed/rust/src/ui/layout_eckhart/firmware/keyboard/label.rscore/embed/rust/src/ui/layout_eckhart/flow/request_string.rscore/embed/rust/src/ui/layout_eckhart/firmware/device_menu_screen.rscore/src/apps/homescreen/device_menu.pycore/embed/rust/src/ui/layout_bolt/component/keyboard/passphrase.rscore/embed/rust/src/ui/layout_caesar/component/input_methods/passphrase.rscore/embed/rust/src/ui/layout_delizia/component/keyboard/passphrase.rscore/embed/rust/src/ui/layout_eckhart/firmware/keyboard/passphrase.rsInspect captured patch +1056 / −140
diff --git a/core/embed/rust/librust_qstr.h b/core/embed/rust/librust_qstr.h
index 3b9466d0..973c7be2 100644
--- a/core/embed/rust/librust_qstr.h
+++ b/core/embed/rust/librust_qstr.h
@@ -30,6 +30,7 @@ static void _librust_qstrs(void) {
MP_QSTR_DONE;
MP_QSTR_DeviceDisconnect;
MP_QSTR_DeviceMenuResult;
+ MP_QSTR_DeviceName;
MP_QSTR_DevicePair;
MP_QSTR_INFO;
MP_QSTR_INITIAL;
@@ -97,6 +98,7 @@ static void _librust_qstrs(void) {
MP_QSTR_address_label;
MP_QSTR_address_qr;
MP_QSTR_allow_cancel;
+ MP_QSTR_allow_empty;
MP_QSTR_allow_pairing;
MP_QSTR_amount;
MP_QSTR_amount_change;
@@ -266,6 +268,8 @@ static void _librust_qstrs(void) {
MP_QSTR_details_title;
MP_QSTR_device_name;
MP_QSTR_device_name__change_template;
+ MP_QSTR_device_name__continue_with_empty_label;
+ MP_QSTR_device_name__enter;
MP_QSTR_device_name__title;
MP_QSTR_disable_animation;
MP_QSTR_disconnect;
@@ -480,6 +484,7 @@ static void _librust_qstrs(void) {
MP_QSTR_plurals__x_groups_needed;
MP_QSTR_plurals__x_shares_needed;
MP_QSTR_pm_event;
+ MP_QSTR_prefill;
MP_QSTR_prefill_word;
MP_QSTR_progress__authenticity_check;
MP_QSTR_progress__done;
@@ -494,6 +499,7 @@ static void _librust_qstrs(void) {
MP_QSTR_progress_event;
MP_QSTR_prompt;
MP_QSTR_prompt_backup;
+ MP_QSTR_prompt_empty;
MP_QSTR_prompt_screen;
MP_QSTR_prompt_title;
MP_QSTR_pubkey;
@@ -565,6 +571,7 @@ static void _librust_qstrs(void) {
MP_QSTR_request_passphrase;
MP_QSTR_request_pin;
MP_QSTR_request_slip39;
+ MP_QSTR_request_string;
MP_QSTR_reset__advanced_group_threshold_info;
MP_QSTR_reset__all_x_of_y_template;
MP_QSTR_reset__any_x_of_y_template;
@@ -892,6 +899,7 @@ static void _librust_qstrs(void) {
MP_QSTR_words__keep_it_safe;
MP_QSTR_words__know_what_your_doing;
MP_QSTR_words__my_trezor;
+ MP_QSTR_words__name;
MP_QSTR_words__no;
MP_QSTR_words__not_recommended;
MP_QSTR_words__operation_cancelled;
diff --git a/core/embed/rust/src/translations/generated/translated_string.rs b/core/embed/rust/src/translations/generated/translated_string.rs
index 2c8e2270..0cbe3029 100644
--- a/core/embed/rust/src/translations/generated/translated_string.rs
+++ b/core/embed/rust/src/translations/generated/translated_string.rs
@@ -1463,6 +1463,9 @@ pub enum TranslatedString {
tutorial__what_is_tropic = 1068, // "What is TROPIC01?"
tutorial__tap_to_start = 1069, // "Tap to start tutorial"
tutorial__tropic_info = 1070, // "TROPIC01 is a next-gen open-source secure element chip designed for transparent and auditable hardware security."
+ device_name__enter = 1071, // "Enter device name"
+ words__name = 1072, // "Name"
+ device_name__continue_with_empty_label = 1073, // "Continue with empty device name?"
}
impl TranslatedString {
@@ -3245,6 +3248,9 @@ impl TranslatedString {
(Self::tutorial__what_is_tropic, "What is TROPIC01?"),
(Self::tutorial__tap_to_start, "Tap to start tutorial"),
(Self::tutorial__tropic_info, "TROPIC01 is a next-gen open-source secure element chip designed for transparent and auditable hardware security."),
+ (Self::device_name__enter, "Enter device name"),
+ (Self::words__name, "Name"),
+ (Self::device_name__continue_with_empty_label, "Continue with empty device name?"),
];
#[cfg(feature = "micropython")]
@@ -3612,6 +3618,8 @@ impl TranslatedString {
#[cfg(feature = "debug")]
(Qstr::MP_QSTR_debug__loading_seed_not_recommended, Self::debug__loading_seed_not_recommended),
(Qstr::MP_QSTR_device_name__change_template, Self::device_name__change_template),
+ (Qstr::MP_QSTR_device_name__continue_with_empty_label, Self::device_name__continue_with_empty_label),
+ (Qstr::MP_QSTR_device_name__enter, Self::device_name__enter),
(Qstr::MP_QSTR_device_name__title, Self::device_name__title),
(Qstr::MP_QSTR_entropy__send, Self::entropy__send),
(Qstr::MP_QSTR_entropy__title_confirm, Self::entropy__title_confirm),
@@ -4664,6 +4672,7 @@ impl TranslatedString {
(Qstr::MP_QSTR_words__keep_it_safe, Self::words__keep_it_safe),
(Qstr::MP_QSTR_words__know_what_your_doing, Self::words__know_what_your_doing),
(Qstr::MP_QSTR_words__my_trezor, Self::words__my_trezor),
+ (Qstr::MP_QSTR_words__name, Self::words__name),
(Qstr::MP_QSTR_words__no, Self::words__no),
(Qstr::MP_QSTR_words__not_recommended, Self::words__not_recommended),
(Qstr::MP_QSTR_words__operation_cancelled, Self::words__operation_cancelled),
diff --git a/core/embed/rust/src/ui/api/firmware_micropython.rs b/core/embed/rust/src/ui/api/firmware_micropython.rs
index 72afe59c..6998fb1d 100644
--- a/core/embed/rust/src/ui/api/firmware_micropython.rs
+++ b/core/embed/rust/src/ui/api/firmware_micropython.rs
@@ -753,9 +753,23 @@ extern "C" fn new_request_pin(n_args: usize, args: *const Obj, kwargs: *mut Map)
extern "C" fn new_request_passphrase(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj {
let block = move |_args: &[Obj], kwargs: &Map| {
let prompt: TString = kwargs.get(Qstr::MP_QSTR_prompt)?.try_into()?;
- let max_len: u32 = kwargs.get(Qstr::MP_QSTR_max_len)?.try_into()?;
+ let prompt_empty: TString = kwargs.get(Qstr::MP_QSTR_prompt_empty)?.try_into()?;
+ let max_len: usize = kwargs.get(Qstr::MP_QSTR_max_len)?.try_into()?;
- let layout = ModelUI::request_passphrase(prompt, max_len)?;
+ let layout = ModelUI::request_passphrase(prompt, prompt_empty, max_len)?;
+ Ok(LayoutObj::new_root(layout)?.into())
+ };
+ unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) }
+}
+
+extern "C" fn new_request_string(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj {
+ let block = move |_args: &[Obj], kwargs: &Map| {
+ let prompt: TString = kwargs.get(Qstr::MP_QSTR_prompt)?.try_into()?;
+ let max_len: usize = kwargs.get(Qstr::MP_QSTR_max_len)?.try_into()?;
+ let allow_empty: bool = kwargs.get(Qstr::MP_QSTR_allow_empty)?.try_into()?;
+ let prefill: Option<TString> = kwargs.get(Qstr::MP_QSTR_prefill)?.try_into_option()?;
+
+ let layout = ModelUI::request_string(prompt, max_len, allow_empty, prefill)?;
Ok(LayoutObj::new_root(layout)?.into())
};
unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) }
@@ -924,7 +938,8 @@ extern "C" fn new_show_device_menu(n_args: usize, args: *const Obj, kwargs: *mut
let block = move |_args: &[Obj], kwargs: &Map| {
let failed_backup: bool = kwargs.get(Qstr::MP_QSTR_failed_backup)?.try_into()?;
let firmware_version: TString = kwargs.get(Qstr::MP_QSTR_firmware_version)?.try_into()?;
- let device_name: TString = kwargs.get(Qstr::MP_QSTR_device_name)?.try_into()?;
+ let device_name: Option<TString> =
+ kwargs.get(Qstr::MP_QSTR_device_name)?.try_into_option()?;
let paired_devices: Obj = kwargs.get(Qstr::MP_QSTR_paired_devices)?;
let paired_devices: Vec<TString, 1> = util::iter_into_vec(paired_devices)?;
let auto_lock_delay: TString<'static> =
@@ -1749,11 +1764,22 @@ pub static mp_module_trezorui_api: Module = obj_module! {
/// def request_passphrase(
/// *,
/// prompt: str,
+ /// prompt_empty: str,
/// max_len: int,
/// ) -> LayoutObj[str | UiResult]:
/// """Passphrase input keyboard."""
Qstr::MP_QSTR_request_passphrase => obj_fn_kw!(0, new_request_passphrase).as_obj(),
+ /// def request_string(
+ /// *,
+ /// prompt: str,
+ /// max_len: int,
+ /// allow_empty: bool,
+ /// prefill: str | None,
+ /// ) -> LayoutObj[str | UiResult]:
+ /// """Label input keyboard."""
+ Qstr::MP_QSTR_request_string => obj_fn_kw!(0, new_request_string).as_obj(),
+
/// def select_menu(
/// *,
/// items: Iterable[str],
@@ -1853,7 +1879,7 @@ pub static mp_module_trezorui_api: Module = obj_module! {
/// *,
/// failed_backup: bool,
/// firmware_version: str,
- /// device_name: str,
+ /// device_name: str | None,
/// paired_devices: Iterable[str],
/// auto_lock_delay: str,
/// ) -> LayoutObj[UiResult | DeviceMenuResult | tuple[DeviceMenuResult, int]]:
@@ -2058,5 +2084,6 @@ pub static mp_module_trezorui_api: Module = obj_module! {
/// WipeDevice: ClassVar[DeviceMenuResult]
/// ScreenBrightness: ClassVar[DeviceMenuResult]
/// AutoLockDelay: ClassVar[DeviceMenuResult]
+ /// DeviceName: ClassVar[DeviceMenuResult]
Qstr::MP_QSTR_DeviceMenuResult => DEVICE_MENU_RESULT.as_obj(),
};
diff --git a/core/embed/rust/src/ui/layout/device_menu_result.rs b/core/embed/rust/src/ui/layout/device_menu_result.rs
index e1e07127..dadb1320 100644
--- a/core/embed/rust/src/ui/layout/device_menu_result.rs
+++ b/core/embed/rust/src/ui/layout/device_menu_result.rs
@@ -14,6 +14,7 @@ pub static CHECK_BACKUP: SimpleTypeObj = SimpleTypeObj::new(&DEVICE_MENU_RESULT_
pub static WIPE_DEVICE: SimpleTypeObj = SimpleTypeObj::new(&DEVICE_MENU_RESULT_BASE_TYPE);
pub static SCREEN_BRIGHTNESS: SimpleTypeObj = SimpleTypeObj::new(&DEVICE_MENU_RESULT_BASE_TYPE);
pub static AUTO_LOCK_DELAY: SimpleTypeObj = SimpleTypeObj::new(&DEVICE_MENU_RESULT_BASE_TYPE);
+pub static DEVICE_NAME: SimpleTypeObj = SimpleTypeObj::new(&DEVICE_MENU_RESULT_BASE_TYPE);
// Create a DeviceMenuResult class that contains all result types
static DEVICE_MENU_RESULT_TYPE: Type = obj_type! {
@@ -26,6 +27,7 @@ static DEVICE_MENU_RESULT_TYPE: Type = obj_type! {
Qstr::MP_QSTR_WipeDevice => WIPE_DEVICE.as_obj(),
Qstr::MP_QSTR_ScreenBrightness => SCREEN_BRIGHTNESS.as_obj(),
Qstr::MP_QSTR_AutoLockDelay => AUTO_LOCK_DELAY.as_obj(),
+ Qstr::MP_QSTR_DeviceName => DEVICE_NAME.as_obj(),
} },
};
diff --git a/core/embed/rust/src/ui/layout_bolt/component/keyboard/passphrase.rs b/core/embed/rust/src/ui/layout_bolt/component/keyboard/passphrase.rs
index e3304feb..2788a162 100644
--- a/core/embed/rust/src/ui/layout_bolt/component/keyboard/passphrase.rs
+++ b/core/embed/rust/src/ui/layout_bolt/component/keyboard/passphrase.rs
@@ -35,6 +35,7 @@ pub struct PassphraseKeyboard {
keys: [Child<Button>; KEY_COUNT],
scrollbar: ScrollBar,
fade: Cell<bool>,
+ max_len: usize,
}
const STARTING_PAGE: usize = 1;
@@ -48,14 +49,13 @@ const KEYBOARD: [[&str; KEY_COUNT]; PAGE_COUNT] = [
["_<>", ".:@", "/|\\", "!()", "+%&", "-[]", "?{}", ",'`", ";\"~", "$^="],
];
-const MAX_LENGTH: usize = 50;
const INPUT_AREA_HEIGHT: i16 = ScrollBar::DOT_SIZE + 9;
impl PassphraseKeyboard {
- pub fn new() -> Self {
+ pub fn new(max_len: usize) -> Self {
Self {
page_swipe: Swipe::horizontal(),
- input: Input::new().into_child(),
+ input: Input::new(max_len).into_child(),
confirm: Button::with_icon(theme::ICON_CONFIRM)
.styled(theme::button_confirm())
.into_child(),
@@ -73,6 +73,7 @@ impl PassphraseKeyboard {
}),
scrollbar: ScrollBar::horizontal(),
fade: Cell::new(false),
+ max_len,
}
}
@@ -166,7 +167,7 @@ impl PassphraseKeyboard {
/// We should disable the input when the passphrase has reached maximum
/// length and we are not cycling through the characters.
fn is_button_active(&self, key: usize) -> bool {
- let textbox_not_full = self.input.inner().textbox.len() < MAX_LENGTH;
+ let textbox_not_full = self.input.inner().textbox.len() < self.max_len;
let key_is_pending = {
if let Some(pending) = self.input.inner().multi_tap.pending_key() {
pending == key
@@ -318,10 +319,10 @@ struct Input {
}
impl Input {
- fn new() -> Self {
+ fn new(max_len: usize) -> Self {
Self {
area: Rect::zero(),
- textbox: TextBox::empty(MAX_LENGTH),
+ textbox: TextBox::empty(max_len),
multi_tap: MultiTapKeyboard::new(),
}
}
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 7ff1b3e2..6784be03 100644
--- a/core/embed/rust/src/ui/layout_bolt/ui_firmware.rs
+++ b/core/embed/rust/src/ui/layout_bolt/ui_firmware.rs
@@ -723,12 +723,22 @@ impl FirmwareUI for UIBolt {
fn request_passphrase(
_prompt: TString<'static>,
- _max_len: u32,
+ _prompt_empty: TString<'static>,
+ max_len: usize,
) -> Result<impl LayoutMaybeTrace, Error> {
- let layout = RootComponent::new(PassphraseKeyboard::new());
+ let layout = RootComponent::new(PassphraseKeyboard::new(max_len));
Ok(layout)
}
+ fn request_string(
+ _prompt: TString<'static>,
+ _max_len: usize,
+ _allow_empty: bool,
+ _prefill: Option<TString<'static>>,
+ ) -> Result<impl LayoutMaybeTrace, Error> {
+ Err::<RootComponent<Empty, ModelUI>, Error>(ERROR_NOT_IMPLEMENTED)
+ }
+
fn select_menu(
_items: heapless::Vec<TString<'static>, MAX_MENU_ITEMS>,
_current: usize,
@@ -925,7 +935,7 @@ impl FirmwareUI for UIBolt {
fn show_device_menu(
_failed_backup: bool,
_firmware_version: TString<'static>,
- _device_name: TString<'static>,
+ _device_name: Option<TString<'static>>,
_paired_devices: heapless::Vec<TString<'static>, 1>,
_auto_lock_delay: TString<'static>,
) -> Result<impl LayoutMaybeTrace, Error> {
diff --git a/core/embed/rust/src/ui/layout_caesar/component/input_methods/passphrase.rs b/core/embed/rust/src/ui/layout_caesar/component/input_methods/passphrase.rs
index b61ded4d..8431bd22 100644
--- a/core/embed/rust/src/ui/layout_caesar/component/input_methods/passphrase.rs
+++ b/core/embed/rust/src/ui/layout_caesar/component/input_methods/passphrase.rs
@@ -26,8 +26,6 @@ enum ChoiceCategory {
SpecialSymbol,
}
-const MAX_PASSPHRASE_LENGTH: usize = 50;
-
const DIGITS: &str = "0123456789";
const LOWERCASE_LETTERS: &str = "abcdefghijklmnopqrstuvwxyz";
const UPPERCASE_LETTERS: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
@@ -273,26 +271,28 @@ pub struct PassphraseEntry {
show_last_digit: bool,
textbox: TextBox,
current_category: ChoiceCategory,
+ max_len: usize,
}
impl PassphraseEntry {
- pub fn new() -> Self {
+ pub fn new(max_len: usize) -> Self {
Self {
choice_page: ChoicePage::new(ChoiceFactoryPassphrase::new(ChoiceCategory::Menu, true))
.with_initial_page_counter(random_menu_position())
.with_controls(ChoiceControls::Carousel),
- passphrase_dots: Child::new(ChangingTextLine::center_mono("", MAX_PASSPHRASE_LENGTH)),
+ passphrase_dots: Child::new(ChangingTextLine::center_mono("", max_len)),
show_plain_passphrase: false,
show_last_digit: false,
- textbox: TextBox::empty(MAX_PASSPHRASE_LENGTH),
+ textbox: TextBox::empty(max_len),
current_category: ChoiceCategory::Menu,
+ max_len,
}
}
fn update_passphrase_dots(&mut self, ctx: &mut EventCtx) {
debug_assert!({
let s = ShortString::new();
- s.capacity() >= MAX_PASSPHRASE_LENGTH
+ s.capacity() >= self.max_len
});
let text_to_show = if self.show_plain_passphrase {
@@ -362,7 +362,7 @@ impl PassphraseEntry {
}
fn is_full(&self) -> bool {
- self.textbox.len() >= MAX_PASSPHRASE_LENGTH
+ self.textbox.len() >= self.max_len
}
/// Randomly choose an index in the current category
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 0e966209..2815e7b2 100644
--- a/core/embed/rust/src/ui/layout_caesar/ui_firmware.rs
+++ b/core/embed/rust/src/ui/layout_caesar/ui_firmware.rs
@@ -917,13 +917,24 @@ impl FirmwareUI for UICaesar {
fn request_passphrase(
prompt: TString<'static>,
- _max_len: u32,
+ _prompt_empty: TString<'static>,
+ max_len: usize,
) -> Result<impl LayoutMaybeTrace, Error> {
- let layout =
- RootComponent::new(Frame::new(prompt, PassphraseEntry::new()).with_title_centered());
+ let layout = RootComponent::new(
+ Frame::new(prompt, PassphraseEntry::new(max_len)).with_title_centered(),
+ );
Ok(layout)
}
+ fn request_string(
+ _prompt: TString<'static>,
+ _max_len: usize,
+ _allow_empty: bool,
+ _prefill: Option<TString<'static>>,
+ ) -> Result<impl LayoutMaybeTrace, Error> {
+ Err::<RootComponent<Empty, ModelUI>, Error>(ERROR_NOT_IMPLEMENTED)
+ }
+
fn select_menu(
items: heapless::Vec<TString<'static>, MAX_MENU_ITEMS>,
current: usize,
@@ -1118,7 +1129,7 @@ impl FirmwareUI for UICaesar {
fn show_device_menu(
_failed_backup: bool,
_firmware_version: TString<'static>,
- _device_name: TString<'static>,
+ _device_name: Option<TString<'static>>,
_paired_devices: Vec<TString<'static>, 1>,
_auto_lock_delay: TString<'static>,
) -> Result<impl LayoutMaybeTrace, Error> {
diff --git a/core/embed/rust/src/ui/layout_delizia/component/keyboard/passphrase.rs b/core/embed/rust/src/ui/layout_delizia/component/keyboard/passphrase.rs
index 56fde4d9..2e6ef527 100644
--- a/core/embed/rust/src/ui/layout_delizia/component/keyboard/passphrase.rs
+++ b/core/embed/rust/src/ui/layout_delizia/component/keyboard/passphrase.rs
@@ -1,6 +1,5 @@
use crate::{
strutil::{ShortString, TString},
- translations::TR,
ui::{
component::{
base::ComponentExt, swipe_detect::SwipeConfig, text::common::TextBox, Component, Event,
@@ -31,7 +30,7 @@ pub enum PassphraseKeyboardMsg {
}
/// Enum keeping track of which keyboard is shown and which comes next. Keep the
-/// number of values and the constant PAGE_COUNT in synch.
+/// number of values and the constant PAGE_COUNT in sync.
#[repr(u32)]
#[derive(Copy, Clone, ToPrimitive)]
#[cfg_attr(feature = "ui_debug", derive(ufmt::derive::uDebug))]
@@ -88,6 +87,7 @@ pub struct PassphraseKeyboard {
active_layout: KeyboardLayout,
fade: Cell<bool>,
swipe_config: SwipeConfig, // FIXME: how about page_swipe
+ max_len: usize,
}
const PAGE_COUNT: usize = 4;
@@ -100,14 +100,12 @@ const KEYBOARD: [[&str; KEY_COUNT]; PAGE_COUNT] = [
["_<>", ".:@", "/|\\", "!()", "+%&", "-[]", "?{}", ",'`", ";\"~", "$^="],
];
-const MAX_LENGTH: usize = 50;
-
const CONFIRM_BTN_INSETS: Insets = Insets::new(5, 0, 5, 0);
const CONFIRM_EMPTY_BTN_MARGIN_RIGHT: i16 = 7;
const CONFIRM_EMPTY_BTN_INSETS: Insets = Insets::new(5, CONFIRM_EMPTY_BTN_MARGIN_RIGHT, 5, 0);
impl PassphraseKeyboard {
- pub fn new() -> Self {
+ pub fn new(prompt: TString<'static>, max_len: usize) -> Self {
let active_layout = KeyboardLayout::LettersLower;
let confirm_btn = Button::with_icon(theme::ICON_SIMPLE_CHECKMARK24)
@@ -139,11 +137,8 @@ impl PassphraseKeyboard {
Self {
page_swipe: Swipe::horizontal(),
- input: Input::new(),
- input_prompt: Label::left_aligned(
- TString::from_translation(TR::passphrase__title_enter),
- theme::label_keyboard(),
- ),
+ input: Input::new(max_len),
+ input_prompt: Label::left_aligned(prompt, theme::label_keyboard()),
erase_btn,
cancel_btn,
confirm_btn,
@@ -158,6 +153,7 @@ impl PassphraseKeyboard {
active_layout,
fade: Cell::new(false),
swipe_config: SwipeConfig::new(),
+ max_len,
}
}
@@ -249,7 +245,7 @@ impl PassphraseKeyboard {
/// We should disable the input when the passphrase has reached maximum
/// length and we are not cycling through the characters.
fn is_button_active(&self, key: usize) -> bool {
- let textbox_not_full = self.input.textbox.len() < MAX_LENGTH;
+ let textbox_not_full = self.input.textbox.len() < self.max_len;
let key_is_pending = {
if let Some(pending) = self.input.multi_tap.pending_key() {
pending == key
@@ -418,10 +414,10 @@ struct Input {
}
impl Input {
- fn new() -> Self {
+ fn new(max_len: usize) -> Self {
Self {
area: Rect::zero(),
- textbox: TextBox::empty(MAX_LENGTH),
+ textbox: TextBox::empty(max_len),
multi_tap: MultiTapKeyboard::new(),
}
}
diff --git a/core/embed/rust/src/ui/layout_delizia/flow/request_passphrase.rs b/core/embed/rust/src/ui/layout_delizia/flow/request_passphrase.rs
index 02ff6ca1..099f43c5 100644
--- a/core/embed/rust/src/ui/layout_delizia/flow/request_passphrase.rs
+++ b/core/embed/rust/src/ui/layout_delizia/flow/request_passphrase.rs
@@ -1,7 +1,6 @@
use crate::{
error,
- strutil::ShortString,
- translations::TR,
+ strutil::{ShortString, TString},
ui::{
component::ComponentExt,
flow::{
@@ -49,14 +48,15 @@ impl FlowController for RequestPassphrase {
}
}
-pub fn new_request_passphrase() -> Result<SwipeFlow, error::Error> {
- let content_confirm_empty = Frame::left_aligned(
- TR::passphrase__continue_with_empty_passphrase.into(),
- PromptScreen::new_yes_or_no(),
- )
- .map(super::util::map_to_prompt);
+pub fn new_request_passphrase(
+ prompt: TString<'static>,
+ prompt_empty: TString<'static>,
+ max_len: usize,
+) -> Result<SwipeFlow, error::Error> {
+ let content_confirm_empty = Frame::left_aligned(prompt_empty, PromptScreen::new_yes_or_no())
+ .map(super::util::map_to_prompt);
- let content_keypad = PassphraseKeyboard::new().map(|msg| match msg {
+ let content_keypad = PassphraseKeyboard::new(prompt, max_len).map(|msg| match msg {
PassphraseKeyboardMsg::Confirmed(s) => Some(FlowMsg::Text(s)),
PassphraseKeyboardMsg::Cancelled => Some(FlowMsg::Cancelled),
});
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 4b80a866..26c218e6 100644
--- a/core/embed/rust/src/ui/layout_delizia/ui_firmware.rs
+++ b/core/embed/rust/src/ui/layout_delizia/ui_firmware.rs
@@ -814,13 +814,23 @@ impl FirmwareUI for UIDelizia {
}
fn request_passphrase(
- _prompt: TString<'static>,
- _max_len: u32,
+ prompt: TString<'static>,
+ prompt_empty: TString<'static>,
+ max_len: usize,
) -> Result<impl LayoutMaybeTrace, Error> {
- let flow = flow::request_passphrase::new_request_passphrase()?;
+ let flow = flow::request_passphrase::new_request_passphrase(prompt, prompt_empty, max_len)?;
Ok(flow)
}
+ fn request_string(
+ _prompt: TString<'static>,
+ _max_len: usize,
+ _allow_empty: bool,
+ _prefill: Option<TString<'static>>,
+ ) -> Result<impl LayoutMaybeTrace, Error> {
+ Err::<RootComponent<Empty, ModelUI>, Error>(ERROR_NOT_IMPLEMENTED)
+ }
+
fn select_menu(
items: heapless::Vec<TString<'static>, MAX_MENU_ITEMS>,
mut current: usize,
@@ -1009,7 +1019,7 @@ impl FirmwareUI for UIDelizia {
fn show_device_menu(
_failed_backup: bool,
_firmware_version: TString<'static>,
- _device_name: TString<'static>,
+ _device_name: Option<TString<'static>>,
_paired_devices: heapless::Vec<TString<'static>, 1>,
_auto_lock_delay: TString<'static>,
) -> Result<impl LayoutMaybeTrace, Error> {
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 dd54f124..2b85d539 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
@@ -155,6 +155,7 @@ impl<'a> ComponentMsgObj for DeviceMenuScreen<'a> {
}
DeviceMenuMsg::CheckBackup => Ok(CHECK_BACKUP.as_obj()),
DeviceMenuMsg::WipeDevice => Ok(WIPE_DEVICE.as_obj()),
+ DeviceMenuMsg::DeviceName => Ok(DEVICE_NAME.as_obj()),
DeviceMenuMsg::ScreenBrightness => Ok(SCREEN_BRIGHTNESS.as_obj()),
DeviceMenuMsg::AutoLockDelay => Ok(AUTO_LOCK_DELAY.as_obj()),
DeviceMenuMsg::Close => Ok(CANCELLED.as_obj()),
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 97507fc5..bca5e6ac 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
@@ -62,6 +62,7 @@ pub enum DeviceMenuMsg {
WipeDevice,
// Device menu
+ DeviceName,
ScreenBrightness,
AutoLockDelay,
@@ -166,7 +167,7 @@ impl<'a> DeviceMenuScreen<'a> {
pub fn new(
failed_backup: bool,
firmware_version: TString<'static>,
- device_name: TString<'static>,
+ device_name: Option<TString<'static>>,
// NB: we currently only support one device at a time.
paired_devices: Vec<TString<'static>, 1>,
auto_lock_delay: TString<'static>,
@@ -282,14 +283,20 @@ impl<'a> DeviceMenuScreen<'a> {
fn add_device_menu(
&mut self,
- device_name: TString<'static>,
+ device_name: Option<TString<'static>>,
about_index: usize,
auto_lock_delay: TString<'static>,
) -> usize {
let mut items: Vec<MenuItem, SHORT_MENU_ITEMS> = Vec::new();
- let mut item_device_name = MenuItem::new("Name".into(), None);
- item_device_name.with_subtext(Some((device_name, None)));
- unwrap!(items.push(item_device_name));
+ if let Some(device_name) = device_name {
+ let mut item_device_name = MenuItem::new(
+ TR::words__name.into(),
+ Some(Action::Return(DeviceMenuMsg::DeviceName)),
+ );
+ item_device_name.with_subtext(Some((device_name, None)));
+ unwrap!(items.push(item_device_name));
+ }
+
unwrap!(items.push(MenuItem::new(
"Screen brightness".into(),
Some(Action::Return(DeviceMenuMsg::ScreenBrightness)),
diff --git a/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/common.rs b/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/common.rs
index 3f684b90..0dc127f5 100644
--- a/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/common.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/common.rs
@@ -8,7 +8,7 @@ use crate::{
},
};
-use super::super::theme;
+use super::super::{super::component::ButtonContent, theme};
/// Contains state commonly used in implementations multi-tap keyboards.
pub struct MultiTapKeyboard {
@@ -110,6 +110,50 @@ impl MultiTapKeyboard {
}
}
+/// Enum keeping track of which keyboard is shown and which comes next. Keep the
+/// number of values and the constant PAGE_COUNT in sync.
+#[repr(u32)]
+#[derive(Copy, Clone, PartialEq)]
+#[cfg_attr(feature = "ui_debug", derive(ufmt::derive::uDebug))]
+pub(crate) enum KeyboardLayout {
+ LettersLower = 0,
+ LettersUpper = 1,
+ Numeric = 2,
+ Special = 3,
+}
+
+impl KeyboardLayout {
+ pub fn next(self) -> Self {
+ match self {
+ Self::LettersLower => Self::LettersUpper,
+ Self::LettersUpper => Self::Numeric,
+ Self::Numeric => Self::Special,
+ Self::Special => Self::LettersLower,
+ }
+ }
+
+ pub fn prev(self) -> Self {
+ match self {
+ Self::LettersLower => Self::Special,
+ Self::LettersUpper => Self::LettersLower,
+ Self::Numeric => Self::LettersUpper,
+ Self::Special => Self::Numeric,
+ }
+ }
+}
+
+impl From<KeyboardLayout> for ButtonContent {
+ /// Used to get content for the "next keyboard" button
+ fn from(kl: KeyboardLayout) -> Self {
+ match kl {
+ KeyboardLayout::LettersLower => ButtonContent::single_line_text("abc".into()),
+ KeyboardLayout::LettersUpper => ButtonContent::single_line_text("ABC".into()),
+ KeyboardLayout::Numeric => ButtonContent::single_line_text("123".into()),
+ KeyboardLayout::Special => ButtonContent::Icon(theme::ICON_ASTERISK),
+ }
+ }
+}
+
/// Create a visible "underscoring" of the last letter of a text.
pub fn render_pending_marker<'s>(
target: &mut impl Renderer<'s>,
diff --git a/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/label.rs b/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/label.rs
new file mode 100644
index 00000000..3bf18a00
--- /dev/null
+++ b/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/label.rs
@@ -0,0 +1,668 @@
+use crate::{
+ strutil::{ShortString, TString},
+ ui::{
+ component::{
+ swipe_detect::SwipeConfig,
+ text::{
+ common::TextBox,
+ layout::{LayoutFit, LineBreaking},
+ TextStyle,
+ },
+ Component, Event, EventCtx, Label, Swipe, TextLayout,
+ },
+ display::Icon,
+ event::TouchEvent,
+ flow::Swipable,
+ geometry::{Alignment, Direction, Insets, Offset, Rect},
+ shape::{Bar, Renderer, Text},
+ util::{long_line_content_with_ellipsis, Pager},
+ },
+};
+
+use super::super::{
+ super::component::{Button, ButtonContent, ButtonMsg, ButtonStyleSheet},
+ constant::SCREEN,
+ keyboard::{
+ common::{
+ render_pending_marker, KeyboardLayout, MultiTapKeyboard, INPUT_TOUCH_HEIGHT,
+ KEYBOARD_INPUT_INSETS, KEYBOARD_INPUT_RADIUS, KEYPAD_VISIBLE_HEIGHT,
+ },
+ keypad::{ButtonState, Keypad, KeypadButton, KeypadMsg, KeypadState},
+ },
+ theme,
+};
+
+#[derive(PartialEq, Debug, Copy, Clone)]
+#[cfg_attr(feature = "ui_debug", derive(ufmt::derive::uDebug))]
+enum DisplayStyle {
+ /// A part that fits on one line
+ OneLine,
+ /// One line with the last pending character.
+ OneLineWithMarker,
+ /// The complete string is shown in the input area.
+ Complete,
+}
+
+pub enum LabelKeyboardMsg {
+ Confirmed(ShortString),
+ Cancelled,
+}
+
+pub struct LabelKeyboard {
+ page_swipe: Swipe,
+ input: LabelInput,
+ input_prompt: Label<'static>,
+ keypad: Keypad,
+ next_btn: Button,
+ active_layout: KeyboardLayout,
+ swipe_config: SwipeConfig,
+ multi_tap: MultiTapKeyboard,
+ max_len: usize,
+ allow_empty: bool,
+}
+
+const PAGE_COUNT: usize = 4;
+const KEY_COUNT: usize = 10;
+#[rustfmt::skip]
+const KEYBOARD: [[&str; KEY_COUNT]; PAGE_COUNT] = [
+ ["abc", "def", "ghi", "jkl", "mno", "pq", "rst", "uvw", "xyz", " *#"],
+ ["ABC", "DEF", "GHI", "JKL", "MNO", "PQ", "RST", "UVW", "XYZ", " *#"],
+ ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
+ ["_<>", ".:@", "/|\\", "!()", "+%&", "-[]", "?{}", ",'`", ";\"~", "$^="],
+ ];
+
+const NEXT_BTN_WIDTH: i16 = 103;
+const NEXT_BTN_PADDING: i16 = 14;
+const NEXT_BTN_INSETS: Insets =
+ Insets::new(NEXT_BTN_PADDING, NEXT_BTN_PADDING, 0, NEXT_BTN_PADDING);
+
+impl LabelKeyboard {
+ pub fn new(
+ prompt: TString<'static>,
+ max_len: usize,
+ allow_empty: bool,
+ prefill: Option<TString<'static>>,
+ ) -> Self {
+ let active_layout = KeyboardLayout::LettersLower;
+ let layout: &[&str; KEY_COUNT] = &KEYBOARD[active_layout as usize];
+ let keypad_content: [ButtonContent; KEY_COUNT] =
+ core::array::from_fn(|idx| Self::key_content(layout[idx]));
+
+ let next_btn = Button::new(active_layout.next().into())
+ .styled(theme::button_keyboard_next())
+ .with_radius(12)
+ .with_text_align(Alignment::Center)
+ .with_expanded_touch_area(NEXT_BTN_INSETS);
+
+ if let Some(prefill) = prefill {
+ debug_assert!(prefill.len() <= max_len);
+ }
+
+ Self {
+ page_swipe: Swipe::horizontal(),
+ input: LabelInput::new(max_len, prefill),
+ input_prompt: Label::left_aligned(prompt, theme::firmware::TEXT_SMALL)
+ .vertically_centered(),
+ next_btn,
+ keypad: Keypad::new_shown().with_keys_content(&keypad_content),
+ active_layout,
+ swipe_config: SwipeConfig::new(),
+ multi_tap: MultiTapKeyboard::new(),
+ max_len,
+ allow_empty,
+ }
+ }
+
+ fn key_text(content: &ButtonContent) -> Option<TString<'static>> {
+ match content {
+ ButtonContent::Text { text, .. } => Some(*text),
+ ButtonContent::Icon(theme::ICON_SPECIAL_CHARS) => Some(" *#".into()),
+ ButtonContent::Icon(_) => Some(" ".into()),
+ _ => None,
+ }
+ }
+
+ fn key_content(text: &'static str) -> ButtonContent {
+ match text {
+ " *#" => ButtonContent::Icon(theme::ICON_SPECIAL_CHARS),
+ t => ButtonContent::single_line_text(t.into()),
+ }
+ }
+
+ fn key_style(layout: KeyboardLayout) -> ButtonStyleSheet {
+ if layout == KeyboardLayout::Numeric {
+ theme::button_keyboard_numeric()
+ } else {
+ theme::button_keyboard()
+ }
+ }
+
+ fn on_page_change(&mut self, ctx: &mut EventCtx, swipe: Direction) {
+ // Change the keyboard layout.
+ self.active_layout = match swipe {
+ Direction::Left => self.active_layout.next(),
+ Direction::Right => self.active_layout.prev(),
+ _ => self.active_layout,
+ };
+ if self.multi_tap.pending_key().is_some() {
+ // Clear the pending state.
+ self.multi_tap.clear_pending_state(ctx);
+ self.input.display_style = DisplayStyle::OneLine;
+ }
+ // Update keys.
+ self.replace_keys_contents();
+ self.update_keypad_state(ctx);
+ }
+
+ fn replace_keys_contents(&mut self) {
+ self.next_btn.set_content(self.active_layout.next().into());
+ let layout = self.active_layout as usize;
+ let styles = Self::key_style(self.active_layout);
+
+ for idx in 0..KEY_COUNT {
+ let text = KEYBOARD[layout][idx];
+ let content = Self::key_content(text);
+ self.keypad.set_key_content(idx, content);
+ self.keypad
+ .set_button_stylesheet(KeypadButton::Key(idx), styles);
+ }
+ }
+
+ /// Update the keypad state based on the current label and input state
+ /// Can be used only when no key is pressed
+ fn update_keypad_state(&mut self, ctx: &mut EventCtx) {
+ let keypad_state = match self.input.display_style {
+ DisplayStyle::Complete => {
+ // Disable the entire active keypad
+ KeypadState {
+ back: ButtonState::Hidden,
+ erase: ButtonState::Disabled,
+ cancel: ButtonState::Hidden,
+ confirm: ButtonState::Disabled,
+ keys: ButtonState::Disabled,
+ override_key: None,
+ }
+ }
+ _ => {
+ if self.label().len() == self.max_len {
+ if let Some(pending_key) = self.multi_tap.pending_key() {
+ // Disable all except of confirm, erase and the pending key
+ KeypadState {
+ back: ButtonState::Hidden,
+ erase: ButtonState::Enabled,
+ cancel: ButtonState::Hidden,
+ confirm: ButtonState::Enabled,
+ keys: ButtonState::Disabled,
+ override_key: Some((pending_key, ButtonState::Enabled)),
+ }
+ } else {
+ // Disable all except of confirm and erase buttons
+ KeypadState {
+ back: ButtonState::Hidden,
+ erase: ButtonState::Enabled,
+ cancel: ButtonState::Hidden,
+ confirm: ButtonState::Enabled,
+ keys: ButtonState::Disabled,
+ override_key: None,
+ }
+ }
+ } else if self.input.textbox.is_empty() {
+ // Disable all except of confirm and erase buttons
+ KeypadState {
+ back: ButtonState::Hidden,
+ erase: ButtonState::Hidden,
+ cancel: ButtonState::Hidden,
+ confirm: if self.allow_empty {
+ ButtonState::Enabled
+ } else {
+ ButtonState::Disabled
+ },
+ keys: ButtonState::Enabled,
+ override_key: None,
+ }
+ } else {
+ KeypadState {
+ back: ButtonState::Hidden,
+ erase: ButtonState::Enabled,
+ cancel: ButtonState::Hidden,
+ confirm: ButtonState::Enabled,
+ keys: ButtonState::Enabled,
+ override_key: None,
+ }
+ }
+ }
+ };
+
+ self.keypad.set_state(keypad_state, ctx);
+ }
+
+ pub fn label(&self) -> &str {
+ self.input.textbox.content()
+ }
+}
+
+impl Component for LabelKeyboard {
+ type Msg = LabelKeyboardMsg;
+
+ fn place(&mut self, bounds: Rect) -> Rect {
+ // assert full screen
+ debug_assert_eq!(bounds.height(), SCREEN.height());
+ debug_assert_eq!(bounds.width(), SCREEN.width());
+
+ // Enable swiping over the entire screen.
+ self.page_swipe.place(bounds);
+
+ // Keypad and input areas are overlapped
+ let (_, keypad_area) = bounds.split_bottom(KEYPAD_VISIBLE_HEIGHT);
+ let (top_area, _) = bounds.split_top(INPUT_TOUCH_HEIGHT);
+
+ let (input_area, next_btn_area) =
+ top_area.split_right(NEXT_BTN_WIDTH + 2 * NEXT_BTN_PADDING);
+
+ let next_btn_area = next_btn_area.inset(NEXT_BTN_INSETS);
+
+ self.input.place(input_area);
+ self.input_prompt
+ .place(top_area.inset(KEYBOARD_INPUT_INSETS));
+ self.keypad.place(keypad_area);
+ self.next_btn.place(next_btn_area);
+
+ bounds
+ }
+
+ fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
+ match event {
+ Event::Attach(_) => {
+ // Update the keypad state in the first event
+ self.update_keypad_state(ctx);
+ }
+ Event::Timer(_) if self.multi_tap.timeout_event(event) => {
+ self.multi_tap.clear_pending_state(ctx);
+ self.input.display_style = DisplayStyle::OneLine;
+ // Disable keypad when the label reached the max length
+ if self.label().len() == self.max_len {
+ self.update_keypad_state(ctx);
+ }
+ return None;
+ }
+
+ _ => {}
+ }
+
+ // Input event has to be handled before the swipe so that swipe in the input
+ // area is not processed
+ match self.input.event(ctx, event) {
+ Some(LabelInputMsg::TouchStart) => {
+ self.multi_tap.clear_pending_state(ctx);
+ // Disable keypad.
+ self.update_keypad_state(ctx);
+ return None;
+ }
+ Some(LabelInputMsg::TouchEnd) => {
+ // Enable keypad.
+ self.update_keypad_state(ctx);
+ return None;
+ }
+ _ => {}
+ }
+
+ // Swipe event has to be handled before the individual button events
+ if let Some(swipe) = self.page_swipe.event(ctx, event) {
+ match swipe {
+ Direction::Left | Direction::Right => {
+ // We have detected a horizontal swipe. Change the keyboard page.
+ self.on_page_change(ctx, swipe);
+ return None;
+ }
+ _ => {}
+ }
+ }
+
+ if let Some(ButtonMsg::Clicked) = self.next_btn.event(ctx, event) {
+ self.on_page_change(ctx, Direction::Left);
+ }
+
+ match self.keypad.event(ctx, event) {
+ Some(KeypadMsg::Key(idx)) => {
+ if let Some(text) = Self::key_text(self.keypad.get_key_content(idx)) {
+ let edit = text.map(|c| self.multi_tap.click_key(ctx, idx, c));
+ self.input.textbox.apply(ctx, edit);
+ if text.len() == 1 {
+ // If the key has just one character, it is immediately applied
+ self.input.display_style = DisplayStyle::OneLine;
+ } else {
+ // multi tap timer is running, the last digit timer should be stopped
+ self.input.display_style = DisplayStyle::OneLineWithMarker;
+ }
+ self.update_keypad_state(ctx);
+ }
+ return None;
+ }
+ Some(KeypadMsg::EraseShort) => {
+ self.multi_tap.clear_pending_state(ctx);
+ self.input.textbox.delete_last(ctx);
+ self.input.display_style = DisplayStyle::OneLine;
+ self.update_keypad_state(ctx);
+ return None;
+ }
+ Some(KeypadMsg::EraseLong) => {
+ self.multi_tap.clear_pending_state(ctx);
+ self.input.textbox.clear(ctx);
+ self.input.display_style = DisplayStyle::OneLine;
+ self.update_keypad_state(ctx);
+ return None;
+ }
+ Some(KeypadMsg::Cancel) => {
+ return Some(LabelKeyboardMsg::Cancelled);
+ }
+ Some(KeypadMsg::Confirm) => {
+ return Some(LabelKeyboardMsg::Confirmed(unwrap!(ShortString::try_from(
+ self.label()
+ ))));
+ }
+ _ => {}
+ }
+
+ None
+ }
+
+ fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
+ let empty = self.label().is_empty();
+
+ // Render prompt when the pin is empty
+ if empty {
+ self.input_prompt.render(target);
+ }
+
+ // When the entire label is shown, the input area might overlap the keypad
+ // so it has to be render later
+ match self.input.display_style {
+ DisplayStyle::Complete => {
+ self.keypad.render(target);
+ self.input.render(target);
+ }
+ _ => {
+ // When the next button is shown, the input area might overlap the keypad so it
+ // has to be render later
+ self.input.render(target);
+
+ if self.next_btn.is_pressed() {
+ self.keypad.render(target);
+ self.next_btn.render(target);
+ } else {
+ self.next_btn.render(target);
+ self.keypad.render(target);
+ }
+ }
+ }
+ }
+}
+
+#[derive(PartialEq, Debug, Copy, Clone)]
+#[cfg_attr(feature = "ui_debug", derive(ufmt::derive::uDebug))]
+pub enum LabelInputMsg {
+ TouchStart,
+ TouchEnd,
+}
+
+struct LabelInput {
+ area: Rect,
+ textbox: TextBox,
+ display_style: DisplayStyle,
+ shown_area: Rect,
+}
+
+impl LabelInput {
+ const TWITCH: i16 = 4;
+ const SHOWN_INSETS: Insets = Insets::new(12, 24, 12, 24);
+ const SHOWN_STYLE: TextStyle =
+ theme::TEXT_REGULAR.with_line_breaking(LineBreaking::BreakWordsNoHyphen);
+ const SHOWN_TOUCH_OUTSET: Insets = Insets::bottom(200);
+ const ICON: Icon = theme::ICON_DASH_VERTICAL;
+ const ICON_WIDTH: i16 = Self::ICON.toif.width();
+ const ICON_SPACE: i16 = 12;
+
+ fn new(max_len: usize, prefill: Option<TString<'static>>) -> Self {
+ let textbox = if let Some(prefill) = prefill {
+ prefill.map(|s| TextBox::new(s, max_len))
+ } else {
+ TextBox::empty(max_len)
+ };
+
+ Self {
+ area: Rect::zero(),
+ textbox,
+ display_style: DisplayStyle::OneLine,
+ shown_area: Rect::zero(),
+ }
+ }
+
+ fn label(&self) -> &str {
+ self.textbox.content()
+ }
+
+ fn update_shown_area(&mut self) {
+ // The area where the label is shown
+ let mut shown_area = Rect::from_top_left_and_size(
+ self.area.top_left(),
+ Offset::new(SCREEN.width(), self.area.height()),
+ )
+ .inset(KEYBOARD_INPUT_INSETS);
+
+ // Extend the shown area until the text fits
+ while let LayoutFit::OutOfBounds { .. } = TextLayout::new(Self::SHOWN_STYLE)
+ .with_align(Alignment::Start)
+ .with_bounds(shown_area.inset(Self::SHOWN_INSETS))
+ .fit_text(self.label())
+ {
+ shown_area =
+ shown_area.outset(Insets::bottom(Self::SHOWN_STYLE.text_font.line_height()));
+ }
+
+ self.shown_area = shown_area;
+ }
+
+ fn render_complete<'s>(&self, target: &mut impl Renderer<'s>) {
+ // Make sure the pin should be shown
+ debug_assert_eq!(self.display_style, DisplayStyle::Complete);
+
+ Bar::new(self.shown_area)
+ .with_bg(theme::GREY_SUPER_DARK)
+ .with_radius(KEYBOARD_INPUT_RADIUS)
+ .render(target);
+
+ TextLayout::new(Self::SHOWN_STYLE)
+ .with_bounds(self.shown_area.inset(Self::SHOWN_INSETS))
+ .with_align(Alignment::Start)
+ .render_text(self.label(), target, true);
+ }
+
+ fn render_one_line<'s>(&self, target: &mut impl Renderer<'s>) {
+ debug_assert_ne!(self.display_style, DisplayStyle::Complete);
+
+ let insets = Insets::new(
+ KEYBOARD_INPUT_INSETS.top,
+ 0,
+ KEYBOARD_INPUT_INSETS.bottom,
+ KEYBOARD_INPUT_INSETS.left,
+ );
+
+ let area: Rect = self.area.inset(insets);
+ let style = theme::TEXT_REGULAR;
+
+ // Find out how much text can fit into the textbox.
+ // Accounting for the pending marker, which draws itself one pixel longer than
+ // the last character
+ let available_area_width = area.width() - 1;
+ let text_to_display = long_line_content_with_ellipsis(
+ self.label(),
+ "...",
+ style.text_font,
+ available_area_width,
+ );
+
+ let cursor = area
+ .left_center()
+ .ofs(Offset::new(8, style.text_font.text_max_height() / 2 - 4));
+
+ Text::new(cursor, &text_to_display, style.text_font)
+ .with_fg(style.text_color)
+ .render(target);
+
+ // Paint the pending marker.
+ if self.display_style == DisplayStyle::OneLineWithMarker {
+ render_pending_marker(
+ target,
+ cursor,
+ &text_to_display,
+ style.text_font,
+ style.text_color,
+ );
+ }
+ // let pp_len = self.label().len();
+ // let last_char = self.display_style != DisplayStyle::Hidden;
+
+ // // Render only when there are characters
+ // if pp_len == 0 {
+ // return;
+ // }
+ // // Number of visible icons + characters
+ // let visible_len = pp_len.min(MAX_SHOWN_LEN);
+ // // Number of visible icons
+ // let visible_icons = visible_len - last_char as usize;
+
+ // // Jiggle when overflowed.
+ // if pp_len > visible_len && pp_len % 2 == 0 && self.display_style !=
+ // DisplayStyle::Shown { cursor.x += Self::TWITCH;
+ // }
+
+ // let mut char_idx = 0;
+
+ // // Greyed out overflowing icons
+ // for (i, &fg_color) in FADING_ICON_COLORS.iter().enumerate() {
+ // if pp_len > visible_len + (FADING_ICON_COUNT - 1 - i) {
+ // ToifImage::new(cursor, Self::ICON.toif)
+ // .with_align(Alignment2D::TOP_LEFT)
+ // .with_fg(fg_color)
+ // .render(target);
+ // cursor.x += Self::ICON_SPACE + Self::ICON_WIDTH;
+ // char_idx += 1;
+ // }
+ // }
+
+ // if visible_icons > 0 {
+ // // Classical dot(s)
+ // for _ in char_idx..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;
+ // }
+ // }
+
+ // if last_char {
+ // // This should not fail because pp_len > 0
+ // let last = &self.label()[(pp_len - 1)..pp_len];
+
+ // // Adapt x and y positions for the character
+ // cursor.y = hidden_area.left_center().y +
+ // style.text_font.text_max_height() / 2; cursor.x -=
+ // Self::ICON_WIDTH;
+
+ // // Paint the last character
+ // Text::new(cursor, last, style.text_font)
+ // .with_align(Alignment::Start)
+ // .with_fg(style.text_color)
+ // .render(target);
+
+ // // Paint the pending marker.
+ // if self.display_style == DisplayStyle::LastWithMarker {
+ // render_pending_marker(target, cursor, last, style.text_font,
+ // style.text_color); }
+ // }
+ }
+}
+
+impl Component for LabelInput {
+ type Msg = LabelInputMsg;
+
+ fn place(&mut self, bounds: Rect) -> Rect {
+ self.area = bounds;
+ bounds
+ }
+
+ fn event(&mut self, _ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
+ // No touch events are handled when the textbox is empty
+ if self.textbox.is_empty() {
+ return None;
+ }
+
+ // Extend the label area downward to allow touch input without the finger
+ // covering the label
+ let extended_shown_area = self
+ .shown_area
+ .outset(Self::SHOWN_TOUCH_OUTSET)
+ .clamp(SCREEN);
+
+ match event {
+ // Return touch start if the touch is detected inside the touchable area
+ Event::Touch(TouchEvent::TouchStart(pos)) if self.area.contains(pos) => {
+ // Show the entire label on the touch start
+ self.display_style = DisplayStyle::Complete;
+ self.update_shown_area();
+ return Some(LabelInputMsg::TouchStart);
+ }
+ // Return touch end if the touch end is detected inside the visible area
+ Event::Touch(TouchEvent::TouchEnd(pos))
+ if extended_shown_area.contains(pos)
+ && self.display_style == DisplayStyle::Complete =>
+ {
+ self.display_style = DisplayStyle::OneLine;
+ return Some(LabelInputMsg::TouchEnd);
+ }
+ // Return touch end if the touch moves out of the visible area
+ Event::Touch(TouchEvent::TouchMove(pos))
+ if !extended_shown_area.contains(pos)
+ && self.display_style == DisplayStyle::Complete =>
+ {
+ self.display_style = DisplayStyle::OneLine;
+ return Some(LabelInputMsg::TouchEnd);
+ }
+ _ => {}
+ };
+ None
+ }
+
+ fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
+ if !self.label().is_empty() {
+ match self.display_style {
+ DisplayStyle::Complete => self.render_complete(target),
+ _ => self.render_one_line(target),
+ }
+ }
+ }
+}
+
+#[cfg(feature = "micropython")]
+impl Swipable for LabelKeyboard {
+ fn get_swipe_config(&self) -> SwipeConfig {
+ self.swipe_config
+ }
+
+ fn get_pager(&self) -> Pager {
+ Pager::single_page()
+ }
+}
+
+#[cfg(feature = "ui_debug")]
+impl crate::trace::Trace for LabelKeyboard {
+ fn trace(&self, t: &mut dyn crate::trace::Tracer) {
+ let display_style = uformat!("{:?}", self.input.display_style);
+ let active_layout = uformat!("{:?}", self.active_layout);
+ t.component("LabelKeyboard");
+ t.string("label", self.label().into());
+ t.string("display_style", display_style.as_str().into());
+ t.string("active_layout", active_layout.as_str().into());
+ }
+}
diff --git a/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/mod.rs b/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/mod.rs
index 26bcc1cd..756b6621 100644
--- a/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/mod.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/mod.rs
@@ -1,4 +1,5 @@
pub mod bip39;
+pub mod label;
pub mod mnemonic;
pub mod passphrase;
pub mod pin;
diff --git a/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/passphrase.rs b/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/passphrase.rs
index 132e29b0..edf71d61 100644
--- a/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/passphrase.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/passphrase.rs
@@ -1,7 +1,6 @@
use crate::{
strutil::{ShortString, TString},
time::Duration,
- translations::TR,
ui::{
component::{
swipe_detect::SwipeConfig,
@@ -26,8 +25,8 @@ use super::super::{
constant::SCREEN,
keyboard::{
common::{
- render_pending_marker, MultiTapKeyboard, FADING_ICON_COLORS, FADING_ICON_COUNT,
- INPUT_TOUCH_HEIGHT, KEYBOARD_INPUT_INSETS, KEYBOARD_INPUT_RADIUS,
+ render_pending_marker, KeyboardLayout, MultiTapKeyboard, FADING_ICON_COLORS,
+ FADING_ICON_COUNT, INPUT_TOUCH_HEIGHT, KEYBOARD_INPUT_INSETS, KEYBOARD_INPUT_RADIUS,
KEYPAD_VISIBLE_HEIGHT,
},
keypad::{ButtonState, Keypad, KeypadButton, KeypadMsg, KeypadState},
@@ -40,50 +39,6 @@ pub enum PassphraseKeyboardMsg {
Cancelled,
}
-/// Enum keeping track of which keyboard is shown and which comes next. Keep the
-/// number of values and the constant PAGE_COUNT in synch.
-#[repr(u32)]
-#[derive(Copy, Clone, PartialEq)]
-#[cfg_attr(feature = "ui_debug", derive(ufmt::derive::uDebug))]
-enum KeyboardLayout {
- LettersLower = 0,
- LettersUpper = 1,
- Numeric = 2,
- Special = 3,
-}
-
-impl KeyboardLayout {
- fn next(self) -> Self {
- match self {
- Self::LettersLower => Self::LettersUpper,
- Self::LettersUpper => Self::Numeric,
- Self::Numeric => Self::Special,
- Self::Special => Self::LettersLower,
- }
- }
-
- fn prev(self) -> Self {
- match self {
- Self::LettersLower => Self::Special,
- Self::LettersUpper => Self::LettersLower,
- Self::Numeric => Self::LettersUpper,
- Self::Special => Self::Numeric,
- }
- }
-}
-
-impl From<KeyboardLayout> for ButtonContent {
- /// Used to get content for the "next keyboard" button
- fn from(kl: KeyboardLayout) -> Self {
- match kl {
- KeyboardLayout::LettersLower => ButtonContent::single_line_text("abc".into()),
- KeyboardLayout::LettersUpper => ButtonContent::single_line_text("ABC".into()),
- KeyboardLayout::Numeric => ButtonContent::single_line_text("123".into()),
- KeyboardLayout::Special => ButtonContent::Icon(theme::ICON_ASTERISK),
- }
- }
-}
-
pub struct PassphraseKeyboard {
page_swipe: Swipe,
input: PassphraseInput,
@@ -93,6 +48,7 @@ pub struct PassphraseKeyboard {
active_layout: KeyboardLayout,
swipe_config: SwipeConfig,
multi_tap: MultiTapKeyboard,
+ max_len: usize,
}
const PAGE_COUNT: usize = 4;
@@ -105,7 +61,6 @@ const KEYBOARD: [[&str; KEY_COUNT]; PAGE_COUNT] = [
["_<>", ".:@", "/|\\", "!()", "+%&", "-[]", "?{}", ",'`", ";\"~", "$^="],
];
-const MAX_LENGTH: usize = 50; // max length of the passphrase
const MAX_SHOWN_LEN: usize = 13; // max number of icons per line
const LAST_DIGIT_TIMEOUT: Duration = Duration::from_secs(1);
@@ -115,7 +70,7 @@ const NEXT_BTN_INSETS: Insets =
Insets::new(NEXT_BTN_PADDING, NEXT_BTN_PADDING, 0, NEXT_BTN_PADDING);
impl PassphraseKeyboard {
- pub fn new() -> Self {
+ pub fn new(prompt: TString<'static>, max_len: usize) -> Self {
let active_layout = KeyboardLayout::LettersLower;
let layout: &[&str; KEY_COUNT] = &KEYBOARD[active_layout as usize];
let keypad_content: [ButtonContent; KEY_COUNT] =
@@ -129,17 +84,15 @@ impl PassphraseKeyboard {
Self {
page_swipe: Swipe::horizontal(),
- input: PassphraseInput::new(),
- input_prompt: Label::left_aligned(
- TString::from_translation(TR::passphrase__title_enter),
- theme::firmware::TEXT_SMALL,
- )
- .vertically_centered(),
+ input: PassphraseInput::new(max_len),
+ input_prompt: Label::left_aligned(prompt, theme::firmware::TEXT_SMALL)
+ .vertically_centered(),
next_btn,
keypad: Keypad::new_shown().with_keys_content(&keypad_content),
active_layout,
swipe_config: SwipeConfig::new(),
multi_tap: MultiTapKeyboard::new(),
+ max_len,
}
}
@@ -216,7 +169,7 @@ impl PassphraseKeyboard {
}
}
_ => {
- if self.passphrase().len() == MAX_LENGTH {
+ if self.passphrase().len() == self.max_len {
if let Some(pending_key) = self.multi_tap.pending_key() {
// Disable all except of confirm, erase and the pending key
KeypadState {
@@ -309,7 +262,7 @@ impl Component for PassphraseKeyboard {
self.input.last_char_timer.start(ctx, LAST_DIGIT_TIMEOUT);
self.input.display_style = DisplayStyle::LastOnly;
// Disable keypad when the passphrase reached the max length
- if self.passphrase().len() == MAX_LENGTH {
+ if self.passphrase().len() == self.max_len {
self.update_keypad_state(ctx);
}
return None;
@@ -455,10 +408,10 @@ impl PassphraseInput {
const ICON_WIDTH: i16 = Self::ICON.toif.width();
const ICON_SPACE: i16 = 12;
- fn new() -> Self {
+ fn new(max_len: usize) -> Self {
Self {
area: Rect::zero(),
- textbox: TextBox::empty(MAX_LENGTH),
+ textbox: TextBox::empty(max_len),
display_style: DisplayStyle::Hidden,
last_char_timer: Timer::new(),
shown_area: Rect::zero(),
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 be8e8413..e56ee4c1 100644
--- a/core/embed/rust/src/ui/layout_eckhart/firmware/mod.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/firmware/mod.rs
@@ -32,6 +32,7 @@ pub use hold_to_confirm::HoldToConfirmAnim;
pub use homescreen::{check_homescreen_format, Homescreen, HomescreenMsg};
pub use keyboard::{
bip39::Bip39Input,
+ label::{LabelKeyboard, LabelKeyboardMsg},
mnemonic::{MnemonicInput, MnemonicKeyboard, MnemonicKeyboardMsg},
passphrase::{PassphraseKeyboard, PassphraseKeyboardMsg},
pin::{PinKeyboard, PinKeyboardMsg},
diff --git a/core/embed/rust/src/ui/layout_eckhart/flow/mod.rs b/core/embed/rust/src/ui/layout_eckhart/flow/mod.rs
index cc289543..1101f2fc 100644
--- a/core/embed/rust/src/ui/layout_eckhart/flow/mod.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/flow/mod.rs
@@ -12,6 +12,7 @@ pub mod prompt_backup;
pub mod receive;
pub mod request_number;
pub mod request_passphrase;
+pub mod request_string;
pub mod show_danger;
pub mod show_share_words;
pub mod show_thp_pairing_code;
@@ -32,6 +33,7 @@ pub use prompt_backup::PromptBackup;
pub use receive::Receive;
pub use request_number::new_request_number;
pub use request_passphrase::RequestPassphrase;
+pub use request_string::RequestString;
pub use show_danger::ShowDanger;
pub use show_share_words::new_show_share_words_flow;
pub use show_thp_pairing_code::new_show_thp_pairing_code;
diff --git a/core/embed/rust/src/ui/layout_eckhart/flow/request_passphrase.rs b/core/embed/rust/src/ui/layout_eckhart/flow/request_passphrase.rs
index a84397fe..22d4f1a9 100644
--- a/core/embed/rust/src/ui/layout_eckhart/flow/request_passphrase.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/flow/request_passphrase.rs
@@ -1,6 +1,6 @@
use crate::{
error,
- strutil::ShortString,
+ strutil::{ShortString, TString},
translations::TR,
ui::{
component::{
@@ -58,16 +58,17 @@ impl FlowController for RequestPassphrase {
}
}
-pub fn new_request_passphrase() -> Result<SwipeFlow, error::Error> {
+pub fn new_request_passphrase(
+ prompt: TString<'static>,
+ prompt_empty: TString<'static>,
+ max_len: usize,
+) -> Result<SwipeFlow, error::Error> {
let content_confirm_empty = TextScreen::new(
- Paragraph::new(
- &theme::TEXT_REGULAR,
- TR::passphrase__continue_with_empty_passphrase,
- )
- .into_paragraphs()
- .with_placement(LinearPlacement::vertical()),
+ Paragraph::new(&theme::TEXT_REGULAR, prompt_empty)
+ .into_paragraphs()
+ .with_placement(LinearPlacement::vertical()),
)
- .with_header(Header::new(TR::passphrase__title_enter.into()))
+ .with_header(Header::new(prompt))
.with_action_bar(ActionBar::new_double(
Button::with_icon(theme::ICON_CHEVRON_LEFT),
Button::with_text(TR::buttons__confirm.into()),
@@ -79,7 +80,7 @@ pub fn new_request_passphrase() -> Result<SwipeFlow, error::Error> {
_ => Some(FlowMsg::Cancelled),
});
- let content_keypad = PassphraseKeyboard::new().map(|msg| match msg {
+ let content_keypad = PassphraseKeyboard::new(prompt, max_len).map(|msg| match msg {
PassphraseKeyboardMsg::Confirmed(s) => Some(FlowMsg::Text(s)),
PassphraseKeyboardMsg::Cancelled => Some(FlowMsg::Cancelled),
});
diff --git a/core/embed/rust/src/ui/layout_eckhart/flow/request_string.rs b/core/embed/rust/src/ui/layout_eckhart/flow/request_string.rs
new file mode 100644
index 00000000..8bdcc245
--- /dev/null
+++ b/core/embed/rust/src/ui/layout_eckhart/flow/request_string.rs
@@ -0,0 +1,95 @@
+use crate::{
+ error,
+ strutil::{ShortString, TString},
+ translations::TR,
+ ui::{
+ component::{
+ text::paragraphs::{Paragraph, ParagraphSource},
+ ComponentExt,
+ },
+ flow::{
+ base::{Decision, DecisionBuilder as _},
+ FlowController, FlowMsg, SwipeFlow,
+ },
+ geometry::{Direction, LinearPlacement},
+ },
+};
+
+use super::super::{
+ component::Button,
+ firmware::{ActionBar, Header, LabelKeyboard, LabelKeyboardMsg, TextScreen, TextScreenMsg},
+ theme,
+};
+
+#[derive(Copy, Clone, PartialEq, Eq)]
+pub enum RequestString {
+ Keypad,
+ ConfirmEmpty,
+}
+
+impl FlowController for RequestString {
+ #[inline]
+ fn index(&'static self) -> usize {
+ *self as usize
+ }
+
+ fn handle_swipe(&'static self, _direction: Direction) -> Decision {
+ self.do_nothing()
+ }
+
+ fn handle_event(&'static self, msg: FlowMsg) -> Decision {
+ match (self, msg) {
+ (Self::Keypad, FlowMsg::Text(s)) => {
+ if s.is_empty() {
+ Self::ConfirmEmpty.goto()
+ } else {
+ self.return_msg(FlowMsg::Text(s))
+ }
+ }
+ (Self::Keypad, FlowMsg::Cancelled) => self.return_msg(FlowMsg::Cancelled),
+ (Self::ConfirmEmpty, FlowMsg::Cancelled) => Self::Keypad.goto(),
+ (Self::ConfirmEmpty, FlowMsg::Confirmed) => {
+ self.return_msg(FlowMsg::Text(ShortString::new()))
+ }
+ _ => self.do_nothing(),
+ }
+ }
+}
+
+pub fn new_request_string(
+ prompt: TString<'static>,
+ max_len: usize,
+ allow_empty: bool,
+ prefill: Option<TString<'static>>,
+) -> Result<SwipeFlow, error::Error> {
+ let content_confirm_empty = TextScreen::new(
+ Paragraph::new(
+ &theme::TEXT_REGULAR,
+ TR::device_name__continue_with_empty_label,
+ )
+ .into_paragraphs()
+ .with_placement(LinearPlacement::vertical()),
+ )
+ .with_header(Header::new(prompt))
+ .with_action_bar(ActionBar::new_double(
+ Button::with_icon(theme::ICON_CHEVRON_LEFT),
+ Button::with_text(TR::buttons__confirm.into()),
+ ))
+ .with_page_limit(1)
+ .map(|msg| match msg {
+ TextScreenMsg::Cancelled => Some(FlowMsg::Cancelled),
+ TextScreenMsg::Confirmed => Some(FlowMsg::Confirmed),
+ _ => Some(FlowMsg::Cancelled),
+ });
+
+ let content_keypad =
+ LabelKeyboard::new(prompt, max_len, allow_empty, prefill).map(|msg| match msg {
+ LabelKeyboardMsg::Confirmed(s) => Some(FlowMsg::Text(s)),
+ LabelKeyboardMsg::Cancelled => Some(FlowMsg::Cancelled),
+ });
+
+ let mut res = SwipeFlow::new(&RequestString::Keypad)?;
+ res.add_page(&RequestString::Keypad, content_keypad)?
+ .add_page(&RequestString::ConfirmEmpty, content_confirm_empty)?;
+ Ok(res)
+}
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 7f7aa517..8ab822c2 100644
--- a/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs
@@ -961,10 +961,21 @@ impl FirmwareUI for UIEckhart {
}
fn request_passphrase(
- _prompt: TString<'static>,
- _max_len: u32,
+ prompt: TString<'static>,
+ prompt_empty: TString<'static>,
+ max_len: usize,
) -> Result<impl LayoutMaybeTrace, Error> {
- let flow = flow::request_passphrase::new_request_passphrase()?;
+ let flow = flow::request_passphrase::new_request_passphrase(prompt, prompt_empty, max_len)?;
+ Ok(flow)
+ }
+
+ fn request_string(
+ prompt: TString<'static>,
+ max_len: usize,
+ allow_empty: bool,
+ prefill: Option<TString<'static>>,
+ ) -> Result<impl LayoutMaybeTrace, Error> {
+ let flow = flow::request_string::new_request_string(prompt, max_len, allow_empty, prefill)?;
Ok(flow)
}
@@ -1185,7 +1196,7 @@ impl FirmwareUI for UIEckhart {
fn show_device_menu(
failed_backup: bool,
firmware_version: TString<'static>,
- device_name: TString<'static>,
+ device_name: Option<TString<'static>>,
paired_devices: Vec<TString<'static>, 1>,
auto_lock_delay: TString<'static>,
) -> Result<impl LayoutMaybeTrace, Error> {
diff --git a/core/embed/rust/src/ui/ui_firmware.rs b/core/embed/rust/src/ui/ui_firmware.rs
index 3ebc9d32..0f488cc1 100644
--- a/core/embed/rust/src/ui/ui_firmware.rs
+++ b/core/embed/rust/src/ui/ui_firmware.rs
@@ -287,7 +287,15 @@ pub trait FirmwareUI {
fn request_passphrase(
prompt: TString<'static>,
- max_len: u32,
+ prompt_empty: TString<'static>,
+ max_len: usize,
+ ) -> Result<impl LayoutMaybeTrace, Error>;
+
+ fn request_string(
+ prompt: TString<'static>,
+ max_len: usize,
+ allow_empty: bool,
+ prefill: Option<TString<'static>>,
) -> Result<impl LayoutMaybeTrace, Error>;
fn select_menu(
@@ -353,7 +361,7 @@ pub trait FirmwareUI {
fn show_device_menu(
failed_backup: bool,
firmware_version: TString<'static>,
- device_name: TString<'static>,
+ device_name: Option<TString<'static>>,
paired_devices: Vec<TString<'static>, 1>,
auto_lock_delay: TString<'static>,
) -> Result<impl LayoutMaybeTrace, Error>;
diff --git a/core/mocks/generated/trezorui_api.pyi b/core/mocks/generated/trezorui_api.pyi
index d2888dec..28ac8add 100644
--- a/core/mocks/generated/trezorui_api.pyi
+++ b/core/mocks/generated/trezorui_api.pyi
@@ -497,11 +497,23 @@ def request_pin(
def request_passphrase(
*,
prompt: str,
+ prompt_empty: str,
max_len: int,
) -> LayoutObj[str | UiResult]:
"""Passphrase input keyboard."""
+# rust/src/ui/api/firmware_micropython.rs
+def request_string(
+ *,
+ prompt: str,
+ max_len: int,
+ allow_empty: bool,
+ prefill: str | None,
+) -> LayoutObj[str | UiResult]:
+ """Label input keyboard."""
+
+
# rust/src/ui/api/firmware_micropython.rs
def select_menu(
*,
@@ -612,7 +624,7 @@ def show_device_menu(
*,
failed_backup: bool,
firmware_version: str,
- device_name: str,
+ device_name: str | None,
paired_devices: Iterable[str],
auto_lock_delay: str,
) -> LayoutObj[UiResult | DeviceMenuResult | tuple[DeviceMenuResult, int]]:
@@ -837,3 +849,4 @@ class DeviceMenuResult:
WipeDevice: ClassVar[DeviceMenuResult]
ScreenBrightness: ClassVar[DeviceMenuResult]
AutoLockDelay: ClassVar[DeviceMenuResult]
+ DeviceName: ClassVar[DeviceMenuResult]
diff --git a/core/mocks/trezortranslate_keys.pyi b/core/mocks/trezortranslate_keys.pyi
index 9b6cf40a..3bb4c571 100644
--- a/core/mocks/trezortranslate_keys.pyi
+++ b/core/mocks/trezortranslate_keys.pyi
@@ -250,6 +250,8 @@ class TR:
debug__loading_seed: str = "Loading seed"
debug__loading_seed_not_recommended: str = "Loading private seed is not recommended."
device_name__change_template: str = "Change device name to {0}?"
+ device_name__continue_with_empty_label: str = "Continue with empty device name?"
+ device_name__enter: str = "Enter device name"
device_name__title: str = "Device name"
entropy__send: str = "Do you really want to send entropy?"
entropy__title_confirm: str = "Confirm entropy"
@@ -1003,6 +1005,7 @@ class TR:
words__keep_it_safe: str = "Keep it safe!"
words__know_what_your_doing: str = "Continue only if you know what you are doing!"
words__my_trezor: str = "My Trezor"
+ words__name: str = "Name"
words__no: str = "No"
words__not_recommended: str = "Not recommended!"
words__operation_cancelled: str = "Operation cancelled"
diff --git a/core/src/apps/homescreen/device_menu.py b/core/src/apps/homescreen/device_menu.py
index 9c9d5b6d..2f81e522 100644
--- a/core/src/apps/homescreen/device_menu.py
+++ b/core/src/apps/homescreen/device_menu.py
@@ -39,7 +39,11 @@ async def handle_device_menu() -> None:
paired_devices = ["Trezor Suite"] if ble.is_connected() else []
# ###
firmware_version = ".".join(map(str, utils.VERSION))
- device_name = storage.device.get_label() or "Trezor"
+ device_name = (
+ (storage.device.get_label() or "Trezor")
+ if storage.device.is_initialized()
+ else None
+ )
auto_lock_ms = storage.device.get_autolock_delay_ms()
auto_lock_delay = strings.format_autolock_duration(auto_lock_ms)
@@ -81,6 +85,23 @@ async def handle_device_menu() -> None:
auto_lock_delay_ms = await _prompt_auto_lock_delay()
storage.device.set_autolock_delay_ms(auto_lock_delay_ms)
+ elif menu_result is DeviceMenuResult.DeviceName:
+ from trezor.messages import ApplySettings
+
+ from apps.management.apply_settings import apply_settings
+
+ assert storage.device.is_initialized()
+ label = await interact(
+ trezorui_api.request_string(
+ prompt=TR.device_name__enter,
+ max_len=storage.device.LABEL_MAXLENGTH,
+ allow_empty=False,
+ prefill=storage.device.get_label(),
+ ),
+ "device_name",
+ )
+ assert isinstance(label, str)
+ await apply_settings(ApplySettings(label=label))
elif isinstance(menu_result, tuple):
# It's a tuple with (result_type, index)
result_type, index = menu_result
diff --git a/core/src/trezor/ui/layouts/bolt/__init__.py b/core/src/trezor/ui/layouts/bolt/__init__.py
index 4a40502d..cc8467cb 100644
--- a/core/src/trezor/ui/layouts/bolt/__init__.py
+++ b/core/src/trezor/ui/layouts/bolt/__init__.py
@@ -1635,7 +1635,9 @@ def show_wait_text(message: str) -> None:
async def request_passphrase_on_device(max_len: int) -> str:
result = await interact(
trezorui_api.request_passphrase(
- prompt=TR.passphrase__title_enter, max_len=max_len
+ prompt=TR.passphrase__title_enter,
+ prompt_empty=TR.passphrase__continue_with_empty_passphrase,
+ max_len=max_len,
),
"passphrase_device",
ButtonRequestType.PassphraseEntry,
diff --git a/core/src/trezor/ui/layouts/caesar/__init__.py b/core/src/trezor/ui/layouts/caesar/__init__.py
index 5ed71baa..05f6e69a 100644
--- a/core/src/trezor/ui/layouts/caesar/__init__.py
+++ b/core/src/trezor/ui/layouts/caesar/__init__.py
@@ -1636,6 +1636,7 @@ async def request_passphrase_on_device(max_len: int) -> str:
result = await interact(
trezorui_api.request_passphrase(
prompt=TR.passphrase__title_enter,
+ prompt_empty=TR.passphrase__continue_with_empty_passphrase,
max_len=max_len,
),
"passphrase_device",
diff --git a/core/src/trezor/ui/layouts/delizia/__init__.py b/core/src/trezor/ui/layouts/delizia/__init__.py
index e80645c0..52923167 100644
--- a/core/src/trezor/ui/layouts/delizia/__init__.py
+++ b/core/src/trezor/ui/layouts/delizia/__init__.py
@@ -1555,7 +1555,9 @@ def show_wait_text(message: str) -> None:
def request_passphrase_on_device(max_len: int) -> Awaitable[str]:
result = interact(
trezorui_api.request_passphrase(
- prompt=TR.passphrase__title_enter, max_len=max_len
+ prompt=TR.passphrase__title_enter,
+ prompt_empty=TR.passphrase__continue_with_empty_passphrase,
+ max_len=max_len,
),
"passphrase_device",
ButtonRequestType.PassphraseEntry,
diff --git a/core/src/trezor/ui/layouts/eckhart/__init__.py b/core/src/trezor/ui/layouts/eckhart/__init__.py
index d4fc6f7c..e5f69e1f 100644
--- a/core/src/trezor/ui/layouts/eckhart/__init__.py
+++ b/core/src/trezor/ui/layouts/eckhart/__init__.py
@@ -1614,7 +1614,9 @@ def show_wait_text(message: str) -> None:
def request_passphrase_on_device(max_len: int) -> Awaitable[str]:
result = interact(
trezorui_api.request_passphrase(
- prompt=TR.passphrase__title_enter, max_len=max_len
+ prompt=TR.passphrase__title_enter,
+ prompt_empty=TR.passphrase__continue_with_empty_passphrase,
+ max_len=max_len,
),
"passphrase_device",
ButtonRequestType.PassphraseEntry,
diff --git a/core/translations/en.json b/core/translations/en.json
index 013c0225..91d33029 100644
--- a/core/translations/en.json
+++ b/core/translations/en.json
@@ -282,6 +282,8 @@
"debug__loading_seed": "Loading seed",
"debug__loading_seed_not_recommended": "Loading private seed is not recommended.",
"device_name__change_template": "Change device name to {0}?",
+ "device_name__continue_with_empty_label": "Continue with empty device name?",
+ "device_name__enter": "Enter device name",
"device_name__title": "Device name",
"entropy__send": "Do you really want to send entropy?",
"entropy__title_confirm": "Confirm entropy",
@@ -1220,6 +1222,7 @@
"words__keep_it_safe": "Keep it safe!",
"words__know_what_your_doing": "Continue only if you know what you are doing!",
"words__my_trezor": "My Trezor",
+ "words__name": "Name",
"words__no": "No",
"words__not_recommended": "Not recommended!",
"words__operation_cancelled": "Operation cancelled",
diff --git a/core/translations/order.json b/core/translations/order.json
index 329d2dca..012925ee 100644
--- a/core/translations/order.json
+++ b/core/translations/order.json
@@ -1069,5 +1069,8 @@
"1067": "tutorial__welcome_safe7",
"1068": "tutorial__what_is_tropic",
"1069": "tutorial__tap_to_start",
- "1070": "tutorial__tropic_info"
+ "1070": "tutorial__tropic_info",
+ "1071": "device_name__enter",
+ "1072": "words__name",
+ "1073": "device_name__continue_with_empty_label"
}
diff --git a/core/translations/signatures.json b/core/translations/signatures.json
index 087aa083..f8c75a81 100644
--- a/core/translations/signatures.json
+++ b/core/translations/signatures.json
@@ -1,8 +1,8 @@
{
"current": {
- "merkle_root": "a974f788429324a9235ac786eed3d60ada3250eaae25c61d2fdd53462f271ab9",
- "datetime": "2025-08-12T08:59:14.686679+00:00",
- "commit": "a1dfa64d3fc5e6f1f9e7ff035eaa1d50d7abc392"
+ "merkle_root": "e332650b4a7a414886d6b719f0e810691cba9ba3dd3ebea3b36651b428e6ae55",
+ "datetime": "2025-08-18T14:37:50.578121+00:00",
+ "commit": "dcd2735e2a8f1d21e4202495007ccfdddcab70d6"
},
"history": [
{
Why this scored 19/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.