mirror of
https://github.com/trezor/trezor-firmware.git
synced 2024-12-04 21:48:17 +00:00
Merge ba52fdf421
into ef02c4de5d
This commit is contained in:
commit
5264a3de59
1
core/.changelog.d/3959.added
Normal file
1
core/.changelog.d/3959.added
Normal file
@ -0,0 +1 @@
|
||||
Show last typed passphrase character for short period of time
|
@ -1,13 +1,15 @@
|
||||
use crate::{
|
||||
strutil::{ShortString, TString},
|
||||
time::Duration,
|
||||
translations::TR,
|
||||
ui::{
|
||||
component::{
|
||||
base::ComponentExt, swipe_detect::SwipeConfig, text::common::TextBox, Component, Event,
|
||||
EventCtx, Label, Maybe, Never, Swipe,
|
||||
EventCtx, Label, Maybe, Never, Swipe, Timer,
|
||||
},
|
||||
display,
|
||||
geometry::{Alignment, Direction, Grid, Insets, Offset, Rect},
|
||||
event::TouchEvent,
|
||||
geometry::{Alignment, Alignment2D, Direction, Grid, Insets, Offset, Rect},
|
||||
model_mercury::{
|
||||
component::{
|
||||
button::{Button, ButtonContent, ButtonMsg},
|
||||
@ -30,6 +32,14 @@ pub enum PassphraseKeyboardMsg {
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Debug, Copy, Clone)]
|
||||
#[cfg_attr(feature = "ui_debug", derive(ufmt::derive::uDebug))]
|
||||
enum DisplayStyle {
|
||||
Dots,
|
||||
Chars,
|
||||
LastChar,
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
@ -105,6 +115,8 @@ 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);
|
||||
const INPUT_INSETS: Insets = Insets::new(10, 2, 10, 4);
|
||||
const LAST_DIGIT_TIMEOUT_S: u32 = 1;
|
||||
|
||||
impl PassphraseKeyboard {
|
||||
pub fn new() -> Self {
|
||||
@ -186,8 +198,14 @@ impl PassphraseKeyboard {
|
||||
Direction::Right => self.active_layout.prev(),
|
||||
_ => self.active_layout,
|
||||
};
|
||||
// Clear the pending state.
|
||||
self.input.multi_tap.clear_pending_state(ctx);
|
||||
if self.input.multi_tap.pending_key().is_some() {
|
||||
// Clear the pending state.
|
||||
self.input.multi_tap.clear_pending_state(ctx);
|
||||
// the character has been added, show it for a bit and then hide it
|
||||
self.input
|
||||
.last_char_timer
|
||||
.start(ctx, Duration::from_secs(LAST_DIGIT_TIMEOUT_S));
|
||||
}
|
||||
// Update keys.
|
||||
self.replace_keys_contents(ctx);
|
||||
// Reset backlight to normal level on next paint.
|
||||
@ -272,7 +290,6 @@ impl Component for PassphraseKeyboard {
|
||||
fn place(&mut self, bounds: Rect) -> Rect {
|
||||
const CONFIRM_BTN_WIDTH: i16 = 78;
|
||||
const CONFIRM_EMPTY_BTN_WIDTH: i16 = 32;
|
||||
const INPUT_INSETS: Insets = Insets::new(10, 2, 10, 4);
|
||||
|
||||
let bounds = bounds.inset(theme::borders());
|
||||
let (top_area, keypad_area) =
|
||||
@ -284,7 +301,6 @@ impl Component for PassphraseKeyboard {
|
||||
.1;
|
||||
|
||||
let top_area = top_area.inset(INPUT_INSETS);
|
||||
let input_area = input_area.inset(INPUT_INSETS);
|
||||
let confirm_btn_area = confirm_btn_area.inset(CONFIRM_BTN_INSETS);
|
||||
let confirm_empty_btn_area = confirm_empty_btn_area.inset(CONFIRM_EMPTY_BTN_INSETS);
|
||||
|
||||
@ -323,8 +339,14 @@ impl Component for PassphraseKeyboard {
|
||||
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
|
||||
if self.input.multi_tap.timeout_event(event) {
|
||||
self.input.multi_tap.clear_pending_state(ctx);
|
||||
self.input
|
||||
.last_char_timer
|
||||
.start(ctx, Duration::from_secs(LAST_DIGIT_TIMEOUT_S));
|
||||
return None;
|
||||
}
|
||||
|
||||
self.input.event(ctx, event);
|
||||
|
||||
if let Some(swipe) = self.page_swipe.event(ctx, event) {
|
||||
// We have detected a horizontal swipe. Change the keyboard page.
|
||||
self.on_page_change(ctx, swipe);
|
||||
@ -383,6 +405,17 @@ impl Component for PassphraseKeyboard {
|
||||
let edit = text.map(|c| self.input.multi_tap.click_key(ctx, key, c));
|
||||
self.input.textbox.apply(ctx, edit);
|
||||
self.after_edit(ctx);
|
||||
if text.len() == 1 {
|
||||
// If the key has just one character, it is immediately applied and the last
|
||||
// digit timer should be started
|
||||
self.input
|
||||
.last_char_timer
|
||||
.start(ctx, Duration::from_secs(LAST_DIGIT_TIMEOUT_S));
|
||||
} else {
|
||||
// multi tap timer is runnig, the last digit timer should be stopped
|
||||
self.input.last_char_timer.stop();
|
||||
}
|
||||
self.input.display_style = DisplayStyle::LastChar;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
@ -416,14 +449,143 @@ struct Input {
|
||||
area: Rect,
|
||||
textbox: TextBox,
|
||||
multi_tap: MultiTapKeyboard,
|
||||
display_style: DisplayStyle,
|
||||
last_char_timer: Timer,
|
||||
}
|
||||
|
||||
impl Input {
|
||||
const TWITCH: i16 = 4;
|
||||
const X_STEP: i16 = 13;
|
||||
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
area: Rect::zero(),
|
||||
textbox: TextBox::empty(MAX_LENGTH),
|
||||
multi_tap: MultiTapKeyboard::new(),
|
||||
display_style: DisplayStyle::LastChar,
|
||||
last_char_timer: Timer::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_chars<'s>(&self, area: Rect, target: &mut impl Renderer<'s>) {
|
||||
let style = theme::label_keyboard_mono();
|
||||
let mut text_baseline = area.top_left() + Offset::y(style.text_font.text_height());
|
||||
let chars = self.textbox.content().len();
|
||||
|
||||
if chars > 0 {
|
||||
// Find out how much text can fit into the textbox.
|
||||
// Accounting for the pending marker, which draws itself one extra pixel
|
||||
let available_area_width = area.width() - 1;
|
||||
let truncated = long_line_content_with_ellipsis(
|
||||
self.textbox.content(),
|
||||
"",
|
||||
style.text_font,
|
||||
available_area_width,
|
||||
);
|
||||
|
||||
// Jiggle hidden passphrase when overflowed.
|
||||
if chars > truncated.len()
|
||||
&& chars % 2 == 0
|
||||
&& (self.display_style == DisplayStyle::Dots
|
||||
|| self.display_style == DisplayStyle::LastChar)
|
||||
{
|
||||
text_baseline.x += Self::TWITCH;
|
||||
}
|
||||
|
||||
// Paint the visible passphrase.
|
||||
shape::Text::new(text_baseline, &truncated)
|
||||
.with_font(style.text_font)
|
||||
.with_fg(style.text_color)
|
||||
.render(target);
|
||||
|
||||
// Paint the pending marker.
|
||||
if self.multi_tap.pending_key().is_some() {
|
||||
render_pending_marker(
|
||||
target,
|
||||
text_baseline,
|
||||
&truncated,
|
||||
style.text_font,
|
||||
style.text_color,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_dots<'s>(&self, area: Rect, target: &mut impl Renderer<'s>) {
|
||||
let style = theme::label_keyboard_mono();
|
||||
let bullet = theme::ICON_PIN_BULLET.toif;
|
||||
let mut cursor = area.left_center();
|
||||
let all_chars = self.textbox.content().len();
|
||||
let last_char = self.display_style == DisplayStyle::LastChar;
|
||||
|
||||
if all_chars > 0 {
|
||||
// Find out how much text can fit into the textbox.
|
||||
// Accounting for the pending marker, which draws itself one extra pixel
|
||||
let truncated = long_line_content_with_ellipsis(
|
||||
self.textbox.content(),
|
||||
"",
|
||||
style.text_font,
|
||||
area.width() - 1,
|
||||
);
|
||||
let visible_chars = truncated.len();
|
||||
let visible_dots = visible_chars - last_char as usize;
|
||||
|
||||
// Jiggle when overflowed.
|
||||
if all_chars > visible_chars
|
||||
&& all_chars % 2 == 0
|
||||
&& (self.display_style == DisplayStyle::Dots
|
||||
|| self.display_style == DisplayStyle::LastChar)
|
||||
{
|
||||
cursor.x += Self::TWITCH;
|
||||
}
|
||||
|
||||
let mut char_idx = 0;
|
||||
// Small leftmost dot.
|
||||
if all_chars > visible_chars + 1 {
|
||||
shape::ToifImage::new(cursor, theme::DOT_SMALL.toif)
|
||||
.with_align(Alignment2D::TOP_LEFT)
|
||||
.with_fg(theme::GREY)
|
||||
.render(target);
|
||||
cursor.x += Self::X_STEP;
|
||||
char_idx += 1;
|
||||
}
|
||||
// Greyed out dot.
|
||||
if all_chars > visible_chars {
|
||||
shape::ToifImage::new(cursor, theme::DOT_SMALL.toif)
|
||||
.with_align(Alignment2D::TOP_LEFT)
|
||||
.with_fg(style.text_color)
|
||||
.render(target);
|
||||
cursor.x += Self::X_STEP;
|
||||
char_idx += 1;
|
||||
}
|
||||
|
||||
if visible_dots > 0 {
|
||||
// Classical dot(s)
|
||||
for _ in char_idx..visible_dots {
|
||||
shape::ToifImage::new(cursor, bullet)
|
||||
.with_align(Alignment2D::TOP_LEFT)
|
||||
.with_fg(style.text_color)
|
||||
.render(target);
|
||||
cursor.x += Self::X_STEP;
|
||||
}
|
||||
}
|
||||
|
||||
if last_char {
|
||||
// Adapt y position for the character
|
||||
cursor.y = area.top_left().y + style.text_font.text_height();
|
||||
// This should not fail because all_chars > 0
|
||||
let last = &self.textbox.content()[(all_chars - 1)..all_chars];
|
||||
// Paint the last character
|
||||
shape::Text::new(cursor, last)
|
||||
.with_align(Alignment::Start)
|
||||
.with_font(style.text_font)
|
||||
.with_fg(style.text_color)
|
||||
.render(target);
|
||||
// Paint the pending marker.
|
||||
if self.multi_tap.pending_key().is_some() {
|
||||
render_pending_marker(target, cursor, last, style.text_font, style.text_color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -436,41 +598,37 @@ impl Component for Input {
|
||||
self.area
|
||||
}
|
||||
|
||||
fn event(&mut self, _ctx: &mut EventCtx, _event: Event) -> Option<Self::Msg> {
|
||||
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
|
||||
match event {
|
||||
Event::Timer(_) if self.last_char_timer.expire(event) => {
|
||||
self.display_style = DisplayStyle::Dots;
|
||||
ctx.request_paint();
|
||||
}
|
||||
Event::Touch(TouchEvent::TouchStart(pos)) if self.area.contains(pos) => {
|
||||
self.display_style = DisplayStyle::Chars;
|
||||
ctx.request_paint();
|
||||
}
|
||||
Event::Touch(TouchEvent::TouchEnd(pos)) if self.area.contains(pos) => {
|
||||
self.display_style = DisplayStyle::Dots;
|
||||
ctx.request_paint();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
let style = theme::label_keyboard();
|
||||
let text_area = self.area.inset(INPUT_INSETS);
|
||||
|
||||
let text_baseline = self.area.top_left() + Offset::y(style.text_font.text_height())
|
||||
- Offset::y(style.text_font.text_baseline());
|
||||
// Paint the background
|
||||
shape::Bar::new(text_area).with_bg(theme::BG).render(target);
|
||||
|
||||
let text = self.textbox.content();
|
||||
|
||||
shape::Bar::new(self.area).with_bg(theme::BG).render(target);
|
||||
|
||||
// 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 = self.area.width() - 1;
|
||||
let text_to_display =
|
||||
long_line_content_with_ellipsis(text, "...", style.text_font, available_area_width);
|
||||
|
||||
shape::Text::new(text_baseline, &text_to_display)
|
||||
.with_font(style.text_font)
|
||||
.with_fg(style.text_color)
|
||||
.render(target);
|
||||
|
||||
// Paint the pending marker.
|
||||
if self.multi_tap.pending_key().is_some() {
|
||||
render_pending_marker(
|
||||
target,
|
||||
text_baseline,
|
||||
&text_to_display,
|
||||
style.text_font,
|
||||
style.text_color,
|
||||
);
|
||||
// Paint the passphrase
|
||||
if !self.textbox.content().is_empty() {
|
||||
match self.display_style {
|
||||
DisplayStyle::Chars => self.render_chars(text_area, target),
|
||||
_ => self.render_dots(text_area, target),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -136,6 +136,10 @@ pub const fn label_keyboard() -> TextStyle {
|
||||
TextStyle::new(Font::DEMIBOLD, GREY_EXTRA_LIGHT, BG, GREY_LIGHT, GREY_LIGHT)
|
||||
}
|
||||
|
||||
pub const fn label_keyboard_mono() -> TextStyle {
|
||||
TextStyle::new(Font::MONO, GREY_EXTRA_LIGHT, BG, GREY_LIGHT, GREY_LIGHT)
|
||||
}
|
||||
|
||||
pub const fn label_keyboard_prompt() -> TextStyle {
|
||||
TextStyle::new(Font::DEMIBOLD, GREY_LIGHT, BG, GREY_LIGHT, GREY_LIGHT)
|
||||
}
|
||||
|
@ -1,9 +1,12 @@
|
||||
use crate::{
|
||||
strutil::{ShortString, TString},
|
||||
time::Duration,
|
||||
translations::TR,
|
||||
trezorhal::random,
|
||||
ui::{
|
||||
component::{text::common::TextBox, Child, Component, ComponentExt, Event, EventCtx},
|
||||
component::{
|
||||
text::common::TextBox, Child, Component, ComponentExt, Event, EventCtx, Timer,
|
||||
},
|
||||
display::Icon,
|
||||
geometry::Rect,
|
||||
shape::Renderer,
|
||||
@ -43,6 +46,8 @@ const DIGITS_INDEX: usize = 5;
|
||||
const SPECIAL_INDEX: usize = 6;
|
||||
const SPACE_INDEX: usize = 7;
|
||||
|
||||
const LAST_DIGIT_TIMEOUT_S: u32 = 1;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MenuItem {
|
||||
text: TString<'static>,
|
||||
@ -273,6 +278,7 @@ pub struct PassphraseEntry {
|
||||
show_last_digit: bool,
|
||||
textbox: TextBox,
|
||||
current_category: ChoiceCategory,
|
||||
last_char_timer: Timer,
|
||||
}
|
||||
|
||||
impl PassphraseEntry {
|
||||
@ -281,11 +287,14 @@ impl PassphraseEntry {
|
||||
choice_page: ChoicePage::new(ChoiceFactoryPassphrase::new(ChoiceCategory::Menu, true))
|
||||
.with_carousel(true)
|
||||
.with_initial_page_counter(random_menu_position()),
|
||||
passphrase_dots: Child::new(ChangingTextLine::center_mono("", MAX_PASSPHRASE_LENGTH)),
|
||||
passphrase_dots: Child::new(
|
||||
ChangingTextLine::center_mono("", MAX_PASSPHRASE_LENGTH).without_ellipsis(),
|
||||
),
|
||||
show_plain_passphrase: false,
|
||||
show_last_digit: false,
|
||||
textbox: TextBox::empty(MAX_PASSPHRASE_LENGTH),
|
||||
current_category: ChoiceCategory::Menu,
|
||||
last_char_timer: Timer::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -383,17 +392,22 @@ impl Component for PassphraseEntry {
|
||||
}
|
||||
|
||||
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
|
||||
// Any non-timer event when showing real passphrase should hide it
|
||||
// Same with showing last digit
|
||||
if !matches!(event, Event::Timer(_)) {
|
||||
if self.show_plain_passphrase {
|
||||
match event {
|
||||
// Timeout for showing the last digit.
|
||||
Event::Timer(_) if self.last_char_timer.expire(event) => {
|
||||
if self.show_last_digit {
|
||||
self.show_last_digit = false;
|
||||
self.update_passphrase_dots(ctx);
|
||||
}
|
||||
}
|
||||
// Other timers are ignored.
|
||||
Event::Timer(_) => {}
|
||||
// Any non-timer event when showing plain passphrase should hide it
|
||||
_ if self.show_plain_passphrase => {
|
||||
self.show_plain_passphrase = false;
|
||||
self.update_passphrase_dots(ctx);
|
||||
}
|
||||
if self.show_last_digit {
|
||||
self.show_last_digit = false;
|
||||
self.update_passphrase_dots(ctx);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if let Some((action, long_press)) = self.choice_page.event(ctx, event) {
|
||||
@ -437,6 +451,8 @@ impl Component for PassphraseEntry {
|
||||
PassphraseAction::Character(ch) if !self.is_full() => {
|
||||
self.append_char(ctx, ch);
|
||||
self.show_last_digit = true;
|
||||
self.last_char_timer
|
||||
.start(ctx, Duration::from_secs(LAST_DIGIT_TIMEOUT_S));
|
||||
self.update_passphrase_dots(ctx);
|
||||
self.randomize_category_position(ctx);
|
||||
ctx.request_paint();
|
||||
|
@ -1,23 +1,24 @@
|
||||
use crate::{
|
||||
strutil::TString,
|
||||
strutil::{ShortString, TString},
|
||||
time::Duration,
|
||||
ui::{
|
||||
component::{
|
||||
base::ComponentExt, text::common::TextBox, Child, Component, Event, EventCtx, Never,
|
||||
Timer,
|
||||
},
|
||||
display,
|
||||
geometry::{Grid, Offset, Rect},
|
||||
event::TouchEvent,
|
||||
geometry::{Alignment, Grid, Offset, Rect},
|
||||
model_tt::component::{
|
||||
button::{Button, ButtonContent, ButtonMsg},
|
||||
keyboard::common::{render_pending_marker, MultiTapKeyboard},
|
||||
swipe::{Swipe, SwipeDirection},
|
||||
theme, ScrollBar,
|
||||
},
|
||||
shape,
|
||||
shape::Renderer,
|
||||
shape::{self, Renderer},
|
||||
util::long_line_content_with_ellipsis,
|
||||
},
|
||||
};
|
||||
|
||||
use core::cell::Cell;
|
||||
|
||||
#[cfg_attr(feature = "debug", derive(ufmt::derive::uDebug))]
|
||||
@ -26,6 +27,14 @@ pub enum PassphraseKeyboardMsg {
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Debug, Copy, Clone)]
|
||||
#[cfg_attr(feature = "ui_debug", derive(ufmt::derive::uDebug))]
|
||||
enum DisplayStyle {
|
||||
Dots,
|
||||
Chars,
|
||||
LastChar,
|
||||
}
|
||||
|
||||
pub struct PassphraseKeyboard {
|
||||
page_swipe: Swipe,
|
||||
input: Child<Input>,
|
||||
@ -50,6 +59,8 @@ const KEYBOARD: [[&str; KEY_COUNT]; PAGE_COUNT] = [
|
||||
const MAX_LENGTH: usize = 50;
|
||||
const INPUT_AREA_HEIGHT: i16 = ScrollBar::DOT_SIZE + 9;
|
||||
|
||||
const LAST_DIGIT_TIMEOUT_S: u32 = 1;
|
||||
|
||||
impl PassphraseKeyboard {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@ -102,8 +113,17 @@ impl PassphraseKeyboard {
|
||||
};
|
||||
self.scrollbar.go_to(key_page);
|
||||
// Clear the pending state.
|
||||
self.input
|
||||
.mutate(ctx, |ctx, i| i.multi_tap.clear_pending_state(ctx));
|
||||
|
||||
let pending = self
|
||||
.input
|
||||
.mutate(ctx, |_ctx, i| i.multi_tap.pending_key().is_some());
|
||||
if pending {
|
||||
self.input
|
||||
.mutate(ctx, |ctx, i| i.multi_tap.clear_pending_state(ctx));
|
||||
// the character has been added, show it for a bit and then hide it
|
||||
self.input
|
||||
.mutate(ctx, |ctx, t| t.start_timer(ctx, LAST_DIGIT_TIMEOUT_S));
|
||||
}
|
||||
// Update buttons.
|
||||
self.replace_button_content(ctx, key_page);
|
||||
// Reset backlight to normal level on next paint.
|
||||
@ -189,7 +209,7 @@ impl Component for PassphraseKeyboard {
|
||||
let (input_area, key_grid_area) =
|
||||
bounds.split_bottom(4 * theme::PIN_BUTTON_HEIGHT + 3 * theme::BUTTON_SPACING);
|
||||
|
||||
let (input_area, scroll_area) = input_area.split_bottom(INPUT_AREA_HEIGHT);
|
||||
let (_, scroll_area) = input_area.split_bottom(INPUT_AREA_HEIGHT);
|
||||
let (scroll_area, _) = scroll_area.split_top(ScrollBar::DOT_SIZE);
|
||||
|
||||
let key_grid = Grid::new(key_grid_area, 4, 3).with_spacing(theme::BUTTON_SPACING);
|
||||
@ -231,8 +251,14 @@ impl Component for PassphraseKeyboard {
|
||||
}
|
||||
});
|
||||
if multitap_timeout {
|
||||
// the character has been added, show it for a bit and then hide it
|
||||
self.input
|
||||
.mutate(ctx, |ctx, t| t.start_timer(ctx, LAST_DIGIT_TIMEOUT_S));
|
||||
return None;
|
||||
}
|
||||
|
||||
self.input.event(ctx, event);
|
||||
|
||||
if let Some(swipe) = self.page_swipe.event(ctx, event) {
|
||||
// We have detected a horizontal swipe. Change the keyboard page.
|
||||
self.on_page_swipe(ctx, swipe);
|
||||
@ -288,6 +314,17 @@ impl Component for PassphraseKeyboard {
|
||||
i.textbox.apply(ctx, edit);
|
||||
});
|
||||
self.after_edit(ctx);
|
||||
if text.len() == 1 {
|
||||
// If the key has just one character, it is immediately applied and the last
|
||||
// digit timer should be started
|
||||
self.input
|
||||
.mutate(ctx, |ctx, t| t.start_timer(ctx, LAST_DIGIT_TIMEOUT_S));
|
||||
} else {
|
||||
// multi tap timer is runnig, the last digit timer should be stopped
|
||||
self.input.mutate(ctx, |_ctx, t| t.stop_timer());
|
||||
}
|
||||
self.input
|
||||
.mutate(ctx, |_ctx, t| t.set_display_style(DisplayStyle::LastChar));
|
||||
return None;
|
||||
}
|
||||
}
|
||||
@ -313,14 +350,140 @@ struct Input {
|
||||
area: Rect,
|
||||
textbox: TextBox,
|
||||
multi_tap: MultiTapKeyboard,
|
||||
display_style: DisplayStyle,
|
||||
last_char_timer: Timer,
|
||||
}
|
||||
|
||||
impl Input {
|
||||
const TWITCH: i16 = 4;
|
||||
const X_STEP: i16 = 12;
|
||||
const Y_STEP: i16 = 5;
|
||||
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
area: Rect::zero(),
|
||||
textbox: TextBox::empty(MAX_LENGTH),
|
||||
multi_tap: MultiTapKeyboard::new(),
|
||||
display_style: DisplayStyle::LastChar,
|
||||
last_char_timer: Timer::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_display_style(&mut self, display_style: DisplayStyle) {
|
||||
self.display_style = display_style;
|
||||
}
|
||||
|
||||
fn start_timer(&mut self, ctx: &mut EventCtx, timeout_ms: u32) {
|
||||
self.last_char_timer
|
||||
.start(ctx, Duration::from_secs(timeout_ms));
|
||||
}
|
||||
|
||||
fn stop_timer(&mut self) {
|
||||
self.last_char_timer.stop();
|
||||
}
|
||||
|
||||
fn render_chars<'s>(&self, area: Rect, target: &mut impl Renderer<'s>) {
|
||||
let style = theme::label_keyboard_mono();
|
||||
let mut text_baseline = area.top_left() + Offset::y(style.text_font.text_height());
|
||||
let chars = self.textbox.content().len();
|
||||
|
||||
if chars > 0 {
|
||||
// Find out how much text can fit into the textbox.
|
||||
// Accounting for the pending marker, which draws itself one extra pixel
|
||||
let available_area_width = area.width() - 1;
|
||||
let truncated = long_line_content_with_ellipsis(
|
||||
self.textbox.content(),
|
||||
"",
|
||||
style.text_font,
|
||||
available_area_width,
|
||||
);
|
||||
|
||||
// Jiggle hidden passphrase when overflowed.
|
||||
if chars > truncated.len()
|
||||
&& chars % 2 == 0
|
||||
&& (self.display_style == DisplayStyle::Dots
|
||||
|| self.display_style == DisplayStyle::LastChar)
|
||||
{
|
||||
text_baseline.x += Self::TWITCH;
|
||||
}
|
||||
|
||||
// Paint the visible passphrase.
|
||||
shape::Text::new(text_baseline, &truncated)
|
||||
.with_font(style.text_font)
|
||||
.with_fg(style.text_color)
|
||||
.render(target);
|
||||
|
||||
// Paint the pending marker.
|
||||
if self.multi_tap.pending_key().is_some() {
|
||||
render_pending_marker(
|
||||
target,
|
||||
text_baseline,
|
||||
&truncated,
|
||||
style.text_font,
|
||||
style.text_color,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_dots<'s>(&self, area: Rect, target: &mut impl Renderer<'s>) {
|
||||
let style = theme::label_keyboard_mono();
|
||||
let mut cursor = area.top_left() + Offset::y(style.text_font.text_height());
|
||||
let all_chars = self.textbox.content().len();
|
||||
let last_char = self.display_style == DisplayStyle::LastChar;
|
||||
|
||||
if all_chars > 0 {
|
||||
// Find out how much text can fit into the textbox.
|
||||
// Accounting for the pending marker, which draws itself one extra pixel
|
||||
let truncated = long_line_content_with_ellipsis(
|
||||
self.textbox.content(),
|
||||
"",
|
||||
style.text_font,
|
||||
area.width() - 1,
|
||||
);
|
||||
let visible_chars = truncated.len();
|
||||
|
||||
// Jiggle when overflowed.
|
||||
if all_chars > visible_chars
|
||||
&& all_chars % 2 == 0
|
||||
&& (self.display_style == DisplayStyle::Dots
|
||||
|| self.display_style == DisplayStyle::LastChar)
|
||||
{
|
||||
cursor.x += Self::TWITCH;
|
||||
}
|
||||
|
||||
let visible_dots = visible_chars - last_char as usize;
|
||||
let mut dots = ShortString::new();
|
||||
for _ in 0..visible_dots {
|
||||
dots.push('*').unwrap();
|
||||
}
|
||||
|
||||
// Paint the dots
|
||||
shape::Text::new(cursor, &dots)
|
||||
.with_align(Alignment::Start)
|
||||
.with_font(style.text_font)
|
||||
.with_fg(theme::GREY_MEDIUM)
|
||||
.render(target);
|
||||
|
||||
if last_char {
|
||||
// This should not fail because all_chars > 0
|
||||
let last = &self.textbox.content()[(all_chars - 1)..all_chars];
|
||||
|
||||
// Adapt x position for the character
|
||||
cursor.x +=
|
||||
style.text_font.text_width(&truncated) - style.text_font.text_width(&last);
|
||||
|
||||
// Paint the last character
|
||||
shape::Text::new(cursor, last)
|
||||
.with_align(Alignment::Start)
|
||||
.with_font(style.text_font)
|
||||
.with_fg(style.text_color)
|
||||
.render(target);
|
||||
// Paint the pending marker.
|
||||
if self.multi_tap.pending_key().is_some() {
|
||||
render_pending_marker(target, cursor, last, style.text_font, style.text_color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -333,41 +496,37 @@ impl Component for Input {
|
||||
self.area
|
||||
}
|
||||
|
||||
fn event(&mut self, _ctx: &mut EventCtx, _event: Event) -> Option<Self::Msg> {
|
||||
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
|
||||
match event {
|
||||
Event::Timer(_) if self.last_char_timer.expire(event) => {
|
||||
self.display_style = DisplayStyle::Dots;
|
||||
ctx.request_paint();
|
||||
}
|
||||
Event::Touch(TouchEvent::TouchStart(pos)) if self.area.contains(pos) => {
|
||||
self.display_style = DisplayStyle::Chars;
|
||||
ctx.request_paint();
|
||||
}
|
||||
Event::Touch(TouchEvent::TouchEnd(pos)) if self.area.contains(pos) => {
|
||||
self.display_style = DisplayStyle::Dots;
|
||||
ctx.request_paint();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
let style = theme::label_keyboard();
|
||||
let (text_area, _) = self.area.split_bottom(INPUT_AREA_HEIGHT);
|
||||
|
||||
let text_baseline = self.area.top_left() + Offset::y(style.text_font.text_height())
|
||||
- Offset::y(style.text_font.text_baseline());
|
||||
// Paint the background
|
||||
shape::Bar::new(text_area).with_bg(theme::BG).render(target);
|
||||
|
||||
let text = self.textbox.content();
|
||||
|
||||
shape::Bar::new(self.area).with_bg(theme::BG).render(target);
|
||||
|
||||
// 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 = self.area.width() - 1;
|
||||
let text_to_display =
|
||||
long_line_content_with_ellipsis(text, "...", style.text_font, available_area_width);
|
||||
|
||||
shape::Text::new(text_baseline, &text_to_display)
|
||||
.with_font(style.text_font)
|
||||
.with_fg(style.text_color)
|
||||
.render(target);
|
||||
|
||||
// Paint the pending marker.
|
||||
if self.multi_tap.pending_key().is_some() {
|
||||
render_pending_marker(
|
||||
target,
|
||||
text_baseline,
|
||||
&text_to_display,
|
||||
style.text_font,
|
||||
style.text_color,
|
||||
);
|
||||
// Paint the passphrase
|
||||
if !self.textbox.content().is_empty() {
|
||||
match self.display_style {
|
||||
DisplayStyle::Chars => self.render_chars(text_area, target),
|
||||
_ => self.render_dots(text_area, target),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -120,6 +120,10 @@ pub const fn label_keyboard() -> TextStyle {
|
||||
TextStyle::new(Font::DEMIBOLD, OFF_WHITE, BG, GREY_LIGHT, GREY_LIGHT)
|
||||
}
|
||||
|
||||
pub const fn label_keyboard_mono() -> TextStyle {
|
||||
TextStyle::new(Font::MONO, OFF_WHITE, BG, GREY_LIGHT, GREY_LIGHT)
|
||||
}
|
||||
|
||||
pub const fn label_keyboard_prompt() -> TextStyle {
|
||||
TextStyle::new(Font::DEMIBOLD, GREY_LIGHT, BG, GREY_LIGHT, GREY_LIGHT)
|
||||
}
|
||||
|
@ -27,6 +27,8 @@ class CommonPass:
|
||||
|
||||
EMPTY_ADDRESS = "mvbu1Gdy8SUjTenqerxUaZyYjmveZvt33q"
|
||||
|
||||
MULTI_CATEGORY = "as12 *&_N"
|
||||
|
||||
|
||||
class PassphraseCategory(Enum):
|
||||
MENU = "MENU"
|
||||
|
@ -70,6 +70,9 @@ DA_51_ADDRESS = DA_50_ADDRESS
|
||||
assert len(DA_51) == 51
|
||||
assert DA_51_ADDRESS == DA_50_ADDRESS
|
||||
|
||||
# pending + entered character is shown for 1 + 1 seconds, so the delay must be grater
|
||||
DELAY_S = 2.1
|
||||
|
||||
|
||||
def get_passphrase_choices(char: str) -> tuple[str, ...]:
|
||||
if char in " *#":
|
||||
@ -176,6 +179,11 @@ def enter_passphrase(debug: "DebugLink") -> None:
|
||||
debug.click(buttons.MERCURY_YES)
|
||||
|
||||
|
||||
def show_passphrase(debug: "DebugLink") -> None:
|
||||
"""See the passphrase"""
|
||||
debug.click(buttons.TOP_ROW)
|
||||
|
||||
|
||||
def delete_char(debug: "DebugLink") -> None:
|
||||
"""Deletes the last char"""
|
||||
coords = buttons.pin_passphrase_grid(9)
|
||||
@ -329,3 +337,16 @@ def test_cycle_through_last_character(
|
||||
passphrase = DA_49 + "i" # for i we need to cycle through "ghi" three times
|
||||
input_passphrase(debug, passphrase)
|
||||
enter_passphrase(debug)
|
||||
|
||||
|
||||
@pytest.mark.setup_client(passphrase=True)
|
||||
def test_last_char_timeout(device_handler: "BackgroundDeviceHandler"):
|
||||
with prepare_passphrase_dialogue(device_handler) as debug:
|
||||
for character in CommonPass.MULTI_CATEGORY:
|
||||
# insert a character
|
||||
input_passphrase(debug, character)
|
||||
# wait until the last character is hidden
|
||||
time.sleep(DELAY_S)
|
||||
# show the entire passphrase
|
||||
show_passphrase(debug)
|
||||
enter_passphrase(debug)
|
||||
|
@ -14,6 +14,7 @@
|
||||
# You should have received a copy of the License along with this library.
|
||||
# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
|
||||
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING, Generator, Optional
|
||||
|
||||
@ -54,6 +55,9 @@ AAA_51_ADDRESS = "miPeCUxf1Ufh5DtV3AuBopNM8YEDvnQZMh"
|
||||
assert len(AAA_51) == 51
|
||||
assert AAA_51_ADDRESS == AAA_50_ADDRESS
|
||||
|
||||
# entered character is shown for 1 second, so the delay must be grater
|
||||
DELAY_S = 1.1
|
||||
|
||||
|
||||
BACK = "inputs__back"
|
||||
SHOW = "inputs__show"
|
||||
@ -264,3 +268,16 @@ def test_passphrase_loop_all_characters(device_handler: "BackgroundDeviceHandler
|
||||
go_to_category(debug, PassphraseCategory.MENU, use_carousel=False)
|
||||
|
||||
enter_passphrase(debug)
|
||||
|
||||
|
||||
@pytest.mark.setup_client(passphrase=True)
|
||||
def test_last_char_timeout(device_handler: "BackgroundDeviceHandler"):
|
||||
with prepare_passphrase_dialogue(device_handler) as debug:
|
||||
for character in CommonPass.MULTI_CATEGORY:
|
||||
# insert a character
|
||||
input_passphrase(debug, character)
|
||||
# wait until the last character is hidden
|
||||
time.sleep(DELAY_S)
|
||||
# show the entire passphrase
|
||||
show_passphrase(debug)
|
||||
enter_passphrase(debug)
|
||||
|
@ -63,6 +63,9 @@ DA_51_ADDRESS = DA_50_ADDRESS
|
||||
assert len(DA_51) == 51
|
||||
assert DA_51_ADDRESS == DA_50_ADDRESS
|
||||
|
||||
# pending + entered character is shown for 1 + 1 seconds, so the delay must be grater
|
||||
DELAY_S = 2.1
|
||||
|
||||
|
||||
@contextmanager
|
||||
def prepare_passphrase_dialogue(
|
||||
@ -145,6 +148,11 @@ def enter_passphrase(debug: "DebugLink") -> None:
|
||||
debug.click(coords)
|
||||
|
||||
|
||||
def show_passphrase(debug: "DebugLink") -> None:
|
||||
"""See the passphrase"""
|
||||
debug.click(buttons.TOP_ROW)
|
||||
|
||||
|
||||
def delete_char(debug: "DebugLink") -> None:
|
||||
"""Deletes the last char"""
|
||||
coords = buttons.pin_passphrase_grid(9)
|
||||
@ -296,3 +304,16 @@ def test_cycle_through_last_character(
|
||||
passphrase = DA_49 + "i" # for i we need to cycle through "ghi" three times
|
||||
input_passphrase(debug, passphrase)
|
||||
enter_passphrase(debug)
|
||||
|
||||
|
||||
@pytest.mark.setup_client(passphrase=True)
|
||||
def test_last_char_timeout(device_handler: "BackgroundDeviceHandler"):
|
||||
with prepare_passphrase_dialogue(device_handler) as debug:
|
||||
for character in CommonPass.MULTI_CATEGORY:
|
||||
# insert a character
|
||||
input_passphrase(debug, character)
|
||||
# wait until the last character is hidden
|
||||
time.sleep(DELAY_S)
|
||||
# show the entire passphrase
|
||||
show_passphrase(debug)
|
||||
enter_passphrase(debug)
|
||||
|
Loading…
Reference in New Issue
Block a user