chore(core/eckhart): redesign connected item
What changed, and why it matters
This commit is a routine user-interface redesign for the Trezor hardware wallet (specifically the T3W1/Eckhart layout). It moves the name of the connected app from the Bluetooth connection button onto a separate 'Host Info' screen, and adjusts how long text is clipped in buttons. There is no security-relevant change here.
No security action required. Treat as normal UI/UX maintenance.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The patch refactors the Eckhart UI layout’s Bluetooth device menu. It removes the app-name subtext from connection list items, adds an ‘Apps connected’ paragraph to the Host Info screen, and introduces a new text-clipping helper (longest_prefix_break_words) plus a break_words rendering mode for two-line button labels. Translation keys and signatures are regenerated accordingly. No cryptographic, authentication, or trust-boundary logic is modified.
Changed components
core/embed/rust/src/ui/layout_eckhart/component/button.rscore/embed/rust/src/ui/layout_eckhart/firmware/device_menu_screen.rscore/embed/rust/src/ui/display/font.rscore/translations/en.jsoncore/translations/signatures.jsonInspect captured patch +340 / −170
diff --git a/core/.changelog.d/5870.changed b/core/.changelog.d/5870.changed
new file mode 100644
index 00000000..073dd118
--- /dev/null
+++ b/core/.changelog.d/5870.changed
@@ -0,0 +1 @@
+[T3W1] Moved app name from the connection button to the Host Info screen.
diff --git a/core/embed/rust/librust_qstr.h b/core/embed/rust/librust_qstr.h
index 9e808a0f..06abe555 100644
--- a/core/embed/rust/librust_qstr.h
+++ b/core/embed/rust/librust_qstr.h
@@ -181,6 +181,7 @@ static void _librust_qstrs(void) {
MP_QSTR_bitcoin__unverified_external_inputs;
MP_QSTR_bitcoin__valid_signature;
MP_QSTR_bitcoin__voting_rights;
+ MP_QSTR_ble__apps_connected;
MP_QSTR_ble__disable;
MP_QSTR_ble__enable;
MP_QSTR_ble__forget_all;
diff --git a/core/embed/rust/src/translations/generated/translated_string.rs b/core/embed/rust/src/translations/generated/translated_string.rs
index 539985e9..a8e436ce 100644
--- a/core/embed/rust/src/translations/generated/translated_string.rs
+++ b/core/embed/rust/src/translations/generated/translated_string.rs
@@ -1551,6 +1551,7 @@ pub enum TranslatedString {
ble__host_info = 1161, // "Host info"
ble__mac_address = 1162, // "MAC address"
ble__waiting_for_host = 1163, // "Waiting for host..."
+ ble__apps_connected = 1164, // "Apps connected"
}
impl TranslatedString {
@@ -3536,6 +3537,7 @@ impl TranslatedString {
(Self::ble__host_info, "Host info"),
(Self::ble__mac_address, "MAC address"),
(Self::ble__waiting_for_host, "Waiting for host..."),
+ (Self::ble__apps_connected, "Apps connected"),
];
#[cfg(feature = "micropython")]
@@ -3615,6 +3617,7 @@ impl TranslatedString {
(Qstr::MP_QSTR_bitcoin__unverified_external_inputs, Self::bitcoin__unverified_external_inputs),
(Qstr::MP_QSTR_bitcoin__valid_signature, Self::bitcoin__valid_signature),
(Qstr::MP_QSTR_bitcoin__voting_rights, Self::bitcoin__voting_rights),
+ (Qstr::MP_QSTR_ble__apps_connected, Self::ble__apps_connected),
(Qstr::MP_QSTR_ble__disable, Self::ble__disable),
(Qstr::MP_QSTR_ble__enable, Self::ble__enable),
(Qstr::MP_QSTR_ble__forget_all, Self::ble__forget_all),
diff --git a/core/embed/rust/src/ui/display/font.rs b/core/embed/rust/src/ui/display/font.rs
index eb36800c..e6ebc6f5 100644
--- a/core/embed/rust/src/ui/display/font.rs
+++ b/core/embed/rust/src/ui/display/font.rs
@@ -342,7 +342,7 @@ impl FontInfo {
// Another character would not fit => split at the previous word boundary
return &text[0..prev_word_boundary];
}
- if c == ' ' || c == ':' {
+ if c == ' ' {
prev_word_boundary = i;
}
text_width += c_width;
@@ -351,6 +351,27 @@ impl FontInfo {
})
}
+ /// Get the longest prefix of a given `text` (breaking at letter boundaries)
+ /// that will fit into the area `width` pixels wide.
+ pub fn longest_prefix_break_words<'a>(&'static self, width: i16, text: &'a str) -> &'a str {
+ let mut prefix = text;
+ loop {
+ if self.text_width(prefix) <= width {
+ return prefix;
+ }
+ // remove exactly one UTF-8 char from the end
+ if let Some((i, _)) = prefix.char_indices().next_back() {
+ if i == 0 {
+ return "";
+ }
+ debug_assert!(prefix.is_char_boundary(i));
+ prefix = &prefix[..i];
+ } else {
+ return ""; // empty string
+ }
+ }
+ }
+
/// Get the length of the longest suffix from a given `text`
/// that will fit into the area `width` pixels wide.
pub fn longest_suffix(&'static self, width: i16, text: &str) -> usize {
@@ -401,3 +422,75 @@ impl GlyphMetrics for Font {
FontInfo::line_height(self)
}
}
+
+#[cfg(test)]
+mod tests {
+
+ cfg_if::cfg_if! {
+ if #[cfg(feature = "layout_bolt")] {
+ use crate::ui::layout_bolt::fonts::FONT_NORMAL as FONT;
+ } else if #[cfg(feature = "layout_caesar")] {
+ use crate::ui::layout_caesar::fonts::FONT_NORMAL as FONT;
+ } else if #[cfg(feature = "layout_delizia")] {
+ use crate::ui::layout_delizia::fonts::FONT_DEMIBOLD as FONT;
+ } else if #[cfg(feature = "layout_eckhart")] {
+ use crate::ui::layout_eckhart::fonts::FONT_SATOSHI_MEDIUM_26 as FONT;
+ } else {
+ compile_error!("Non supported layout feature enabled");
+ }
+ }
+
+ #[test]
+ fn longest_prefix_break_words_ascii() {
+ let text = "Hello world";
+
+ let w_h = FONT.text_width("H");
+ let w_hello = FONT.text_width("Hello");
+ let w_full = FONT.text_width(text);
+
+ assert_eq!(FONT.longest_prefix_break_words(-10, text), "");
+ assert_eq!(FONT.longest_prefix_break_words(0, text), "");
+ assert_eq!(FONT.longest_prefix_break_words(w_h - 1, text), "");
+ assert_eq!(FONT.longest_prefix_break_words(w_h, text), "H");
+
+ // Just below "Hello" fits "Hell"
+ assert_eq!(FONT.longest_prefix_break_words(w_hello - 1, text), "Hell");
+ assert_eq!(FONT.longest_prefix_break_words(w_hello, text), "Hello");
+
+ // Exact full width and bigger should return the whole string
+ assert_eq!(FONT.longest_prefix_break_words(w_full, text), text);
+ assert_eq!(FONT.longest_prefix_break_words(w_full + 1, text), text);
+ }
+
+ #[test]
+ fn longest_prefix_break_words_unicode() {
+ let text = "Ačéà";
+
+ // Pick checkpoints
+ let p1 = &text[.."A".len()];
+ let p2 = &text[.."Ač".len()];
+ let p3 = &text[.."Ačé".len()];
+ let w1 = FONT.text_width(p1);
+ let w2 = FONT.text_width(p2);
+ let w3 = FONT.text_width(p3);
+ let w_full = FONT.text_width(text);
+
+ // Below first char → empty; at first char → that char
+ assert_eq!(FONT.longest_prefix_break_words(0, text), "");
+ assert_eq!(FONT.longest_prefix_break_words(w1 - 1, text), "");
+ assert_eq!(FONT.longest_prefix_break_words(w1, text), p1);
+
+ // Subsequent boundaries
+ assert_eq!(FONT.longest_prefix_break_words(w2 - 1, text), p1);
+ assert_eq!(FONT.longest_prefix_break_words(w2, text), p2);
+ assert_eq!(FONT.longest_prefix_break_words(w3 - 1, text), p2);
+ assert_eq!(FONT.longest_prefix_break_words(w3, text), p3);
+
+ // Full width or larger → full text
+ assert_eq!(FONT.longest_prefix_break_words(w_full, text), text);
+ assert_eq!(
+ FONT.longest_prefix_break_words(w_full.saturating_add(500), text),
+ text
+ );
+ }
+}
diff --git a/core/embed/rust/src/ui/layout_eckhart/component/button.rs b/core/embed/rust/src/ui/layout_eckhart/component/button.rs
index 21bf7b6d..700eef06 100644
--- a/core/embed/rust/src/ui/layout_eckhart/component/button.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/component/button.rs
@@ -113,7 +113,7 @@ impl Button {
subtext: TString<'static>,
subtext_style: &'static TextStyle,
) -> Self {
- Self::with_text_and_subtext(text, subtext, subtext_style, None)
+ Self::with_text_and_subtext(text, subtext, subtext_style)
.with_text_align(Self::MENU_ITEM_ALIGNMENT)
.with_content_offset(Self::MENU_ITEM_CONTENT_OFFSET)
.styled(stylesheet)
@@ -126,7 +126,7 @@ impl Button {
subtext: TString<'static>,
subtext_style: &'static TextStyle,
) -> Self {
- Self::with_single_line_text_and_subtext(text, subtext, subtext_style, None)
+ Self::with_single_line_text_and_subtext(text, subtext, subtext_style)
.with_text_align(Self::MENU_ITEM_ALIGNMENT)
.with_content_offset(Self::MENU_ITEM_CONTENT_OFFSET)
.styled(stylesheet)
@@ -137,17 +137,9 @@ impl Button {
pub fn new_connection_item(
text: TString<'static>,
stylesheet: ButtonStyleSheet,
- subtext: Option<TString<'static>>,
connected: bool,
) -> Self {
- let icon_color = if connected {
- theme::GREEN_LIGHT
- } else {
- theme::GREY_DARK
- };
- let (subtext, subtext_style) = if let Some(subtext) = subtext {
- (subtext, &theme::TEXT_MENU_ITEM_SUBTITLE)
- } else if connected {
+ let (subtext, subtext_style) = if connected {
(
TR::words__connected.into(),
&theme::TEXT_MENU_ITEM_SUBTITLE_GREEN,
@@ -159,16 +151,11 @@ impl Button {
)
};
- Self::with_text_and_subtext(
- text,
- subtext,
- subtext_style,
- Some((theme::ICON_SQUARE, icon_color)),
- )
- .with_text_align(Self::MENU_ITEM_ALIGNMENT)
- .with_content_offset(Self::MENU_ITEM_CONTENT_OFFSET)
- .styled(stylesheet)
- .with_radius(Self::MENU_ITEM_RADIUS)
+ Self::with_clipped_text_and_subtext(text, subtext, subtext_style)
+ .with_text_align(Self::MENU_ITEM_ALIGNMENT)
+ .with_content_offset(Self::MENU_ITEM_CONTENT_OFFSET)
+ .styled(stylesheet)
+ .with_radius(Self::MENU_ITEM_RADIUS)
}
pub const fn with_single_line_text(text: TString<'static>) -> Self {
@@ -183,13 +170,23 @@ impl Button {
text: TString<'static>,
subtext: TString<'static>,
subtext_style: &'static TextStyle,
- icon: Option<(Icon, Color)>,
) -> Self {
Self::new(ButtonContent::text_and_subtext(
text,
subtext,
subtext_style,
- icon,
+ ))
+ }
+
+ pub fn with_clipped_text_and_subtext(
+ text: TString<'static>,
+ subtext: TString<'static>,
+ subtext_style: &'static TextStyle,
+ ) -> Self {
+ Self::new(ButtonContent::clipped_text_and_subtext(
+ text,
+ subtext,
+ subtext_style,
))
}
@@ -197,13 +194,11 @@ impl Button {
text: TString<'static>,
subtext: TString<'static>,
subtext_style: &'static TextStyle,
- icon: Option<(Icon, Color)>,
) -> Self {
Self::new(ButtonContent::single_line_text_and_subtext(
text,
subtext,
subtext_style,
- icon,
))
}
@@ -361,9 +356,15 @@ impl Button {
}
}
- fn text_height(&self, text: &str, single_line: bool, width: i16) -> i16 {
+ fn text_height(&self, text: &str, single_line: bool, break_words: bool, width: i16) -> i16 {
if single_line {
self.style().font.line_height()
+ } else if break_words {
+ if self.stylesheet.normal.font.text_width(text) <= width {
+ return self.style().font.line_height();
+ } else {
+ self.style().font.line_height() * 2 - constant::LINE_SPACE
+ }
} else {
let (t1, t2) = split_two_lines(text, self.stylesheet.normal.font, width);
if t1.is_empty() || t2.is_empty() {
@@ -379,24 +380,18 @@ impl Button {
match &self.content {
ButtonContent::Empty => 0,
ButtonContent::Text { text, single_line } => {
- text.map(|t| self.text_height(t, *single_line, width))
+ text.map(|t| self.text_height(t, *single_line, false, width))
}
ButtonContent::Icon(icon) => icon.toif.height(),
ButtonContent::TextAndSubtext {
text,
single_line,
- icon,
+ break_words,
..
- } => {
- let width = if icon.is_some() {
- width - Self::CONN_ICON_WIDTH
- } else {
- width
- };
- text.map(|t| {
- self.text_height(t, *single_line, width) + self.baseline_subtext_height()
- })
- }
+ } => text.map(|t| {
+ self.text_height(t, *single_line, *break_words, width)
+ + self.baseline_subtext_height()
+ }),
#[cfg(feature = "micropython")]
ButtonContent::HomeBar(..) => theme::ACTION_BAR_HEIGHT,
}
@@ -478,13 +473,13 @@ impl Button {
.with_alpha(alpha)
.render(target)
};
- let render_origin = |y_offset: i16| {
+ let render_origin = |offset: Offset| {
match self.text_align {
Alignment::Start => self.area.left_center().ofs(self.content_offset),
Alignment::Center => self.area.center().ofs(self.content_offset),
Alignment::End => self.area.right_center().ofs(self.content_offset.neg()),
}
- .ofs(Offset::y(y_offset))
+ .ofs(offset)
};
match &self.content {
@@ -493,7 +488,7 @@ impl Button {
let text_baseline_height = self.baseline_text_height();
text.map(|t| {
if *single_line {
- show_text(t, render_origin(text_baseline_height / 2));
+ show_text(t, render_origin(Offset::y(text_baseline_height / 2)));
} else {
let (t1, t2) = split_two_lines(
t,
@@ -502,15 +497,19 @@ impl Button {
);
if t1.is_empty() || t2.is_empty() {
- show_text(t, render_origin(text_baseline_height / 2));
+ show_text(t, render_origin(Offset::y(text_baseline_height / 2)));
} else {
show_text(
t1,
- render_origin(-(text_baseline_height / 2 + constant::LINE_SPACE)),
+ render_origin(Offset::y(
+ -(text_baseline_height / 2 + constant::LINE_SPACE),
+ )),
);
show_text(
t2,
- render_origin(text_baseline_height + constant::LINE_SPACE * 2),
+ render_origin(Offset::y(
+ text_baseline_height + constant::LINE_SPACE * 2,
+ )),
);
}
}
@@ -519,36 +518,90 @@ impl Button {
ButtonContent::TextAndSubtext {
text,
single_line,
- icon,
+ break_words,
..
} => {
let text_baseline_height = self.baseline_text_height();
- let available_width = self.area.width()
- - 2 * self.content_offset.x
- - icon.map_or(0, |_| Self::CONN_ICON_WIDTH);
+ let available_width = self.area.width() - 2 * self.content_offset.x;
text.map(|t| {
if *single_line {
show_text(
t,
- render_origin(text_baseline_height / 2 - constant::LINE_SPACE * 2),
+ render_origin(Offset::y(
+ text_baseline_height / 2 - constant::LINE_SPACE * 2,
+ )),
);
+ } else if *break_words {
+ let first = stylesheet
+ .font
+ .longest_prefix_break_words(available_width, t);
+
+ if first == t {
+ // The first line fits
+ show_text(
+ first,
+ render_origin(Offset::y(
+ text_baseline_height / 2 - constant::LINE_SPACE * 2,
+ )),
+ );
+ } else {
+ show_text(
+ first,
+ render_origin(Offset::y(
+ -(text_baseline_height / 2 + constant::LINE_SPACE * 3),
+ )),
+ );
+ let remaining = t[first.len()..].trim();
+ if stylesheet.font.text_width(remaining) <= available_width {
+ // The second line fits
+ show_text(
+ remaining,
+ render_origin(Offset::y(
+ text_baseline_height - constant::LINE_SPACE * 2,
+ )),
+ );
+ } else {
+ // Break the second line and add the ellipsis
+ let ellipsis = "...";
+ let width = available_width - stylesheet.font.text_width(ellipsis);
+ let second =
+ stylesheet.font.longest_prefix_break_words(width, remaining);
+ show_text(
+ second,
+ render_origin(Offset::y(
+ text_baseline_height - constant::LINE_SPACE * 2,
+ )),
+ );
+ show_text(
+ ellipsis,
+ render_origin(Offset::new(
+ stylesheet.font.text_width(second),
+ text_baseline_height - constant::LINE_SPACE * 2,
+ )),
+ );
+ }
+ }
} else {
let (t1, t2) = split_two_lines(t, stylesheet.font, available_width);
if t1.is_empty() || t2.is_empty() {
show_text(
t,
- render_origin(text_baseline_height / 2 - constant::LINE_SPACE * 2),
+ render_origin(Offset::y(
+ text_baseline_height / 2 - constant::LINE_SPACE * 2,
+ )),
);
} else {
show_text(
t1,
- render_origin(
+ render_origin(Offset::y(
-(text_baseline_height / 2 + constant::LINE_SPACE * 3),
- ),
+ )),
);
show_text(
t2,
- render_origin(text_baseline_height - constant::LINE_SPACE * 2),
+ render_origin(Offset::y(
+ text_baseline_height - constant::LINE_SPACE * 2,
+ )),
);
}
}
@@ -559,19 +612,6 @@ impl Button {
} else {
unreachable!();
};
-
- if let Some((icon, icon_color)) = icon {
- shape::ToifImage::new(
- self.area
- .right_center()
- .ofs(Offset::x(Self::CONN_ICON_WIDTH / 2).neg())
- .ofs(self.content_offset.neg()),
- icon.toif,
- )
- .with_align(Alignment2D::CENTER)
- .with_fg(*icon_color)
- .render(target);
- }
}
ButtonContent::Icon(icon) => {
shape::ToifImage::new(self.area.center() + self.content_offset, icon.toif)
@@ -620,17 +660,14 @@ impl Component for Button {
fn place(&mut self, bounds: Rect) -> Rect {
self.area = bounds;
- if let ButtonContent::TextAndSubtext { icon, .. } = self.content {
+ if let ButtonContent::TextAndSubtext { .. } = self.content {
let subtext_start = (bounds.height() + self.content_height(bounds.width())) / 2
- self.baseline_subtext_height();
if let Some(m) = self.subtext_marquee.as_mut() {
- let mut marquee_area = self
+ let marquee_area = self
.area
.inset(Insets::top(subtext_start))
.inset(Insets::sides(self.content_offset.x));
- if icon.is_some() {
- marquee_area = marquee_area.inset(Insets::right(Self::CONN_ICON_WIDTH));
- }
m.place(marquee_area);
}
}
@@ -788,9 +825,9 @@ pub enum ButtonContent {
TextAndSubtext {
text: TString<'static>,
single_line: bool,
+ break_words: bool,
subtext: TString<'static>,
subtext_style: &'static TextStyle,
- icon: Option<(Icon, Color)>,
},
Icon(Icon),
#[cfg(feature = "micropython")]
@@ -816,14 +853,27 @@ impl ButtonContent {
text: TString<'static>,
subtext: TString<'static>,
subtext_style: &'static TextStyle,
- icon: Option<(Icon, Color)>,
) -> Self {
Self::TextAndSubtext {
text,
single_line: false,
+ break_words: false,
+ subtext,
+ subtext_style,
+ }
+ }
+
+ pub const fn clipped_text_and_subtext(
+ text: TString<'static>,
+ subtext: TString<'static>,
+ subtext_style: &'static TextStyle,
+ ) -> Self {
+ Self::TextAndSubtext {
+ text,
+ single_line: false,
+ break_words: true,
subtext,
subtext_style,
- icon,
}
}
@@ -831,14 +881,13 @@ impl ButtonContent {
text: TString<'static>,
subtext: TString<'static>,
subtext_style: &'static TextStyle,
- icon: Option<(Icon, Color)>,
) -> Self {
Self::TextAndSubtext {
text,
single_line: true,
+ break_words: false,
subtext,
subtext_style,
- icon,
}
}
}
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 8667a65b..822cef90 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
@@ -11,7 +11,7 @@ use crate::{
ui::{
component::{
text::{
- paragraphs::{Paragraph, ParagraphSource, Paragraphs},
+ paragraphs::{Paragraph, ParagraphSource, ParagraphVecShort, Paragraphs, VecExt},
TextStyle,
},
Component, Event, EventCtx,
@@ -89,8 +89,8 @@ impl From<DeviceMenuId> for usize {
// FIXME: use mem::variant_count when it becomes stable
const MAX_SUBMENUS: usize = 9;
-// submenus, device screens, regulatory and about screens
-const MAX_SUBSCREENS: usize = MAX_SUBMENUS + MAX_PAIRED_DEVICES + 2;
+// submenus, device + hostinfo screen couples, regulatory and about screens
+const MAX_SUBSCREENS: usize = MAX_SUBMENUS + 2 * MAX_PAIRED_DEVICES + 2;
#[derive(Clone)]
enum Action {
@@ -141,11 +141,11 @@ pub enum DeviceMenuMsg {
Close,
}
-trait VecExt {
+trait MenuVecExt {
fn add(&mut self, item: MenuItem) -> &mut Self;
}
-impl<const N: usize> VecExt for Vec<MenuItem, N> {
+impl<const N: usize> MenuVecExt for Vec<MenuItem, N> {
fn add(&mut self, item: MenuItem) -> &mut Self {
if self.push(item).is_err() {
#[cfg(feature = "ui_debug")]
@@ -250,25 +250,28 @@ impl Submenu {
}
}
+struct HostInfoScreen {
+ host_name: TString<'static>,
+ mac: TString<'static>,
+ app_name: Option<TString<'static>>,
+ parent_screen: u8,
+}
+
+struct DeviceScreen {
+ title: TString<'static>,
+ connected: bool,
+ device_index: u8,
+ host_info_screen: u8,
+}
+
// Each subscreen of the DeviceMenuScreen is one of these
enum Subscreen {
// A registered submenu
Submenu(u8, DeviceMenuId),
// A screen allowing the user to to disconnect a device
- DeviceScreen(
- TString<'static>, /* device main text */
- bool, /* is the device connected? */
- u8, /* index in the list of devices */
- u8, /* host info screen index */
- ),
-
- HostInfoScreen(
- TString<'static>, /* host name */
- TString<'static>, /* MAC address */
- u8, /* parent screen index */
- ),
-
+ DeviceScreen(DeviceScreen),
+ HostInfoScreen(HostInfoScreen),
// The about screen
AboutScreen,
// A screen showing the regulatory information
@@ -282,7 +285,7 @@ enum Subscreen {
enum ActiveScreen {
Menu(VerticalMenuScreen<MediumMenuVec>, DeviceMenuId),
Device(VerticalMenuScreen<ShortMenuVec>),
- HostInfo(TextScreen<Paragraphs<[Paragraph<'static>; 4]>>),
+ HostInfo(TextScreen<Paragraphs<ParagraphVecShort<'static>>>),
About(TextScreen<Paragraphs<PropsList>>),
BackupInfo(TextScreen<Paragraphs<Paragraph<'static>>>),
Regulatory(RegulatoryScreen),
@@ -359,35 +362,43 @@ impl DeviceMenuScreen {
is_connected.then_some(TR::words__connected.into());
let mut submenu_indices: Vec<u8, MAX_PAIRED_DEVICES> = Vec::new();
- for (i, (mac, host_info)) in (0u8..).zip(paired_devices.iter()) {
- let connected = connected_idx == Some(i);
+ for (device_index, (mac, host_info)) in (0u8..).zip(paired_devices.iter()) {
+ let connected = connected_idx == Some(device_index);
// Add the host info subscreen first, so that we can reference it from the
// device subscreen
- let host_name = if let Some([host_name, _app_name]) = host_info {
- *host_name
+ let (host_name, app_name) = if let Some([host_name, app_name]) = host_info {
+ (*host_name, Some(*app_name))
} else {
- TR::words__unknown.into()
+ (TR::words__unknown.into(), None)
};
- let info_subscreen = screen.add_subscreen(Subscreen::HostInfoScreen(
- host_name, *mac,
- 0, /* dummy value because the device subscreen doesn't exist yet */
- ));
+ let host_info_screen =
+ screen.add_subscreen(Subscreen::HostInfoScreen(HostInfoScreen {
+ host_name,
+ mac: *mac,
+ app_name,
+ parent_screen: 0, /* dummy value because the device subscreen doesn't exist
+ * yet */
+ }));
// Add the device subscreen with the reference to the host info subscreen
- let text = if let Some([host_name, _]) = host_info {
+ let title = if let Some([host_name, _]) = host_info {
*host_name
} else {
*mac
};
- let device_subscreen =
- screen.add_subscreen(Subscreen::DeviceScreen(text, connected, i, info_subscreen));
+ let device_subscreen = screen.add_subscreen(Subscreen::DeviceScreen(DeviceScreen {
+ title,
+ connected,
+ device_index,
+ host_info_screen,
+ }));
// Update the parent index in the host info screen to break the circular
// dependency
- if let Subscreen::HostInfoScreen(_, _, parent_idx) =
- &mut screen.subscreens[usize::from(info_subscreen)]
+ if let Subscreen::HostInfoScreen(ref mut host_info) =
+ screen.subscreens[usize::from(host_info_screen)]
{
- *parent_idx = device_subscreen;
+ host_info.parent_screen = device_subscreen;
} else {
unreachable!();
}
@@ -434,7 +445,7 @@ impl DeviceMenuScreen {
connected_idx: Option<u8>,
) {
let mut items: Vec<MenuItem, MEDIUM_MENU_ITEMS> = Vec::new();
- for (i, ((mac, host_info), device)) in
+ for (i, ((_mac, host_info), device)) in
(0u8..).zip(paired_devices.iter().zip(submenu_indices))
{
let connection_status = match connected_idx {
@@ -442,17 +453,14 @@ impl DeviceMenuScreen {
_ => Some(false),
};
- let (text, subtext) = if let Some([host_name, app_name]) = host_info {
- (*app_name, Some(*host_name))
+ let text = if let Some([host_name, _app_name]) = host_info {
+ *host_name
} else {
- (*mac, None)
+ TR::words__unknown.into()
};
- let mut item_device =
- MenuItem::go_to_subscreen(text, device).with_connection_status(connection_status);
- if let Some(subtext) = subtext {
- item_device = item_device.with_subtext(Some((subtext, None)));
- }
+ let item_device =
+ MenuItem::go_to_subscreen(text, device).with_connection_status(connection_status);
items.add(item_device);
}
@@ -785,12 +793,7 @@ impl DeviceMenuScreen {
let mut menu = VerticalMenu::<MediumMenuVec>::empty();
for item in &submenu.items {
let button = if let Some(connected) = item.connection_status {
- Button::new_connection_item(
- item.text,
- *item.stylesheet,
- item.subtext.map(|(t, _)| t),
- connected,
- )
+ Button::new_connection_item(item.text, *item.stylesheet, connected)
} else if let Some((subtext, subtext_style)) = item.subtext {
let subtext_style =
subtext_style.unwrap_or(&theme::TEXT_MENU_ITEM_SUBTITLE);
@@ -822,9 +825,9 @@ impl DeviceMenuScreen {
id,
);
}
- Subscreen::DeviceScreen(device, connected, ..) => {
+ Subscreen::DeviceScreen(ref device_screen) => {
let mut menu = VerticalMenu::empty();
- if connected {
+ if device_screen.connected {
menu.item(Button::new_menu_item(
TR::words__disconnect.into(),
theme::menu_item_title(),
@@ -848,22 +851,38 @@ impl DeviceMenuScreen {
HeaderMsg::Back,
),
)
- .with_subtitle(device),
+ .with_subtitle(device_screen.title),
);
}
- Subscreen::HostInfoScreen(host_name, mac, ..) => {
+ Subscreen::HostInfoScreen(ref host_info) => {
+ let mut para = ParagraphVecShort::new();
+ para.add(
+ Paragraph::new(&theme::TEXT_MEDIUM_EXTRA_LIGHT, TR::words__name)
+ .with_bottom_padding(theme::PROP_INNER_SPACING),
+ );
+ para.add(
+ Paragraph::new(&theme::TEXT_MONO_LIGHT, host_info.host_name)
+ .with_bottom_padding(theme::TEXT_VERTICAL_SPACING),
+ );
+ para.add(
+ Paragraph::new(&theme::TEXT_MEDIUM_EXTRA_LIGHT, TR::ble__mac_address)
+ .with_bottom_padding(theme::PROP_INNER_SPACING),
+ );
+ para.add(
+ Paragraph::new(&theme::TEXT_MONO_LIGHT, host_info.mac)
+ .with_bottom_padding(theme::TEXT_VERTICAL_SPACING),
+ );
+ if let Some(app_name) = host_info.app_name {
+ para.add(
+ Paragraph::new(&theme::TEXT_MEDIUM_EXTRA_LIGHT, TR::ble__apps_connected)
+ .with_bottom_padding(theme::PROP_INNER_SPACING),
+ );
+ para.add(Paragraph::new(&theme::TEXT_MONO_LIGHT, app_name));
+ }
*self.active_screen.deref_mut() = ActiveScreen::HostInfo(
TextScreen::new(
- Paragraphs::new([
- Paragraph::new(&theme::TEXT_MEDIUM_EXTRA_LIGHT, TR::words__name)
- .with_bottom_padding(theme::PROP_INNER_SPACING),
- Paragraph::new(&theme::TEXT_MONO_LIGHT, host_name)
- .with_bottom_padding(theme::TEXT_VERTICAL_SPACING),
- Paragraph::new(&theme::TEXT_MEDIUM_EXTRA_LIGHT, TR::ble__mac_address)
- .with_bottom_padding(theme::PROP_INNER_SPACING),
- Paragraph::new(&theme::TEXT_MONO_LIGHT, mac),
- ])
- .with_placement(LinearPlacement::vertical()),
+ para.into_paragraphs()
+ .with_placement(LinearPlacement::vertical()),
)
.with_header(Header::new(TR::ble__host_info.into()).with_close_button()),
);
@@ -935,9 +954,9 @@ impl DeviceMenuScreen {
fn go_back(&mut self, ctx: &mut EventCtx) -> Option<DeviceMenuMsg> {
let active_subscreen = &self.subscreens[usize::from(self.active_subscreen)];
- if let Subscreen::HostInfoScreen(_name, _mac, parent_idx) = active_subscreen {
+ if let Subscreen::HostInfoScreen(ref host_info) = active_subscreen {
// Handle going back from the HostInfoScreen
- self.activate_subscreen(*parent_idx, ctx);
+ self.activate_subscreen(host_info.parent_screen, ctx);
return None;
}
@@ -1033,33 +1052,34 @@ impl Component for DeviceMenuScreen {
_ => {}
}
}
- (
- Subscreen::DeviceScreen(_, connected, device_idx, host_info_idx),
- ActiveScreen::Device(menu),
- ) => match menu.event(ctx, event) {
- Some(VerticalMenuScreenMsg::Selected(button_idx)) => {
- match (button_idx, *connected) {
- (0, true) => {
- return Some(DeviceMenuMsg::DisconnectDevice);
- }
- (0, false) | (1, true) => {
- self.activate_subscreen(*host_info_idx, ctx);
- return None;
- }
- (1, false) | (2, true) => {
- return Some(DeviceMenuMsg::UnpairDevice(*device_idx));
+ (Subscreen::DeviceScreen(ref device_screen), ActiveScreen::Device(menu)) => {
+ match menu.event(ctx, event) {
+ Some(VerticalMenuScreenMsg::Selected(button_idx)) => {
+ match (button_idx, device_screen.connected) {
+ (0, true) => {
+ return Some(DeviceMenuMsg::DisconnectDevice);
+ }
+ (0, false) | (1, true) => {
+ self.activate_subscreen(device_screen.host_info_screen, ctx);
+ return None;
+ }
+ (1, false) | (2, true) => {
+ return Some(DeviceMenuMsg::UnpairDevice(
+ device_screen.device_index,
+ ));
+ }
+ _ => {}
}
- _ => {}
}
+ Some(VerticalMenuScreenMsg::Back) => {
+ return self.go_back(ctx);
+ }
+ Some(VerticalMenuScreenMsg::Close) => {
+ return Some(DeviceMenuMsg::Close);
+ }
+ _ => {}
}
- Some(VerticalMenuScreenMsg::Back) => {
- return self.go_back(ctx);
- }
- Some(VerticalMenuScreenMsg::Close) => {
- return Some(DeviceMenuMsg::Close);
- }
- _ => {}
- },
+ }
(Subscreen::AboutScreen, ActiveScreen::About(about)) => {
if let Some(TextScreenMsg::Cancelled) = about.event(ctx, event) {
return self.go_back(ctx);
diff --git a/core/mocks/trezortranslate_keys.pyi b/core/mocks/trezortranslate_keys.pyi
index 654dd0fa..abc0565a 100644
--- a/core/mocks/trezortranslate_keys.pyi
+++ b/core/mocks/trezortranslate_keys.pyi
@@ -77,6 +77,7 @@ class TR:
bitcoin__unverified_external_inputs: str = "The transaction contains unverified external inputs."
bitcoin__valid_signature: str = "The signature is valid."
bitcoin__voting_rights: str = "Voting rights to"
+ ble__apps_connected: str = "Apps connected"
ble__disable: str = "Turn Bluetooth off?"
ble__enable: str = "Turn Bluetooth on?"
ble__forget_all: str = "Forget all"
diff --git a/core/translations/en.json b/core/translations/en.json
index bb87db84..ecd10809 100644
--- a/core/translations/en.json
+++ b/core/translations/en.json
@@ -109,6 +109,7 @@
"bitcoin__unverified_external_inputs": "The transaction contains unverified external inputs.",
"bitcoin__valid_signature": "The signature is valid.",
"bitcoin__voting_rights": "Voting rights to",
+ "ble__apps_connected": "Apps connected",
"ble__disable": "Turn Bluetooth off?",
"ble__enable": "Turn Bluetooth on?",
"ble__forget_all": "Forget all",
diff --git a/core/translations/order.json b/core/translations/order.json
index 4bce2985..79496257 100644
--- a/core/translations/order.json
+++ b/core/translations/order.json
@@ -1162,5 +1162,6 @@
"1160": "homescreen__backup_needed_info",
"1161": "ble__host_info",
"1162": "ble__mac_address",
- "1163": "ble__waiting_for_host"
+ "1163": "ble__waiting_for_host",
+ "1164": "ble__apps_connected"
}
diff --git a/core/translations/signatures.json b/core/translations/signatures.json
index 18f8c25d..253e2703 100644
--- a/core/translations/signatures.json
+++ b/core/translations/signatures.json
@@ -1,8 +1,8 @@
{
"current": {
- "merkle_root": "d83452bcbd293dadbe58e63101a596fe54b03a8df002746241cc4c206201de7c",
- "datetime": "2025-09-26T07:32:48.356972+00:00",
- "commit": "671847de53ee2bd2c27119bf378e8ef07197f24c"
+ "merkle_root": "d9e63fbda383b98d56ce2d2d69695f74bcf964a24cf324ab4f2e3ef67434775e",
+ "datetime": "2025-09-29T11:26:17.028808+00:00",
+ "commit": "74ec3e8b87d77e3e55ada58de6caefa2b9433164"
},
"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.