Skip to content

Commit 831d8ed

Browse files
committed
feat(core/bolt): hide chars in passphrase input
- hide already written characters in the passphrase keyboard in Bolt - holding a finger in the input field reveals the whole passphrase in a potentially multi-line manner
1 parent c1528ab commit 831d8ed

2 files changed

Lines changed: 227 additions & 47 deletions

File tree

core/.changelog.d/6342.fixed

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[T2T1,T3T1] Hide written characters in passphrase keyboard.

core/embed/rust/src/ui/layout_bolt/component/keyboard/passphrase.rs

Lines changed: 226 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
11
use crate::{
2-
strutil::TString,
2+
strutil::{ShortString, TString},
3+
time::Duration,
34
ui::{
45
component::{
5-
base::ComponentExt, text::common::TextBox, Child, Component, Event, EventCtx, Never,
6-
Paginate,
6+
base::ComponentExt,
7+
text::{
8+
common::TextBox,
9+
layout::{LayoutFit, LineBreaking},
10+
TextStyle,
11+
},
12+
Child, Component, Event, EventCtx, Never, Pad, Paginate, TextLayout, Timer,
713
},
814
display,
9-
geometry::{Grid, Offset, Rect},
10-
shape::{self, Renderer},
11-
util::{long_line_content_with_ellipsis, Pager},
15+
event::TouchEvent,
16+
geometry::{Alignment, Grid, Insets, Offset, Rect},
17+
shape::{Bar, Renderer, Text},
18+
util::{DisplayStyle, Pager},
1219
},
1320
};
1421

1522
use super::super::{
23+
super::constant::SCREEN,
1624
button::{Button, ButtonContent, ButtonMsg},
1725
keyboard::common::{render_pending_marker, MultiTapKeyboard},
1826
swipe::{Swipe, SwipeDirection},
@@ -134,9 +142,15 @@ impl PassphraseKeyboard {
134142
};
135143

136144
self.scrollbar.set_pager(pager);
137-
// Clear the pending state.
138-
self.input
139-
.mutate(ctx, |ctx, i| i.multi_tap.clear_pending_state(ctx));
145+
// Clear the pending state. If there was a pending character, it has been
146+
// committed; show it briefly then hide.
147+
self.input.mutate(ctx, |ctx, i| {
148+
if i.multi_tap.pending_key().is_some() {
149+
i.multi_tap.clear_pending_state(ctx);
150+
i.display_style = DisplayStyle::LastOnly;
151+
i.last_char_timer.start(ctx, Input::LAST_DIGIT_TIMEOUT);
152+
}
153+
});
140154
// Update buttons.
141155
self.replace_button_content(ctx, pager.current().into());
142156
// Reset backlight to normal level on next paint.
@@ -255,9 +269,12 @@ impl Component for PassphraseKeyboard {
255269
}
256270

257271
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
272+
// Handle multi-tap timeout: commit the pending character and show it briefly
258273
let multitap_timeout = self.input.mutate(ctx, |ctx, i| {
259274
if i.multi_tap.timeout_event(event) {
260275
i.multi_tap.clear_pending_state(ctx);
276+
i.display_style = DisplayStyle::LastOnly;
277+
i.last_char_timer.start(ctx, Input::LAST_DIGIT_TIMEOUT);
261278
true
262279
} else {
263280
false
@@ -266,6 +283,15 @@ impl Component for PassphraseKeyboard {
266283
if multitap_timeout {
267284
return None;
268285
}
286+
287+
// Handle input touch events (reveal/hide passphrase)
288+
self.input.event(ctx, event);
289+
290+
// When passphrase is shown in full, disable all keypad interaction
291+
if self.input.inner().display_style == DisplayStyle::Shown {
292+
return None;
293+
}
294+
269295
if let Some(swipe) = self.page_swipe.event(ctx, event) {
270296
// We have detected a horizontal swipe. Change the keyboard page.
271297
self.on_page_swipe(ctx, swipe);
@@ -286,6 +312,7 @@ impl Component for PassphraseKeyboard {
286312
self.input.mutate(ctx, |ctx, i| {
287313
i.multi_tap.clear_pending_state(ctx);
288314
i.textbox.delete_last(ctx);
315+
i.display_style = DisplayStyle::Hidden;
289316
});
290317
self.after_edit(ctx);
291318
None
@@ -295,6 +322,7 @@ impl Component for PassphraseKeyboard {
295322
self.input.mutate(ctx, |ctx, i| {
296323
i.multi_tap.clear_pending_state(ctx);
297324
i.textbox.clear(ctx);
325+
i.display_style = DisplayStyle::Hidden;
298326
});
299327
self.after_edit(ctx);
300328
return None;
@@ -319,6 +347,15 @@ impl Component for PassphraseKeyboard {
319347
self.input.mutate(ctx, |ctx, i| {
320348
let edit = text.map(|c| i.multi_tap.click_key(ctx, key, c));
321349
i.textbox.apply(ctx, edit);
350+
if text.len() == 1 {
351+
// Single-char key: immediately applied, show last char briefly
352+
i.display_style = DisplayStyle::LastOnly;
353+
i.last_char_timer.start(ctx, Input::LAST_DIGIT_TIMEOUT);
354+
} else {
355+
// Multi-tap key: pending state, show char with marker
356+
i.last_char_timer.stop();
357+
i.display_style = DisplayStyle::LastWithMarker;
358+
}
322359
});
323360
self.after_edit(ctx);
324361
return None;
@@ -328,16 +365,21 @@ impl Component for PassphraseKeyboard {
328365
}
329366

330367
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
331-
self.input.render(target);
332-
self.scrollbar.render(target);
333-
self.confirm.render(target);
334-
self.back.render(target);
335-
for btn in &self.keys {
336-
btn.render(target);
337-
}
338-
if self.fade.take() {
339-
// Note that this is blocking and takes some time.
340-
display::fade_backlight(theme::backlight::get_backlight_normal());
368+
if self.input.inner().display_style == DisplayStyle::Shown {
369+
// When passphrase is revealed, render only the shown overlay
370+
self.input.render(target);
371+
} else {
372+
self.input.render(target);
373+
self.scrollbar.render(target);
374+
self.confirm.render(target);
375+
self.back.render(target);
376+
for btn in &self.keys {
377+
btn.render(target);
378+
}
379+
if self.fade.take() {
380+
// Note that this is blocking and takes some time.
381+
display::fade_backlight(theme::backlight::get_backlight_normal());
382+
}
341383
}
342384
}
343385
}
@@ -346,14 +388,129 @@ struct Input {
346388
area: Rect,
347389
textbox: TextBox,
348390
multi_tap: MultiTapKeyboard,
391+
display_style: DisplayStyle,
392+
last_char_timer: Timer,
393+
pad: Pad,
394+
shown_area: Rect,
349395
}
350396

351397
impl Input {
398+
const TWITCH: i16 = 4;
399+
const LAST_DIGIT_TIMEOUT: Duration = Duration::from_secs(1);
400+
const STYLE: TextStyle =
401+
theme::label_keyboard().with_line_breaking(LineBreaking::BreakWordsNoHyphen);
402+
const SHOWN_INSETS: Insets = Insets::new(8, 10, 8, 10);
403+
const SHOWN_TOUCH_OUTSET: Insets = Insets::bottom(80);
404+
352405
fn new(max_len: usize) -> Self {
353406
Self {
354407
area: Rect::zero(),
355408
textbox: TextBox::empty(max_len),
356409
multi_tap: MultiTapKeyboard::new(),
410+
display_style: DisplayStyle::Hidden,
411+
last_char_timer: Timer::new(),
412+
pad: Pad::with_background(theme::BG),
413+
shown_area: Rect::zero(),
414+
}
415+
}
416+
417+
fn update_shown_area(&mut self) {
418+
let line_height = Self::STYLE.text_font.line_height();
419+
420+
// Start with full screen width, positioned at the input area top
421+
let initial_height = line_height + Self::SHOWN_INSETS.top + Self::SHOWN_INSETS.bottom;
422+
let mut shown_area = SCREEN.inset(Self::SHOWN_INSETS).with_height(initial_height);
423+
424+
// Extend the shown area until the text fits
425+
while let LayoutFit::OutOfBounds { .. } = TextLayout::new(Self::STYLE)
426+
.with_align(Alignment::Start)
427+
.with_bounds(shown_area.inset(Self::SHOWN_INSETS))
428+
.fit_text(self.textbox.content())
429+
{
430+
shown_area = shown_area.outset(Insets::bottom(line_height));
431+
}
432+
433+
self.shown_area = shown_area;
434+
}
435+
436+
fn render_shown<'s>(&self, target: &mut impl Renderer<'s>) {
437+
debug_assert_eq!(self.display_style, DisplayStyle::Shown);
438+
439+
Bar::new(self.shown_area)
440+
.with_bg(theme::GREY_DARK)
441+
.with_radius(theme::RADIUS as i16)
442+
.render(target);
443+
444+
TextLayout::new(Self::STYLE)
445+
.with_bounds(self.shown_area.inset(Self::SHOWN_INSETS))
446+
.with_align(Alignment::Start)
447+
.render_text(self.textbox.content(), target, true);
448+
}
449+
450+
fn render_hidden<'s>(&self, target: &mut impl Renderer<'s>) {
451+
debug_assert_ne!(self.display_style, DisplayStyle::Shown);
452+
453+
let style = theme::label_keyboard();
454+
let pp_len = self.textbox.count();
455+
if pp_len == 0 {
456+
return;
457+
}
458+
459+
let last_char_visible = self.display_style == DisplayStyle::LastOnly
460+
|| self.display_style == DisplayStyle::LastWithMarker;
461+
462+
// Compute how many characters fit in the available width. Account for the
463+
// pending marker, which draws itself one pixel longer than the last char.
464+
let available_width = self.area.width() - 1;
465+
let asterisk_width = style.text_font.char_width('*').max(1);
466+
let max_visible = (available_width / asterisk_width).max(1) as usize;
467+
let visible_count = pp_len.min(max_visible);
468+
let asterisk_count = visible_count.saturating_sub(last_char_visible as usize);
469+
470+
// Build asterisks string
471+
let mut asterisks = ShortString::new();
472+
for _ in 0..asterisk_count {
473+
let _ = asterisks.push('*');
474+
}
475+
476+
let mut text_baseline = self.area.top_left() + Offset::y(style.text_font.text_height())
477+
- Offset::y(style.text_font.text_baseline());
478+
479+
// Twitch when overflowed (alternates so user sees feedback when typing past
480+
// what fits)
481+
if pp_len > max_visible && pp_len % 2 == 1 {
482+
text_baseline.x += Self::TWITCH;
483+
}
484+
485+
// Render asterisks in GREY_LIGHT
486+
if !asterisks.is_empty() {
487+
Text::new(text_baseline, &asterisks, style.text_font)
488+
.with_align(Alignment::Start)
489+
.with_fg(theme::GREY_LIGHT)
490+
.render(target);
491+
}
492+
493+
// Render the visible last character in the regular text color
494+
if last_char_visible {
495+
if let Some(last) = self.textbox.last_char_str() {
496+
let last_baseline =
497+
text_baseline + Offset::x(style.text_font.text_width(&asterisks));
498+
499+
Text::new(last_baseline, last, style.text_font)
500+
.with_align(Alignment::Start)
501+
.with_fg(style.text_color)
502+
.render(target);
503+
504+
if self.display_style == DisplayStyle::LastWithMarker {
505+
render_pending_marker(
506+
target,
507+
last_baseline,
508+
last,
509+
style.text_font,
510+
style.text_color,
511+
);
512+
}
513+
}
357514
}
358515
}
359516
}
@@ -362,44 +519,64 @@ impl Component for Input {
362519
type Msg = Never;
363520

364521
fn place(&mut self, bounds: Rect) -> Rect {
522+
self.pad.place(bounds);
365523
self.area = bounds;
366524
self.area
367525
}
368526

369-
fn event(&mut self, _ctx: &mut EventCtx, _event: Event) -> Option<Self::Msg> {
527+
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
528+
if self.textbox.is_empty() {
529+
return None;
530+
}
531+
532+
let extended_shown_area = self
533+
.shown_area
534+
.outset(Self::SHOWN_TOUCH_OUTSET)
535+
.clamp(SCREEN);
536+
537+
match event {
538+
Event::Touch(TouchEvent::TouchStart(pos)) if self.area.contains(pos) => {
539+
self.multi_tap.clear_pending_state(ctx);
540+
self.last_char_timer.stop();
541+
self.display_style = DisplayStyle::Shown;
542+
self.update_shown_area();
543+
self.pad.clear();
544+
ctx.request_paint();
545+
}
546+
Event::Touch(TouchEvent::TouchEnd(_)) if self.display_style == DisplayStyle::Shown => {
547+
self.display_style = DisplayStyle::Hidden;
548+
self.pad.clear();
549+
ctx.request_paint();
550+
}
551+
Event::Touch(TouchEvent::TouchMove(pos))
552+
if !extended_shown_area.contains(pos)
553+
&& self.display_style == DisplayStyle::Shown =>
554+
{
555+
self.display_style = DisplayStyle::Hidden;
556+
self.pad.clear();
557+
ctx.request_paint();
558+
}
559+
// Timeout for showing the last char
560+
Event::Timer(_) if self.last_char_timer.expire(event) => {
561+
self.display_style = DisplayStyle::Hidden;
562+
self.request_complete_repaint(ctx);
563+
ctx.request_paint();
564+
}
565+
_ => {}
566+
}
370567
None
371568
}
372569

373570
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
374-
let style = theme::label_keyboard();
571+
self.pad.render(target);
375572

376-
let text_baseline = self.area.top_left() + Offset::y(style.text_font.text_height())
377-
- Offset::y(style.text_font.text_baseline());
378-
379-
let text = self.textbox.content();
380-
381-
shape::Bar::new(self.area).with_bg(theme::BG).render(target);
382-
383-
// Find out how much text can fit into the textbox.
384-
// Accounting for the pending marker, which draws itself one pixel longer than
385-
// the last character
386-
let available_area_width = self.area.width() - 1;
387-
let text_to_display =
388-
long_line_content_with_ellipsis(text, "...", style.text_font, available_area_width);
389-
390-
shape::Text::new(text_baseline, &text_to_display, style.text_font)
391-
.with_fg(style.text_color)
392-
.render(target);
573+
if self.textbox.is_empty() {
574+
return;
575+
}
393576

394-
// Paint the pending marker.
395-
if self.multi_tap.pending_key().is_some() {
396-
render_pending_marker(
397-
target,
398-
text_baseline,
399-
&text_to_display,
400-
style.text_font,
401-
style.text_color,
402-
);
577+
match self.display_style {
578+
DisplayStyle::Shown => self.render_shown(target),
579+
_ => self.render_hidden(target),
403580
}
404581
}
405582
}
@@ -410,8 +587,10 @@ impl crate::trace::Trace for PassphraseKeyboard {
410587
let page = self.scrollbar.pager().current();
411588
debug_assert!(page < PAGE_COUNT as u16);
412589
let active_layout = uformat!("{:?}", KeyboardLayout::from_page_unchecked(page.into()));
590+
let display_style = uformat!("{:?}", self.input.inner().display_style);
413591
t.component("PassphraseKeyboard");
414592
t.string("active_layout", active_layout.as_str().into());
415593
t.string("passphrase", self.passphrase().into());
594+
t.string("display_style", display_style.as_str().into());
416595
}
417596
}

0 commit comments

Comments
 (0)