Martin Milata 3 weeks ago committed by GitHub
commit 34ac4adf54
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -4,10 +4,10 @@
}:
let
# the last commit from master as of 2023-04-14
# the last commit from master as of 2024-01-22
rustOverlay = import (builtins.fetchTarball {
url = "https://github.com/oxalica/rust-overlay/archive/db7bf4a2dd295adeeaa809d36387098926a15487.tar.gz";
sha256 = "0gk6kag09w8lyn9was8dpjgslxw5p81bx04379m9v6ky09kw482d";
url = "https://github.com/oxalica/rust-overlay/archive/e36f66bb10b09f5189dc3b1706948eaeb9a1c555.tar.gz";
sha256 = "1vivsmqmqajbvv7181y7mfl48fxmm75hq2c8rj6h1l2ymq28zcpg";
});
# define this variable and devTools if you want nrf{util,connect}
acceptJlink = builtins.getEnv "TREZOR_FIRMWARE_ACCEPT_JLINK_LICENSE" == "yes";
@ -49,7 +49,7 @@ let
done
'';
# NOTE: don't forget to update Minimum Supported Rust Version in docs/core/build/emulator.md
rustProfiles = nixpkgs.rust-bin.nightly."2023-04-14";
rustProfiles = nixpkgs.rust-bin.nightly."2024-01-21";
rustNightly = rustProfiles.minimal.override {
targets = [
"thumbv7em-none-eabihf" # TT

@ -40,18 +40,21 @@ if TREZOR_MODEL in ('R', ):
FONT_BOLD='Font_PixelOperator_Bold_8'
FONT_MONO='Font_PixelOperator_Regular_8'
FONT_BIG=None
FONT_SUB=None
elif TREZOR_MODEL in ('T', 'DISC1', 'DISC2'):
FONT_NORMAL='Font_TTHoves_Regular_21'
FONT_DEMIBOLD='Font_TTHoves_Regular_21'
FONT_BOLD='Font_TTHoves_Bold_17'
FONT_MONO='Font_TTHoves_Regular_21'
FONT_BIG=None
FONT_SUB=None
elif TREZOR_MODEL in ('T3T1',):
FONT_NORMAL='Font_TTSatoshi_DemiBold_21'
FONT_DEMIBOLD='Font_TTSatoshi_DemiBold_21'
FONT_BOLD='Font_TTHoves_Bold_17'
FONT_MONO='Font_TTSatoshi_DemiBold_21'
FONT_BOLD='Font_TTSatoshi_DemiBold_21'
FONT_MONO='Font_RobotoMono_Medium_21'
FONT_BIG=None
FONT_SUB=None
# modtrezorcrypto
CCFLAGS_MOD += '-Wno-sequence-point '
@ -132,6 +135,7 @@ tools.add_font('BOLD', FONT_BOLD, CPPDEFINES_MOD, SOURCE_MOD)
tools.add_font('DEMIBOLD', FONT_DEMIBOLD, CPPDEFINES_MOD, SOURCE_MOD)
tools.add_font('MONO', FONT_MONO, CPPDEFINES_MOD, SOURCE_MOD)
tools.add_font('BIG', FONT_BIG, CPPDEFINES_MOD, SOURCE_MOD)
tools.add_font('SUB', FONT_SUB, CPPDEFINES_MOD, SOURCE_MOD)
env = Environment(
ENV=os.environ,

@ -35,18 +35,21 @@ if TREZOR_MODEL in ('1', 'R'):
FONT_BOLD=None
FONT_MONO='Font_PixelOperatorMono_Regular_8'
FONT_BIG=None
FONT_SUB=None
elif TREZOR_MODEL in ('T',):
FONT_NORMAL='Font_Roboto_Regular_20'
FONT_DEMIBOLD=None
FONT_BOLD=None
FONT_MONO='Font_RobotoMono_Medium_20'
FONT_BIG=None
FONT_SUB=None
elif TREZOR_MODEL in ('T3T1',):
FONT_NORMAL='Font_TTSatoshi_DemiBold_21'
FONT_DEMIBOLD=None
FONT_BOLD=None
FONT_MONO='Font_RobotoMono_Medium_21'
FONT_BIG=None
FONT_SUB=None
# modtrezorcrypto
CCFLAGS_MOD += '-Wno-sequence-point '
@ -107,6 +110,7 @@ tools.add_font('BOLD', FONT_BOLD, CPPDEFINES_MOD, SOURCE_MOD)
tools.add_font('DEMIBOLD', FONT_DEMIBOLD, CPPDEFINES_MOD, SOURCE_MOD)
tools.add_font('MONO', FONT_MONO, CPPDEFINES_MOD, SOURCE_MOD)
tools.add_font('BIG', FONT_BIG, CPPDEFINES_MOD, SOURCE_MOD)
tools.add_font('SUB', FONT_SUB, CPPDEFINES_MOD, SOURCE_MOD)
env = Environment(
ENV=os.environ, CFLAGS='%s -DPRODUCTION=%s' % (ARGUMENTS.get('CFLAGS', ''), ARGUMENTS.get('PRODUCTION', '0')),

@ -37,18 +37,21 @@ if TREZOR_MODEL in ('1', 'R'):
FONT_BOLD='Font_PixelOperator_Bold_8'
FONT_MONO='Font_PixelOperator_Regular_8'
FONT_BIG=None
FONT_SUB=None
elif TREZOR_MODEL in ('T', 'DISC2'):
FONT_NORMAL='Font_TTHoves_Regular_21'
FONT_DEMIBOLD=None
FONT_BOLD='Font_TTHoves_Bold_17'
FONT_MONO=None
FONT_BIG=None
FONT_SUB=None
elif TREZOR_MODEL in ('T3T1',):
FONT_NORMAL='Font_TTSatoshi_DemiBold_21'
FONT_DEMIBOLD='Font_TTSatoshi_DemiBold_21'
FONT_BOLD='Font_TTHoves_Bold_17'
FONT_MONO='Font_TTSatoshi_DemiBold_21'
FONT_BOLD='Font_TTSatoshi_DemiBold_21'
FONT_MONO='Font_RobotoMono_Medium_21'
FONT_BIG=None
FONT_SUB=None
# modtrezorcrypto
CCFLAGS_MOD += '-Wno-sequence-point '
@ -197,6 +200,7 @@ tools.add_font('BOLD', FONT_BOLD, CPPDEFINES_MOD, SOURCE_MOD)
tools.add_font('DEMIBOLD', FONT_DEMIBOLD, CPPDEFINES_MOD, SOURCE_MOD)
tools.add_font('MONO', FONT_MONO, CPPDEFINES_MOD, SOURCE_MOD)
tools.add_font('BIG', FONT_BIG, CPPDEFINES_MOD, SOURCE_MOD)
tools.add_font('SUB', FONT_SUB, CPPDEFINES_MOD, SOURCE_MOD)
env = Environment(ENV=os.environ, CFLAGS='%s -DCONFIDENTIAL= -DPRODUCTION=%s' % (ARGUMENTS.get('CFLAGS', ''), ARGUMENTS.get('PRODUCTION', '0')))

@ -45,12 +45,21 @@ if TREZOR_MODEL in ('1', 'R'):
FONT_BOLD='Font_PixelOperator_Bold_8'
FONT_MONO='Font_PixelOperatorMono_Regular_8'
FONT_BIG='Font_Unifont_Regular_16'
elif TREZOR_MODEL in ('T', 'T3T1', 'DISC1', 'DISC2'):
FONT_SUB=None
elif TREZOR_MODEL in ('T', 'DISC1', 'DISC2'):
FONT_NORMAL='Font_TTHoves_Regular_21'
FONT_DEMIBOLD='Font_TTHoves_DemiBold_21'
FONT_BOLD='Font_TTHoves_Bold_17'
FONT_MONO='Font_RobotoMono_Medium_20'
FONT_BIG=None
FONT_SUB=None
elif TREZOR_MODEL in ('T3T1',):
FONT_NORMAL='Font_TTSatoshi_DemiBold_21'
FONT_DEMIBOLD='Font_TTSatoshi_DemiBold_21'
FONT_BOLD='Font_TTSatoshi_DemiBold_21'
FONT_MONO='Font_RobotoMono_Medium_21'
FONT_BIG='Font_TTSatoshi_DemiBold_42'
FONT_SUB='Font_TTSatoshi_DemiBold_18'
# modtrezorconfig
CPPPATH_MOD += [
@ -408,6 +417,7 @@ tools.add_font('BOLD', FONT_BOLD, CPPDEFINES_MOD, SOURCE_MOD)
tools.add_font('DEMIBOLD', FONT_DEMIBOLD, CPPDEFINES_MOD, SOURCE_MOD)
tools.add_font('MONO', FONT_MONO, CPPDEFINES_MOD, SOURCE_MOD)
tools.add_font('BIG', FONT_BIG, CPPDEFINES_MOD, SOURCE_MOD)
tools.add_font('SUB', FONT_SUB, CPPDEFINES_MOD, SOURCE_MOD)
SOURCE_QSTR = SOURCE_MOD + SOURCE_MICROPYTHON + SOURCE_MICROPYTHON_SPEED
@ -432,12 +442,15 @@ SOURCE_FIRMWARE = [
]
if TREZOR_MODEL in ('T', 'T3T1', 'DISC1', 'DISC2'):
if TREZOR_MODEL in ('T', 'DISC1', 'DISC2'):
UI_LAYOUT = 'UI_LAYOUT_TT'
ui_layout_feature = 'model_tt'
elif TREZOR_MODEL in ('1', 'R'):
UI_LAYOUT = 'UI_LAYOUT_TR'
ui_layout_feature = 'model_tr'
elif TREZOR_MODEL in ('T3T1',):
UI_LAYOUT = 'UI_LAYOUT_MERCURY'
ui_layout_feature = 'model_mercury'
else:
raise ValueError('Unknown Trezor model')
@ -610,6 +623,12 @@ if FROZEN:
SOURCE_PY_DIR + 'trezor/ui/layouts/tr/fido.py',
] if not EVERYTHING else []
))
elif UI_LAYOUT == 'UI_LAYOUT_MERCURY':
SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'trezor/ui/layouts/mercury/*.py',
exclude=[
SOURCE_PY_DIR + 'trezor/ui/layouts/mercury/fido.py',
] if not EVERYTHING else []
))
else:
raise ValueError('Unknown layout')

@ -38,12 +38,21 @@ if TREZOR_MODEL in ('1', 'R'):
FONT_BOLD='Font_PixelOperator_Bold_8'
FONT_MONO=None
FONT_BIG=None
elif TREZOR_MODEL in ('T', 'T3T1'):
FONT_SUB=None
elif TREZOR_MODEL in ('T',):
FONT_NORMAL=None
FONT_DEMIBOLD=None
FONT_BOLD='Font_Roboto_Bold_20'
FONT_MONO=None
FONT_BIG=None
FONT_SUB=None
elif TREZOR_MODEL in ('T3T1',):
FONT_NORMAL='Font_TTSatoshi_DemiBold_21'
FONT_DEMIBOLD=None
FONT_BOLD='Font_TTSatoshi_DemiBold_21'
FONT_MONO='Font_RobotoMono_Medium_21'
FONT_BIG=None
FONT_SUB=None
# modtrezorcrypto
CPPPATH_MOD += [
@ -100,6 +109,7 @@ tools.add_font('BOLD', FONT_BOLD, CPPDEFINES_MOD, SOURCE_MOD)
tools.add_font('DEMIBOLD', FONT_DEMIBOLD, CPPDEFINES_MOD, SOURCE_MOD)
tools.add_font('MONO', FONT_MONO, CPPDEFINES_MOD, SOURCE_MOD)
tools.add_font('BIG', FONT_BIG, CPPDEFINES_MOD, SOURCE_MOD)
tools.add_font('SUB', FONT_SUB, CPPDEFINES_MOD, SOURCE_MOD)
env = Environment(
ENV=os.environ,

@ -33,12 +33,21 @@ if TREZOR_MODEL in ('1', 'R'):
FONT_BOLD='Font_PixelOperator_Bold_8'
FONT_MONO=None
FONT_BIG=None
elif TREZOR_MODEL in ('T', 'T3T1'):
FONT_SUB=None
elif TREZOR_MODEL in ('T',):
FONT_NORMAL=None
FONT_DEMIBOLD=None
FONT_BOLD='Font_Roboto_Bold_20'
FONT_MONO=None
FONT_BIG=None
FONT_SUB=None
elif TREZOR_MODEL in ('T3T1',):
FONT_NORMAL=None
FONT_DEMIBOLD=None
FONT_BOLD='Font_TTSatoshi_DemiBold_21'
FONT_MONO=None
FONT_BIG=None
FONT_SUB=None
# modtrezorcrypto
CPPPATH_MOD += [
@ -74,6 +83,7 @@ tools.add_font('BOLD', FONT_BOLD, CPPDEFINES_MOD, SOURCE_MOD)
tools.add_font('DEMIBOLD', FONT_DEMIBOLD, CPPDEFINES_MOD, SOURCE_MOD)
tools.add_font('MONO', FONT_MONO, CPPDEFINES_MOD, SOURCE_MOD)
tools.add_font('BIG', FONT_BIG, CPPDEFINES_MOD, SOURCE_MOD)
tools.add_font('SUB', FONT_SUB, CPPDEFINES_MOD, SOURCE_MOD)
env = Environment(
ENV=os.environ,

@ -45,12 +45,21 @@ if TREZOR_MODEL in ('1', 'R'):
FONT_BOLD='Font_PixelOperator_Bold_8'
FONT_MONO='Font_PixelOperatorMono_Regular_8'
FONT_BIG='Font_Unifont_Regular_16'
elif TREZOR_MODEL in ('T', 'T3T1'):
FONT_SUB=None
elif TREZOR_MODEL in ('T',):
FONT_NORMAL='Font_TTHoves_Regular_21'
FONT_DEMIBOLD='Font_TTHoves_DemiBold_21'
FONT_BOLD='Font_TTHoves_Bold_17'
FONT_MONO='Font_RobotoMono_Medium_20'
FONT_BIG=None
FONT_SUB=None
elif TREZOR_MODEL in ('T3T1',):
FONT_NORMAL='Font_TTSatoshi_DemiBold_21'
FONT_DEMIBOLD='Font_TTSatoshi_DemiBold_21'
FONT_BOLD='Font_TTSatoshi_DemiBold_21'
FONT_MONO='Font_RobotoMono_Medium_21'
FONT_BIG='Font_TTSatoshi_DemiBold_42'
FONT_SUB='Font_TTSatoshi_DemiBold_18'
# modtrezorconfig
CPPPATH_MOD += [
@ -479,6 +488,7 @@ tools.add_font('BOLD', FONT_BOLD, CPPDEFINES_MOD, SOURCE_MOD)
tools.add_font('DEMIBOLD', FONT_DEMIBOLD, CPPDEFINES_MOD, SOURCE_MOD)
tools.add_font('MONO', FONT_MONO, CPPDEFINES_MOD, SOURCE_MOD)
tools.add_font('BIG', FONT_BIG, CPPDEFINES_MOD, SOURCE_MOD)
tools.add_font('SUB', FONT_SUB, CPPDEFINES_MOD, SOURCE_MOD)
SOURCE_QSTR = SOURCE_MOD + SOURCE_MICROPYTHON + SOURCE_UNIX
@ -491,12 +501,15 @@ else:
env = Environment(ENV=os.environ, CFLAGS='%s -DCONFIDENTIAL= -DPYOPT=%s -DBITCOIN_ONLY=%s %s' % (ARGUMENTS.get('CFLAGS', ''), PYOPT, BITCOIN_ONLY, STATIC))
if TREZOR_MODEL in ('T', 'T3T1'):
if TREZOR_MODEL in ('T',):
UI_LAYOUT = 'UI_LAYOUT_TT'
ui_layout_feature = 'model_tt'
elif TREZOR_MODEL in ('1', 'R'):
UI_LAYOUT = 'UI_LAYOUT_TR'
ui_layout_feature = 'model_tr'
elif TREZOR_MODEL in ('T3T1',):
UI_LAYOUT = 'UI_LAYOUT_MERCURY'
ui_layout_feature = 'model_mercury'
else:
raise ValueError('Unknown Trezor model')
@ -716,6 +729,12 @@ if FROZEN:
SOURCE_PY_DIR + 'trezor/ui/layouts/tr/fido.py',
] if not EVERYTHING else []
))
elif UI_LAYOUT == 'UI_LAYOUT_MERCURY':
SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'trezor/ui/layouts/mercury/*.py',
exclude=[
SOURCE_PY_DIR + 'trezor/ui/layouts/mercury/fido.py',
] if not EVERYTHING else []
))
else:
raise ValueError('Unknown layout')

@ -466,6 +466,8 @@ STATIC const mp_rom_map_elem_t mp_module_trezorutils_globals_table[] = {
{MP_ROM_QSTR(MP_QSTR_UI_LAYOUT), MP_ROM_QSTR(MP_QSTR_TT)},
#elif UI_LAYOUT_TR
{MP_ROM_QSTR(MP_QSTR_UI_LAYOUT), MP_ROM_QSTR(MP_QSTR_TR)},
#elif UI_LAYOUT_MERCURY
{MP_ROM_QSTR(MP_QSTR_UI_LAYOUT), MP_ROM_QSTR(MP_QSTR_MERCURY)},
#else
#error Unknown layout
#endif

@ -49,6 +49,10 @@ int font_height(int font) {
#ifdef TREZOR_FONT_BIG_ENABLE
case FONT_BIG:
return FONT_BIG_HEIGHT;
#endif
#ifdef TREZOR_FONT_SUB_ENABLE
case FONT_SUB:
return FONT_SUB_HEIGHT;
#endif
}
return 0;
@ -75,6 +79,10 @@ int font_max_height(int font) {
#ifdef TREZOR_FONT_BIG_ENABLE
case FONT_BIG:
return FONT_BIG_MAX_HEIGHT;
#endif
#ifdef TREZOR_FONT_SUB_ENABLE
case FONT_SUB:
return FONT_SUB_MAX_HEIGHT;
#endif
}
return 0;
@ -101,6 +109,10 @@ int font_baseline(int font) {
#ifdef TREZOR_FONT_BIG_ENABLE
case FONT_BIG:
return FONT_BIG_BASELINE;
#endif
#ifdef TREZOR_FONT_SUB_ENABLE
case FONT_SUB:
return FONT_SUB_BASELINE;
#endif
}
return 0;
@ -199,6 +211,10 @@ const uint8_t *font_nonprintable_glyph(int font) {
#ifdef TREZOR_FONT_BIG_ENABLE
case FONT_BIG:
return NONPRINTABLE_GLYPH(FONT_BIG_DATA);
#endif
#ifdef TREZOR_FONT_SUB_ENABLE
case FONT_SUB:
return NONPRINTABLE_GLYPH(FONT_SUB_DATA);
#endif
default:
return NULL;
@ -240,6 +256,10 @@ const uint8_t *font_get_glyph(int font, uint16_t c) {
#ifdef TREZOR_FONT_BIG_ENABLE
case FONT_BIG:
return FONT_BIG_DATA[c - ' '];
#endif
#ifdef TREZOR_FONT_SUB_ENABLE
case FONT_SUB:
return FONT_SUB_DATA[c - ' '];
#endif
}
return 0;

@ -64,6 +64,15 @@
FONT_DEFINE(TREZOR_FONT_DEMIBOLD_ENABLE, _BASELINE)
#endif
#ifdef TREZOR_FONT_SUB_ENABLE
#include TREZOR_FONT_SUB_INCLUDE
#define FONT_SUB (-6)
#define FONT_SUB_DATA TREZOR_FONT_SUB_ENABLE
#define FONT_SUB_HEIGHT FONT_DEFINE(TREZOR_FONT_SUB_ENABLE, _HEIGHT)
#define FONT_SUB_MAX_HEIGHT FONT_DEFINE(TREZOR_FONT_SUB_ENABLE, _MAX_HEIGHT)
#define FONT_SUB_BASELINE FONT_DEFINE(TREZOR_FONT_SUB_ENABLE, _BASELINE)
#endif
#ifdef TREZOR_FONT_MONO_ENABLE
#include TREZOR_FONT_MONO_INCLUDE
#define FONT_MONO (-3)
@ -110,10 +119,16 @@
#define FONT_MAX_HEIGHT_5 FONT_MAX_HEIGHT_4
#endif
#ifdef TREZOR_FONT_SUB_ENABLE
#define FONT_MAX_HEIGHT_6 MAX_FONT_H(FONT_SUB_MAX_HEIGHT, FONT_MAX_HEIGHT_5)
#else
#define FONT_MAX_HEIGHT_6 FONT_MAX_HEIGHT_5
#endif
#ifdef TREZOR_FONT_MONO_ENABLE
#define FONT_MAX_HEIGHT MAX_FONT_H(FONT_MONO_MAX_HEIGHT, FONT_MAX_HEIGHT_5)
#define FONT_MAX_HEIGHT MAX_FONT_H(FONT_MONO_MAX_HEIGHT, FONT_MAX_HEIGHT_6)
#else
#define FONT_MAX_HEIGHT FONT_MAX_HEIGHT_5
#define FONT_MAX_HEIGHT FONT_MAX_HEIGHT_6
#endif
int font_height(int font);

@ -53,7 +53,7 @@ static uint64_t term_glyph_bits(char ch) {
uint8_t bytes[8];
} result = {0};
if (ch > ' ' && ch < 128) {
if (ch > ' ' && ch <= '~') {
const uint8_t *b = &Font_Bitmap[(ch - ' ') * 5];
for (int y = 0; y < 7; y++) {

@ -149,6 +149,7 @@ static void _librust_qstrs(void) {
MP_QSTR_confirm_action;
MP_QSTR_confirm_address;
MP_QSTR_confirm_backup;
MP_QSTR_confirm_backup_written_down;
MP_QSTR_confirm_blob;
MP_QSTR_confirm_coinjoin;
MP_QSTR_confirm_emphasized;
@ -172,6 +173,7 @@ static void _librust_qstrs(void) {
MP_QSTR_confirm_value;
MP_QSTR_confirm_with_info;
MP_QSTR_count;
MP_QSTR_create_backup_flow;
MP_QSTR_data;
MP_QSTR_data_hash;
MP_QSTR_data_len;
@ -203,6 +205,7 @@ static void _librust_qstrs(void) {
MP_QSTR_fingerprint;
MP_QSTR_firmware_update__title;
MP_QSTR_firmware_update__title_fingerprint;
MP_QSTR_flow_get_address;
MP_QSTR_get_language;
MP_QSTR_hold;
MP_QSTR_hold_danger;
@ -230,6 +233,9 @@ static void _librust_qstrs(void) {
MP_QSTR_inputs__return;
MP_QSTR_inputs__show;
MP_QSTR_inputs__space;
MP_QSTR_instructions__hold_to_confirm;
MP_QSTR_instructions__swipe_up;
MP_QSTR_instructions__tap_to_confirm;
MP_QSTR_is_type_of;
MP_QSTR_items;
MP_QSTR_joint__title;
@ -527,6 +533,7 @@ static void _librust_qstrs(void) {
MP_QSTR_show_share_words;
MP_QSTR_show_simple;
MP_QSTR_show_success;
MP_QSTR_show_tx_context_menu;
MP_QSTR_show_wait_text;
MP_QSTR_show_warning;
MP_QSTR_sign;
@ -615,6 +622,7 @@ static void _librust_qstrs(void) {
MP_QSTR_words__error;
MP_QSTR_words__fee;
MP_QSTR_words__from;
MP_QSTR_words__important;
MP_QSTR_words__keep_it_safe;
MP_QSTR_words__know_what_your_doing;
MP_QSTR_words__my_trezor;

@ -1237,6 +1237,10 @@ pub enum TranslatedString {
storage_msg__starting = 842, // "STARTING UP"
storage_msg__verifying_pin = 843, // "VERIFYING PIN"
storage_msg__wrong_pin = 844, // "WRONG PIN"
instructions__swipe_up = 845, // "Swipe up"
instructions__tap_to_confirm = 846, // "Tap to confirm"
instructions__hold_to_confirm = 847, // "Hold to confirm"
words__important = 848, // "Important"
}
impl TranslatedString {
@ -2469,6 +2473,10 @@ impl TranslatedString {
Self::storage_msg__starting => "STARTING UP",
Self::storage_msg__verifying_pin => "VERIFYING PIN",
Self::storage_msg__wrong_pin => "WRONG PIN",
Self::instructions__swipe_up => "Swipe up",
Self::instructions__tap_to_confirm => "Tap to confirm",
Self::instructions__hold_to_confirm => "Hold to confirm",
Self::words__important => "Important",
}
}
@ -3702,6 +3710,10 @@ impl TranslatedString {
Qstr::MP_QSTR_storage_msg__starting => Some(Self::storage_msg__starting),
Qstr::MP_QSTR_storage_msg__verifying_pin => Some(Self::storage_msg__verifying_pin),
Qstr::MP_QSTR_storage_msg__wrong_pin => Some(Self::storage_msg__wrong_pin),
Qstr::MP_QSTR_instructions__swipe_up => Some(Self::instructions__swipe_up),
Qstr::MP_QSTR_instructions__tap_to_confirm => Some(Self::instructions__tap_to_confirm),
Qstr::MP_QSTR_instructions__hold_to_confirm => Some(Self::instructions__hold_to_confirm),
Qstr::MP_QSTR_words__important => Some(Self::words__important),
_ => None,
}
}

@ -59,4 +59,8 @@ impl<T> Animation<T> {
panic!("offset is too large");
}
}
pub fn finished(&self, now: Instant) -> bool {
self.elapsed(now) >= self.duration
}
}

@ -74,6 +74,7 @@ pub trait Component {
/// dirty flag for it. Any mutation of `T` has to happen through the `mutate`
/// accessor, `T` can then request a paint call to be scheduled later by calling
/// `EventCtx::request_paint` in its `event` pass.
#[derive(Clone)]
pub struct Child<T> {
component: T,
marked_for_paint: bool,

@ -72,6 +72,7 @@ impl crate::trace::Trace for Image {
}
}
#[derive(Clone)]
pub struct BlendedImage {
bg: Icon,
fg: Icon,

@ -10,6 +10,7 @@ use crate::{
use super::{text::TextStyle, TextLayout};
#[derive(Clone)]
pub struct Label<'a> {
text: TString<'a>,
layout: TextLayout,
@ -37,11 +38,26 @@ impl<'a> Label<'a> {
Self::new(text, Alignment::Center, style)
}
pub fn top_aligned(mut self) -> Self {
self.vertical = Alignment::Start;
self
}
pub fn vertically_centered(mut self) -> Self {
self.vertical = Alignment::Center;
self
}
pub fn bottom_aligned(mut self) -> Self {
self.vertical = Alignment::End;
self
}
pub fn styled(mut self, style: TextStyle) -> Self {
self.layout.style = style;
self
}
pub fn text(&self) -> &TString<'a> {
&self.text
}

@ -16,6 +16,8 @@ pub mod pad;
pub mod paginated;
pub mod placed;
pub mod qr_code;
#[cfg(feature = "touch")]
pub mod swipe;
pub mod text;
pub mod timeout;
@ -33,6 +35,8 @@ pub use pad::Pad;
pub use paginated::{PageMsg, Paginate};
pub use placed::{FixedHeightBar, Floating, GridPlaced, Split};
pub use qr_code::Qr;
#[cfg(feature = "touch")]
pub use swipe::{Swipe, SwipeDirection};
pub use text::{
formatted::FormattedText,
layout::{LineBreaking, PageBreaking, TextLayout},

@ -25,6 +25,7 @@ const CORNER_RADIUS: u8 = 4;
const DARK: Color = Color::rgb(0, 0, 0);
const LIGHT: Color = Color::rgb(0xff, 0xff, 0xff);
#[derive(Clone)]
pub struct Qr {
text: String<MAX_DATA>,
border: i16,

@ -0,0 +1,151 @@
use crate::ui::{
component::{Component, Event, EventCtx},
event::TouchEvent,
geometry::{Point, Rect},
shape::Renderer,
};
#[derive(Copy, Clone)]
pub enum SwipeDirection {
Up,
Down,
Left,
Right,
}
/// Copy of `model_tt/component/swipe.rs` but without the backlight handling.
pub struct Swipe {
pub area: Rect,
pub allow_up: bool,
pub allow_down: bool,
pub allow_left: bool,
pub allow_right: bool,
origin: Option<Point>,
}
impl Swipe {
const DISTANCE: i32 = 120;
const THRESHOLD: f32 = 0.2;
pub fn new() -> Self {
Self {
area: Rect::zero(),
allow_up: false,
allow_down: false,
allow_left: false,
allow_right: false,
origin: None,
}
}
pub fn vertical() -> Self {
Self::new().up().down()
}
pub fn horizontal() -> Self {
Self::new().left().right()
}
pub fn up(mut self) -> Self {
self.allow_up = true;
self
}
pub fn down(mut self) -> Self {
self.allow_down = true;
self
}
pub fn left(mut self) -> Self {
self.allow_left = true;
self
}
pub fn right(mut self) -> Self {
self.allow_right = true;
self
}
fn is_active(&self) -> bool {
self.allow_up || self.allow_down || self.allow_left || self.allow_right
}
fn ratio(&self, dist: i16) -> f32 {
(dist as f32 / Self::DISTANCE as f32).min(1.0)
}
}
impl Component for Swipe {
type Msg = SwipeDirection;
fn place(&mut self, bounds: Rect) -> Rect {
self.area = bounds;
self.area
}
fn event(&mut self, _ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
if !self.is_active() {
return None;
}
match (event, self.origin) {
(Event::Touch(TouchEvent::TouchStart(pos)), _) if self.area.contains(pos) => {
// Mark the starting position of this touch.
self.origin.replace(pos);
}
(Event::Touch(TouchEvent::TouchMove(pos)), Some(origin)) => {
// Consider our allowed directions and the touch distance and modify the display
// backlight accordingly.
let ofs = pos - origin;
let abs = ofs.abs();
if abs.x > abs.y && (self.allow_left || self.allow_right) {
// Horizontal direction.
if (ofs.x < 0 && self.allow_left) || (ofs.x > 0 && self.allow_right) {
// self.backlight(self.ratio(abs.x));
}
} else if abs.x < abs.y && (self.allow_up || self.allow_down) {
// Vertical direction.
if (ofs.y < 0 && self.allow_up) || (ofs.y > 0 && self.allow_down) {
// self.backlight(self.ratio(abs.y));
}
};
}
(Event::Touch(TouchEvent::TouchEnd(pos)), Some(origin)) => {
// Touch interaction is over, reset the position.
self.origin.take();
// Compare the touch distance with our allowed directions and determine if it
// constitutes a valid swipe.
let ofs = pos - origin;
let abs = ofs.abs();
if abs.x > abs.y && (self.allow_left || self.allow_right) {
// Horizontal direction.
if self.ratio(abs.x) >= Self::THRESHOLD {
if ofs.x < 0 && self.allow_left {
return Some(SwipeDirection::Left);
} else if ofs.x > 0 && self.allow_right {
return Some(SwipeDirection::Right);
}
}
} else if abs.x < abs.y && (self.allow_up || self.allow_down) {
// Vertical direction.
if self.ratio(abs.y) >= Self::THRESHOLD {
if ofs.y < 0 && self.allow_up {
return Some(SwipeDirection::Up);
} else if ofs.y > 0 && self.allow_down {
return Some(SwipeDirection::Down);
}
}
};
}
_ => {
// Do nothing.
}
}
None
}
fn paint(&mut self) {}
fn render<'s>(&'s self, _target: &mut impl Renderer<'s>) {}
}

@ -47,6 +47,7 @@ pub trait ParagraphSource<'a> {
}
}
#[derive(Clone)]
pub struct Paragraphs<T> {
area: Rect,
placement: LinearPlacement,
@ -335,6 +336,7 @@ impl<'a> Paragraph<'a> {
}
}
#[derive(Clone)]
struct TextLayoutProxy {
offset: PageOffset,
bounds: Rect,

@ -7,6 +7,7 @@ use crate::{
},
};
#[derive(Clone)]
pub struct Timeout {
time_ms: u32,
timer: Option<TimerToken>,

@ -154,6 +154,7 @@ pub enum Font {
MONO = 3,
BIG = 4,
DEMIBOLD = 5,
SUB = 6,
}
impl From<Font> for i32 {
@ -306,6 +307,16 @@ impl Font {
text.len() // it fits in its entirety
}
pub fn visible_text_height_ex(&self, text: &str) -> (i16, i16) {
let (mut ascent, mut descent) = (0, 0);
for c in text.chars() {
let glyph = self.get_glyph(c);
ascent = ascent.max(glyph.bearing_y);
descent = descent.max(glyph.height - glyph.bearing_y);
}
(ascent, descent)
}
}
pub trait GlyphMetrics {

@ -0,0 +1,90 @@
use crate::ui::{
component::{EventCtx, SwipeDirection},
geometry::Offset,
};
use num_traits::ToPrimitive;
impl SwipeDirection {
pub fn as_offset(self, size: Offset) -> Offset {
match self {
SwipeDirection::Up => Offset::y(-size.y),
SwipeDirection::Down => Offset::y(size.y),
SwipeDirection::Left => Offset::x(-size.x),
SwipeDirection::Right => Offset::x(size.x),
}
}
}
/// Component must implement this trait in order to be part of swipe-based flow.
/// The process of receiving a swipe is two-step, because in order to render the
/// transition animation Flow makes a copy of the pre-swipe state of the
/// component to render it along with the post-swipe state.
pub trait Swipable {
/// Return true if component can handle swipe in a given direction.
fn can_swipe(&self, _direction: SwipeDirection) -> bool {
false
}
/// Make component react to swipe event. Only called if component returned
/// true in the previous function.
fn swiped(&mut self, _ctx: &mut EventCtx, _direction: SwipeDirection) {}
}
/// Component::Msg for component parts of a flow. Converting results of
/// different screens to a shared type makes things easier to work with.
///
/// Also currently the type for message emitted by Flow::event to
/// micropython. They don't need to be the same.
#[derive(Copy, Clone)]
pub enum FlowMsg {
Confirmed,
Cancelled,
Info,
Choice(usize),
}
/// Composable event handler result.
#[derive(Copy, Clone)]
pub enum Decision<Q> {
/// Do nothing, continue with processing next handler.
Nothing,
/// Initiate transition to another state, end event processing.
/// NOTE: it might make sense to include Option<ButtonRequest> here
Goto(Q, SwipeDirection),
/// Yield a message to the caller of the flow (i.e. micropython), end event
/// processing.
Return(FlowMsg),
}
impl<Q> Decision<Q> {
pub fn or_else(self, func: impl FnOnce() -> Self) -> Self {
match self {
Decision::Nothing => func(),
_ => self,
}
}
}
/// Encodes the flow logic as a set of states, and transitions between them
/// triggered by events and swipes.
pub trait FlowState
where
Self: Sized + Copy + PartialEq + Eq + ToPrimitive,
{
/// There needs to be a mapping from states to indices of the FlowStore
/// array. Default implementation works for states that are enums, the
/// FlowStore has to have number of elements equal to number of states.
fn index(&self) -> usize {
unwrap!(self.to_usize())
}
/// What to do when user swipes on screen and current component doesn't
/// respond to swipe of that direction.
fn handle_swipe(&self, direction: SwipeDirection) -> Decision<Self>;
/// What to do when the current component emits a message in response to an
/// event.
fn handle_event(&self, msg: FlowMsg) -> Decision<Self>;
}

@ -0,0 +1,202 @@
use crate::{
error,
time::{Duration, Instant},
ui::{
animation::Animation,
component::{Component, Event, EventCtx, Swipe, SwipeDirection},
flow::{base::Decision, FlowMsg, FlowState, FlowStore},
geometry::{Offset, Rect},
shape::Renderer,
},
};
const ANIMATION_DURATION: Duration = Duration::from_millis(333);
/// Given a state enum and a corresponding FlowStore, create a Component that
/// implements a swipe navigation between the states with animated transitions.
///
/// If a swipe is detected:
/// - currently active component is asked to handle the event,
/// - if it can't then FlowState::handle_swipe is consulted.
pub struct SwipeFlow<Q, S> {
/// Current state.
state: Q,
/// FlowStore with all screens/components.
store: S,
/// `Some` when state transition animation is in progress.
transition: Option<Transition<Q>>,
/// Swipe detector.
swipe: Swipe,
/// Animation parameter.
anim_offset: Offset,
}
struct Transition<Q> {
prev_state: Q,
animation: Animation<Offset>,
direction: SwipeDirection,
}
impl<Q: FlowState, S: FlowStore> SwipeFlow<Q, S> {
pub fn new(init: Q, store: S) -> Result<Self, error::Error> {
Ok(Self {
state: init,
store,
transition: None,
swipe: Swipe::new().down().up().left().right(),
anim_offset: Offset::zero(),
})
}
fn goto(&mut self, ctx: &mut EventCtx, direction: SwipeDirection, state: Q) {
self.transition = Some(Transition {
prev_state: self.state,
animation: Animation::new(
Offset::zero(),
direction.as_offset(self.anim_offset),
ANIMATION_DURATION,
Instant::now(),
),
direction,
});
self.state = state;
ctx.request_anim_frame();
ctx.request_paint()
}
fn render_state<'s>(&'s self, state: Q, target: &mut impl Renderer<'s>) {
self.store.render(state.index(), target)
}
fn render_transition<'s>(&'s self, transition: &Transition<Q>, target: &mut impl Renderer<'s>) {
let off = transition.animation.value(Instant::now());
if self.state == transition.prev_state {
target.with_origin(off, &|target| {
self.store.render_cloned(target);
});
} else {
target.with_origin(off, &|target| {
self.render_state(transition.prev_state, target);
});
}
target.with_origin(
off - transition.direction.as_offset(self.anim_offset),
&|target| {
self.render_state(self.state, target);
},
);
}
fn handle_transition(&mut self, ctx: &mut EventCtx) {
if let Some(transition) = &self.transition {
if transition.animation.finished(Instant::now()) {
self.transition = None;
unwrap!(self.store.clone(None)); // Free the clone.
let msg = self.store.event(self.state.index(), ctx, Event::Attach);
assert!(msg.is_none())
} else {
ctx.request_anim_frame();
}
ctx.request_paint();
}
}
fn handle_swipe_child(&mut self, ctx: &mut EventCtx, direction: SwipeDirection) -> Decision<Q> {
let i = self.state.index();
if self.store.map_swipable(i, |s| s.can_swipe(direction)) {
// Before handling the swipe we make a copy of the original state so that we
// can render both states in the transition animation.
unwrap!(self.store.clone(Some(i)));
self.store.map_swipable(i, |s| s.swiped(ctx, direction));
Decision::Goto(self.state, direction)
} else {
Decision::Nothing
}
}
fn handle_event_child(&mut self, ctx: &mut EventCtx, event: Event) -> Decision<Q> {
let msg = self.store.event(self.state.index(), ctx, event);
if let Some(msg) = msg {
self.state.handle_event(msg)
} else {
Decision::Nothing
}
}
}
impl<Q: FlowState, S: FlowStore> Component for SwipeFlow<Q, S> {
type Msg = FlowMsg;
fn place(&mut self, bounds: Rect) -> Rect {
// Save screen size for slide animation. Once we have reasonable constants trait
// this can be set in the constructor.
self.anim_offset = bounds.size();
self.swipe.place(bounds);
self.store.place(bounds)
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
// TODO: are there any events we want to send to all? timers perhaps?
if let Event::Timer(EventCtx::ANIM_FRAME_TIMER) = event {
self.handle_transition(ctx);
}
// Ignore events while transition is running.
if self.transition.is_some() {
return None;
}
let mut decision = Decision::Nothing;
if let Some(direction) = self.swipe.event(ctx, event) {
decision = self
.handle_swipe_child(ctx, direction)
.or_else(|| self.state.handle_swipe(direction));
}
decision = decision.or_else(|| self.handle_event_child(ctx, event));
match decision {
Decision::Nothing => None,
Decision::Goto(next_state, direction) => {
self.goto(ctx, direction, next_state);
None
}
Decision::Return(msg) => Some(msg),
}
}
fn paint(&mut self) {}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
if let Some(transition) = &self.transition {
self.render_transition(transition, target)
} else {
self.render_state(self.state, target)
}
}
}
#[cfg(feature = "ui_debug")]
impl<Q: FlowState, S: FlowStore> crate::trace::Trace for SwipeFlow<Q, S> {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
self.store.trace(self.state.index(), t)
}
}
#[cfg(feature = "micropython")]
impl<Q: FlowState, S: FlowStore> crate::ui::layout::obj::ComponentMsgObj for SwipeFlow<Q, S> {
fn msg_try_into_obj(
&self,
msg: Self::Msg,
) -> Result<crate::micropython::obj::Obj, error::Error> {
match msg {
FlowMsg::Confirmed => Ok(crate::ui::layout::result::CONFIRMED.as_obj()),
FlowMsg::Cancelled => Ok(crate::ui::layout::result::CANCELLED.as_obj()),
FlowMsg::Info => Ok(crate::ui::layout::result::INFO.as_obj()),
FlowMsg::Choice(i) => {
Ok((crate::ui::layout::result::CONFIRMED.as_obj(), i.try_into()?).try_into()?)
}
}
}
}

@ -0,0 +1,9 @@
pub mod base;
mod flow;
pub mod page;
mod store;
pub use base::{FlowMsg, FlowState, Swipable};
pub use flow::SwipeFlow;
pub use page::{IgnoreSwipe, SwipePage};
pub use store::{flow_store, FlowStore};

@ -0,0 +1,132 @@
use crate::ui::{
component::{Component, Event, EventCtx, Paginate, SwipeDirection},
flow::base::Swipable,
geometry::{Axis, Rect},
shape::Renderer,
};
/// Allows any implementor of `Paginate` to be part of `Swipable` UI flow.
#[derive(Clone)]
pub struct SwipePage<T> {
inner: T,
axis: Axis,
pages: usize,
current: usize,
}
impl<T> SwipePage<T> {
pub fn vertical(inner: T) -> Self {
Self {
inner,
axis: Axis::Vertical,
pages: 1,
current: 0,
}
}
}
impl<T: Component + Paginate> Component for SwipePage<T> {
type Msg = T::Msg;
fn place(&mut self, bounds: Rect) -> Rect {
let result = self.inner.place(bounds);
self.pages = self.inner.page_count();
result
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
let msg = self.inner.event(ctx, event);
msg
}
fn paint(&mut self) {
self.inner.paint()
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
self.inner.render(target)
}
}
impl<T: Component + Paginate> Swipable for SwipePage<T> {
fn can_swipe(&self, direction: SwipeDirection) -> bool {
match (self.axis, direction) {
(Axis::Horizontal, SwipeDirection::Up | SwipeDirection::Down) => false,
(Axis::Vertical, SwipeDirection::Left | SwipeDirection::Right) => false,
(_, SwipeDirection::Left | SwipeDirection::Up) => self.current + 1 < self.pages,
(_, SwipeDirection::Right | SwipeDirection::Down) => self.current > 0,
}
}
fn swiped(&mut self, ctx: &mut EventCtx, direction: SwipeDirection) {
match (self.axis, direction) {
(Axis::Horizontal, SwipeDirection::Up | SwipeDirection::Down) => return,
(Axis::Vertical, SwipeDirection::Left | SwipeDirection::Right) => return,
(_, SwipeDirection::Left | SwipeDirection::Up) => {
self.current = (self.current + 1).min(self.pages - 1);
}
(_, SwipeDirection::Right | SwipeDirection::Down) => {
self.current = self.current.saturating_sub(1);
}
}
self.inner.change_page(self.current);
ctx.request_paint();
}
}
#[cfg(feature = "ui_debug")]
impl<T> crate::trace::Trace for SwipePage<T>
where
T: crate::trace::Trace,
{
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
self.inner.trace(t)
}
}
/// Make any component swipable by ignoring all swipe events.
#[derive(Clone)]
pub struct IgnoreSwipe<T>(T);
impl<T> IgnoreSwipe<T> {
pub fn new(inner: T) -> Self {
IgnoreSwipe(inner)
}
}
impl<T: Component> Component for IgnoreSwipe<T> {
type Msg = T::Msg;
fn place(&mut self, bounds: Rect) -> Rect {
self.0.place(bounds)
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
self.0.event(ctx, event)
}
fn paint(&mut self) {
self.0.paint()
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
self.0.render(target)
}
}
impl<T> Swipable for IgnoreSwipe<T> {
fn can_swipe(&self, _direction: SwipeDirection) -> bool {
false
}
fn swiped(&mut self, _ctx: &mut EventCtx, _direction: SwipeDirection) {}
}
#[cfg(feature = "ui_debug")]
impl<T> crate::trace::Trace for IgnoreSwipe<T>
where
T: crate::trace::Trace,
{
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
self.0.trace(t)
}
}

@ -0,0 +1,219 @@
use crate::{
error,
maybe_trace::MaybeTrace,
ui::{
component::{Component, Event, EventCtx},
flow::base::{FlowMsg, Swipable},
geometry::Rect,
shape::Renderer,
},
};
use crate::micropython::gc::Gc;
/// `FlowStore` is essentially `Vec<Gc<dyn Component + Swipable>>` except that
/// `trait Component` is not object-safe so it ends up being a kind of
/// recursively-defined tuple.
///
/// Additionally the store makes it possible to make a clone of one of its
/// items, in order to make it possible to render transition animations.
pub trait FlowStore {
/// Call `Component::place` on all elements.
fn place(&mut self, bounds: Rect) -> Rect;
/// Call `Component::event` on i-th element, if it emits a message it is
/// converted to `FlowMsg` using a function.
fn event(&mut self, i: usize, ctx: &mut EventCtx, event: Event) -> Option<FlowMsg>;
/// Call `Component::render` on i-th element.
fn render<'s>(&'s self, i: usize, target: &mut impl Renderer<'s>);
#[cfg(feature = "ui_debug")]
/// Call `Trace::trace` on i-th element.
fn trace(&self, i: usize, t: &mut dyn crate::trace::Tracer);
/// Forward `Swipable` methods to i-th element.
fn map_swipable<T>(&mut self, i: usize, func: impl FnOnce(&mut dyn Swipable) -> T) -> T;
/// Make a clone of i-th element, or free all clones if None is given.
fn clone(&mut self, i: Option<usize>) -> Result<(), error::Error>;
/// Call `Component::render` on the cloned element.
fn render_cloned<'s>(&'s self, target: &mut impl Renderer<'s>);
/// Add a Component to the end of a `FlowStore`.
fn add<E: Component + MaybeTrace + Swipable + Clone>(
self,
elem: E,
func: fn(E::Msg) -> Option<FlowMsg>,
) -> Result<impl FlowStore, error::Error>
where
Self: Sized;
}
/// Create new empty flow store.
pub fn flow_store() -> impl FlowStore {
FlowEmpty {}
}
/// Terminating element of a recursive structure.
struct FlowEmpty;
// Methods that take an index panic because it's always out of bounds.
impl FlowStore for FlowEmpty {
fn place(&mut self, bounds: Rect) -> Rect {
bounds
}
fn event(&mut self, _i: usize, _ctx: &mut EventCtx, _event: Event) -> Option<FlowMsg> {
panic!()
}
fn render<'s>(&'s self, _i: usize, _target: &mut impl Renderer<'s>) {
panic!()
}
#[cfg(feature = "ui_debug")]
fn trace(&self, _i: usize, _t: &mut dyn crate::trace::Tracer) {
panic!()
}
fn map_swipable<T>(&mut self, _i: usize, _func: impl FnOnce(&mut dyn Swipable) -> T) -> T {
panic!()
}
fn clone(&mut self, _i: Option<usize>) -> Result<(), error::Error> {
Ok(())
}
fn render_cloned<'s>(&'s self, _target: &mut impl Renderer<'s>) {}
fn add<E: Component + MaybeTrace + Swipable + Clone>(
self,
elem: E,
func: fn(E::Msg) -> Option<FlowMsg>,
) -> Result<impl FlowStore, error::Error>
where
Self: Sized,
{
Ok(FlowComponent {
elem: Gc::new(elem)?,
func,
cloned: None,
next: Self,
})
}
}
struct FlowComponent<E: Component, P> {
/// Component allocated on micropython heap.
pub elem: Gc<E>,
/// Clone.
pub cloned: Option<Gc<E>>,
/// Function to convert message to `FlowMsg`.
pub func: fn(E::Msg) -> Option<FlowMsg>,
/// Nested FlowStore.
pub next: P,
}
impl<E: Component, P> FlowComponent<E, P> {
fn as_ref(&self) -> &E {
&self.elem
}
fn as_mut(&mut self) -> &mut E {
// SAFETY: micropython can only access this object through LayoutObj which wraps
// us in a RefCell which guarantees uniqueness
unsafe { Gc::as_mut(&mut self.elem) }
}
}
impl<E, P> FlowStore for FlowComponent<E, P>
where
E: Component + MaybeTrace + Swipable + Clone,
P: FlowStore,
{
fn place(&mut self, bounds: Rect) -> Rect {
self.as_mut().place(bounds);
self.next.place(bounds);
bounds
}
fn event(&mut self, i: usize, ctx: &mut EventCtx, event: Event) -> Option<FlowMsg> {
if i == 0 {
let msg = self.as_mut().event(ctx, event);
msg.and_then(self.func)
} else {
self.next.event(i - 1, ctx, event)
}
}
fn render<'s>(&'s self, i: usize, target: &mut impl Renderer<'s>) {
if i == 0 {
self.as_ref().render(target)
} else {
self.next.render(i - 1, target)
}
}
#[cfg(feature = "ui_debug")]
fn trace(&self, i: usize, t: &mut dyn crate::trace::Tracer) {
if i == 0 {
self.as_ref().trace(t)
} else {
self.next.trace(i - 1, t)
}
}
fn map_swipable<T>(&mut self, i: usize, func: impl FnOnce(&mut dyn Swipable) -> T) -> T {
if i == 0 {
func(self.as_mut())
} else {
self.next.map_swipable(i - 1, func)
}
}
fn clone(&mut self, i: Option<usize>) -> Result<(), error::Error> {
match i {
None => {
// FIXME: how to ensure the allocation is returned?
self.cloned = None;
self.next.clone(None)?
}
Some(0) => {
self.cloned = Some(Gc::new(self.as_ref().clone())?);
self.next.clone(None)?
}
Some(i) => {
self.cloned = None;
self.next.clone(Some(i - 1))?
}
}
Ok(())
}
fn render_cloned<'s>(&'s self, target: &mut impl Renderer<'s>) {
if let Some(cloned) = &self.cloned {
cloned.render(target)
}
self.next.render_cloned(target);
}
fn add<F: Component + MaybeTrace + Swipable + Clone>(
self,
elem: F,
func: fn(F::Msg) -> Option<FlowMsg>,
) -> Result<impl FlowStore, error::Error>
where
Self: Sized,
{
Ok(FlowComponent {
elem: self.elem,
func: self.func,
cloned: None,
next: self.next.add(elem, func)?,
})
}
}

@ -134,6 +134,12 @@ impl From<Point> for Offset {
}
}
impl Lerp for Offset {
fn lerp(a: Self, b: Self, t: f32) -> Self {
Offset::new(i16::lerp(a.x, b.x, t), i16::lerp(a.y, b.y, t))
}
}
/// A point in 2D space defined by the the `x` and `y` coordinate. Relative
/// coordinates, vectors, and offsets are represented by the `Offset` type.
#[derive(Copy, Clone, PartialEq, Eq)]
@ -545,7 +551,7 @@ pub enum Alignment {
End,
}
#[derive(Copy, Clone)]
#[derive(Copy, Clone, PartialEq, Eq)]
pub struct Alignment2D(pub Alignment, pub Alignment);
impl Alignment2D {

@ -6,6 +6,8 @@ pub mod component;
pub mod constant;
pub mod display;
pub mod event;
#[cfg(all(feature = "micropython", feature = "touch"))]
pub mod flow;
pub mod geometry;
pub mod lerp;
pub mod shape;

@ -0,0 +1,205 @@
use heapless::Vec;
use crate::{
error::Error,
strutil::TString,
translations::TR,
ui::{
component::{
text::paragraphs::{Paragraph, ParagraphSource, ParagraphVecShort, Paragraphs, VecExt},
Component, Event, EventCtx, Paginate, Qr,
},
geometry::Rect,
shape::Renderer,
},
};
use super::{theme, Frame, FrameMsg};
const MAX_XPUBS: usize = 16;
pub struct AddressDetails {
qr_code: Frame<Qr>,
details: Frame<Paragraphs<ParagraphVecShort<'static>>>,
xpub_view: Frame<Paragraphs<Paragraph<'static>>>,
xpubs: Vec<(TString<'static>, TString<'static>), MAX_XPUBS>,
xpub_page_count: Vec<u8, MAX_XPUBS>,
current_page: usize,
}
impl AddressDetails {
pub fn new(
qr_title: TString<'static>,
qr_address: TString<'static>,
case_sensitive: bool,
details_title: TString<'static>,
account: Option<TString<'static>>,
path: Option<TString<'static>>,
) -> Result<Self, Error> {
let mut para = ParagraphVecShort::new();
if let Some(a) = account {
para.add(Paragraph::new(
&theme::TEXT_NORMAL,
TR::words__account_colon,
));
para.add(Paragraph::new(&theme::TEXT_MONO, a));
}
if let Some(p) = path {
para.add(Paragraph::new(
&theme::TEXT_NORMAL,
TR::address_details__derivation_path,
));
para.add(Paragraph::new(&theme::TEXT_MONO, p));
}
let result = Self {
qr_code: Frame::left_aligned(
qr_title,
qr_address
.map(|s| Qr::new(s, case_sensitive))?
.with_border(7),
)
.with_cancel_button()
.with_border(theme::borders_horizontal_scroll()),
details: Frame::left_aligned(
details_title,
para.into_paragraphs(),
)
.with_cancel_button()
.with_border(theme::borders_horizontal_scroll()),
xpub_view: Frame::left_aligned(
" \n ".into(),
Paragraph::new(&theme::TEXT_MONO, "").into_paragraphs(),
)
.with_cancel_button()
.with_border(theme::borders_horizontal_scroll()),
xpubs: Vec::new(),
xpub_page_count: Vec::new(),
current_page: 0,
};
Ok(result)
}
pub fn add_xpub(
&mut self,
title: TString<'static>,
xpub: TString<'static>,
) -> Result<(), Error> {
self.xpubs
.push((title, xpub))
.map_err(|_| Error::OutOfRange)
}
fn switch_xpub(&mut self, i: usize, page: usize) -> usize {
// Context is needed for updating child so that it can request repaint. In this
// case the parent component that handles paging always requests complete
// repaint after page change so we can use a dummy context here.
let mut dummy_ctx = EventCtx::new();
self.xpub_view.update_title(&mut dummy_ctx, self.xpubs[i].0);
self.xpub_view.update_content(&mut dummy_ctx, |_ctx, p| {
p.inner_mut().update(self.xpubs[i].1);
let npages = p.page_count();
p.change_page(page);
npages
})
}
fn lookup(&self, scrollbar_page: usize) -> (usize, usize) {
let mut xpub_index = 0;
let mut xpub_page = scrollbar_page;
for page_count in self.xpub_page_count.iter().map(|pc| {
let upc: usize = (*pc).into();
upc
}) {
if page_count <= xpub_page {
xpub_page -= page_count;
xpub_index += 1;
} else {
break;
}
}
(xpub_index, xpub_page)
}
}
impl Paginate for AddressDetails {
fn page_count(&mut self) -> usize {
let total_xpub_pages: u8 = self.xpub_page_count.iter().copied().sum();
2usize.saturating_add(total_xpub_pages.into())
}
fn change_page(&mut self, to_page: usize) {
self.current_page = to_page;
if to_page > 1 {
let i = to_page - 2;
let (xpub_index, xpub_page) = self.lookup(i);
self.switch_xpub(xpub_index, xpub_page);
}
}
}
impl Component for AddressDetails {
type Msg = ();
fn place(&mut self, bounds: Rect) -> Rect {
self.qr_code.place(bounds);
self.details.place(bounds);
self.xpub_view.place(bounds);
self.xpub_page_count.clear();
for i in 0..self.xpubs.len() {
let npages = self.switch_xpub(i, 0) as u8;
unwrap!(self.xpub_page_count.push(npages));
}
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
let msg = match self.current_page {
0 => self.qr_code.event(ctx, event),
1 => self.details.event(ctx, event),
_ => self.xpub_view.event(ctx, event),
};
match msg {
Some(FrameMsg::Button(_)) => Some(()),
_ => None,
}
}
fn paint(&mut self) {
match self.current_page {
0 => self.qr_code.paint(),
1 => self.details.paint(),
_ => self.xpub_view.paint(),
}
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
match self.current_page {
0 => self.qr_code.render(target),
1 => self.details.render(target),
_ => self.xpub_view.render(target),
}
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
match self.current_page {
0 => self.qr_code.bounds(sink),
1 => self.details.bounds(sink),
_ => self.xpub_view.bounds(sink),
}
}
}
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for AddressDetails {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("AddressDetails");
match self.current_page {
0 => t.child("qr_code", &self.qr_code),
1 => t.child("details", &self.details),
_ => t.child("xpub_view", &self.xpub_view),
}
}
}

@ -4,7 +4,9 @@ use crate::{
strutil::TString,
time::Duration,
ui::{
component::{Component, Event, EventCtx, TimerToken},
component::{
Component, ComponentExt, Event, EventCtx, FixedHeightBar, MsgMap, Split, TimerToken,
},
display::{self, toif::Icon, Color, Font},
event::TouchEvent,
geometry::{Alignment2D, Insets, Offset, Point, Rect},
@ -22,6 +24,7 @@ pub enum ButtonMsg {
LongPressed,
}
#[derive(Clone)]
pub struct Button {
area: Rect,
touch_expand: Option<Insets>,
@ -33,9 +36,10 @@ pub struct Button {
}
impl Button {
/// Offsets the baseline of the button text either up (negative) or down
/// (positive).
pub const BASELINE_OFFSET: i16 = -2;
/// Offsets the baseline of the button text
/// -x/+x => left/right
/// -y/+y => up/down
pub const BASELINE_OFFSET: Offset = Offset::new(2, 6);
pub const fn new(content: ButtonContent) -> Self {
Self {
@ -159,30 +163,7 @@ impl Button {
match &self.content {
ButtonContent::IconBlend(_, _, _) => {}
_ => {
if style.border_width > 0 {
// Paint the border and a smaller background on top of it.
display::rect_fill_rounded(
self.area,
style.border_color,
style.background_color,
style.border_radius,
);
display::rect_fill_rounded(
self.area.inset(Insets::uniform(style.border_width)),
style.button_color,
style.border_color,
style.border_radius,
);
} else {
// We do not need to draw an explicit border in this case, just a
// bigger background.
display::rect_fill_rounded(
self.area,
style.button_color,
style.background_color,
style.border_radius,
);
}
display::rect_fill(self.area, style.button_color);
}
}
}
@ -192,9 +173,7 @@ impl Button {
ButtonContent::IconBlend(_, _, _) => {}
_ => shape::Bar::new(self.area)
.with_bg(style.button_color)
.with_fg(style.border_color)
.with_thickness(style.border_width)
.with_radius(style.border_radius as i16)
.with_fg(style.button_color)
.render(target),
}
}
@ -203,11 +182,7 @@ impl Button {
match &self.content {
ButtonContent::Empty => {}
ButtonContent::Text(text) => {
let width = text.map(|c| style.font.text_width(c));
let height = style.font.text_height();
let start_of_baseline = self.area.center()
+ Offset::new(-width / 2, height / 2)
+ Offset::y(Self::BASELINE_OFFSET);
let start_of_baseline = self.area.center() + Self::BASELINE_OFFSET;
text.map(|text| {
display::text_left(
start_of_baseline,
@ -222,7 +197,7 @@ impl Button {
icon.draw(
self.area.center(),
Alignment2D::CENTER,
style.text_color,
style.icon_color,
style.button_color,
);
}
@ -242,11 +217,7 @@ impl Button {
match &self.content {
ButtonContent::Empty => {}
ButtonContent::Text(text) => {
let width = text.map(|c| style.font.text_width(c));
let height = style.font.text_height();
let start_of_baseline = self.area.center()
+ Offset::new(-width / 2, height / 2)
+ Offset::y(Self::BASELINE_OFFSET);
let start_of_baseline = self.area.left_center() + Self::BASELINE_OFFSET;
text.map(|text| {
shape::Text::new(start_of_baseline, text)
.with_font(style.font)
@ -257,7 +228,7 @@ impl Button {
ButtonContent::Icon(icon) => {
shape::ToifImage::new(self.area.center(), icon.toif)
.with_align(Alignment2D::CENTER)
.with_fg(style.text_color)
.with_fg(style.icon_color)
.render(target);
}
ButtonContent::IconAndText(child) => {
@ -271,7 +242,7 @@ impl Button {
.with_fg(style.button_color)
.render(target);
shape::ToifImage::new(self.area.top_left() + *offset, fg.toif)
.with_fg(style.text_color)
.with_fg(style.icon_color)
.render(target);
}
}
@ -393,7 +364,7 @@ impl crate::trace::Trace for Button {
}
}
#[derive(PartialEq, Eq)]
#[derive(PartialEq, Eq, Clone)]
enum State {
Initial,
Pressed,
@ -401,7 +372,7 @@ enum State {
Disabled,
}
#[derive(PartialEq, Eq)]
#[derive(PartialEq, Eq, Clone)]
pub enum ButtonContent {
Empty,
Text(TString<'static>),
@ -422,12 +393,107 @@ pub struct ButtonStyle {
pub font: Font,
pub text_color: Color,
pub button_color: Color,
pub icon_color: Color,
pub background_color: Color,
pub border_color: Color,
pub border_radius: u8,
pub border_width: i16,
}
impl Button {
pub fn cancel_confirm(
left: Button,
right: Button,
left_is_small: bool,
) -> CancelConfirm<
impl Fn(ButtonMsg) -> Option<CancelConfirmMsg>,
impl Fn(ButtonMsg) -> Option<CancelConfirmMsg>,
> {
let width = if left_is_small {
theme::BUTTON_WIDTH
} else {
0
};
theme::button_bar(Split::left(
width,
theme::BUTTON_SPACING,
left.map(|msg| {
(matches!(msg, ButtonMsg::Clicked)).then(|| CancelConfirmMsg::Cancelled)
}),
right.map(|msg| {
(matches!(msg, ButtonMsg::Clicked)).then(|| CancelConfirmMsg::Confirmed)
}),
))
}
pub fn cancel_confirm_text(
left: Option<TString<'static>>,
right: Option<TString<'static>>,
) -> CancelConfirm<
impl Fn(ButtonMsg) -> Option<CancelConfirmMsg>,
impl Fn(ButtonMsg) -> Option<CancelConfirmMsg>,
> {
let left_is_small: bool;
let left = if let Some(verb) = left {
left_is_small = verb.len() <= 4;
if verb == "^".into() {
Button::with_icon(theme::ICON_UP)
} else {
Button::with_text(verb)
}
} else {
left_is_small = right.is_some();
Button::with_icon(theme::ICON_CANCEL)
};
let right = if let Some(verb) = right {
Button::with_text(verb).styled(theme::button_confirm())
} else {
Button::with_icon(theme::ICON_CONFIRM).styled(theme::button_confirm())
};
Self::cancel_confirm(left, right, left_is_small)
}
pub fn cancel_info_confirm(
confirm: TString<'static>,
info: TString<'static>,
) -> CancelInfoConfirm<
impl Fn(ButtonMsg) -> Option<CancelInfoConfirmMsg>,
impl Fn(ButtonMsg) -> Option<CancelInfoConfirmMsg>,
impl Fn(ButtonMsg) -> Option<CancelInfoConfirmMsg>,
> {
let right = Button::with_text(confirm)
.styled(theme::button_confirm())
.map(|msg| {
(matches!(msg, ButtonMsg::Clicked)).then(|| CancelInfoConfirmMsg::Confirmed)
});
let top = Button::with_text(info)
.styled(theme::button_default())
.map(|msg| (matches!(msg, ButtonMsg::Clicked)).then(|| CancelInfoConfirmMsg::Info));
let left = Button::with_icon(theme::ICON_CANCEL).map(|msg| {
(matches!(msg, ButtonMsg::Clicked)).then(|| CancelInfoConfirmMsg::Cancelled)
});
let total_height = theme::BUTTON_HEIGHT + theme::BUTTON_SPACING + theme::INFO_BUTTON_HEIGHT;
FixedHeightBar::bottom(
Split::top(
theme::INFO_BUTTON_HEIGHT,
theme::BUTTON_SPACING,
top,
Split::left(theme::BUTTON_WIDTH, theme::BUTTON_SPACING, left, right),
),
total_height,
)
}
}
#[derive(Copy, Clone)]
pub enum CancelConfirmMsg {
Cancelled,
Confirmed,
}
type CancelInfoConfirm<F0, F1, F2> =
FixedHeightBar<Split<MsgMap<Button, F0>, Split<MsgMap<Button, F1>, MsgMap<Button, F2>>>>;
type CancelConfirm<F0, F1> = FixedHeightBar<Split<MsgMap<Button, F0>, MsgMap<Button, F1>>>;
#[derive(Clone, Copy)]
pub enum CancelInfoConfirmMsg {
Cancelled,
@ -435,7 +501,7 @@ pub enum CancelInfoConfirmMsg {
Confirmed,
}
#[derive(PartialEq, Eq)]
#[derive(PartialEq, Eq, Clone)]
pub struct IconText {
text: &'static str,
icon: Icon,
@ -450,7 +516,7 @@ impl IconText {
Self { text, icon }
}
pub fn paint(&self, area: Rect, style: &ButtonStyle, baseline_offset: i16) {
pub fn paint(&self, area: Rect, style: &ButtonStyle, baseline_offset: Offset) {
let width = style.font.text_width(self.text);
let height = style.font.text_height();
@ -461,8 +527,7 @@ impl IconText {
area.top_left().x + ((Self::ICON_SPACE + Self::ICON_MARGIN) / 2),
area.center().y,
);
let mut text_pos =
area.center() + Offset::new(-width / 2, height / 2) + Offset::y(baseline_offset);
let mut text_pos = area.center() + Offset::new(-width / 2, height / 2) + baseline_offset;
if area.width() > (Self::ICON_SPACE + Self::TEXT_MARGIN + width) {
//display both icon and text
@ -491,21 +556,19 @@ impl IconText {
self.icon.draw(
icon_pos,
Alignment2D::CENTER,
style.text_color,
style.icon_color,
style.button_color,
);
}
}
pub fn render<'s>(
&self,
target: &mut impl Renderer<'s>,
area: Rect,
style: &ButtonStyle,
baseline_offset: i16,
baseline_offset: Offset,
) {
let width = style.font.text_width(self.text);
let height = style.font.text_height();
let width = style.font.text_width(self.text.as_ref());
let mut use_icon = false;
let mut use_text = false;
@ -514,8 +577,7 @@ impl IconText {
area.top_left().x + ((Self::ICON_SPACE + Self::ICON_MARGIN) / 2),
area.center().y,
);
let mut text_pos =
area.center() + Offset::new(-width / 2, height / 2) + Offset::y(baseline_offset);
let mut text_pos = area.left_center() + baseline_offset;
if area.width() > (Self::ICON_SPACE + Self::TEXT_MARGIN + width) {
//display both icon and text
@ -540,7 +602,7 @@ impl IconText {
if use_icon {
shape::ToifImage::new(icon_pos, self.icon.toif)
.with_align(Alignment2D::CENTER)
.with_fg(style.text_color)
.with_fg(style.icon_color)
.render(target);
}
}

@ -0,0 +1,172 @@
use core::mem;
use crate::{
error::Error,
maybe_trace::MaybeTrace,
strutil::TString,
translations::TR,
ui::{
component::{
base::Never, Bar, Child, Component, ComponentExt, Empty, Event, EventCtx, Label, Split,
},
display::loader::{loader_circular_uncompress, LoaderDimensions},
geometry::{Insets, Offset, Rect},
model_mercury::constant,
shape,
shape::Renderer,
util::animation_disabled,
},
};
use super::{theme, Frame};
const RECTANGLE_HEIGHT: i16 = 56;
const LABEL_TOP: i16 = 135;
const LOADER_OUTER: i16 = 39;
const LOADER_INNER: i16 = 28;
const LOADER_OFFSET: i16 = -34;
const LOADER_SPEED: u16 = 5;
pub struct CoinJoinProgress<U> {
value: u16,
indeterminate: bool,
content: Child<Frame<Split<Empty, U>>>,
// Label is not a child since circular loader paints large black rectangle which overlaps it.
// To work around this, draw label every time loader is drawn.
label: Label<'static>,
}
impl<U> CoinJoinProgress<U> {
pub fn new(
text: TString<'static>,
indeterminate: bool,
) -> Result<CoinJoinProgress<impl Component<Msg = Never> + MaybeTrace>, Error> {
let style = theme::label_coinjoin_progress();
let label = Label::centered(TR::coinjoin__title_do_not_disconnect.into(), style)
.vertically_centered();
let bg = Bar::new(style.background_color, theme::BG, 2);
let inner = (bg, label);
CoinJoinProgress::with_background(text, inner, indeterminate)
}
}
impl<U> CoinJoinProgress<U>
where
U: Component<Msg = Never>,
{
pub fn with_background(
text: TString<'static>,
inner: U,
indeterminate: bool,
) -> Result<Self, Error> {
Ok(Self {
value: 0,
indeterminate,
content: Frame::centered(
TR::coinjoin__title_progress.into(),
Split::bottom(RECTANGLE_HEIGHT, 0, Empty, inner),
)
.into_child(),
label: Label::centered(text, theme::TEXT_NORMAL),
})
}
}
impl<U> Component for CoinJoinProgress<U>
where
U: Component<Msg = Never>,
{
type Msg = Never;
fn place(&mut self, bounds: Rect) -> Rect {
self.content.place(bounds);
let label_bounds = bounds.inset(Insets::top(LABEL_TOP));
self.label.place(label_bounds);
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
self.content.event(ctx, event);
self.label.event(ctx, event);
match event {
_ if animation_disabled() => {
return None;
}
Event::Attach if self.indeterminate => {
ctx.request_anim_frame();
}
Event::Timer(EventCtx::ANIM_FRAME_TIMER) => {
self.value = (self.value + LOADER_SPEED) % 1000;
ctx.request_anim_frame();
ctx.request_paint();
}
Event::Progress(new_value, _new_description) => {
if mem::replace(&mut self.value, new_value) != new_value {
ctx.request_paint();
}
}
_ => {}
}
None
}
fn paint(&mut self) {
self.content.paint();
loader_circular_uncompress(
LoaderDimensions::new(LOADER_OUTER, LOADER_INNER),
LOADER_OFFSET,
theme::FG,
theme::BG,
self.value,
self.indeterminate,
None,
);
self.label.paint();
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
self.content.render(target);
let center = constant::screen().center() + Offset::y(LOADER_OFFSET);
let active_color = theme::FG;
let background_color = theme::BG;
let inactive_color = background_color.blend(active_color, 85);
let start = (self.value as i32 - 100) % 1000;
let end = (self.value as i32 + 100) % 1000;
let start = ((start * 8 * shape::PI4 as i32) / 1000) as i16;
let end = ((end * 8 * shape::PI4 as i32) / 1000) as i16;
shape::Circle::new(center, LOADER_OUTER)
.with_bg(inactive_color)
.render(target);
shape::Circle::new(center, LOADER_OUTER)
.with_bg(active_color)
.with_start_angle(start)
.with_end_angle(end)
.render(target);
shape::Circle::new(center, LOADER_INNER + 2)
.with_bg(active_color)
.render(target);
shape::Circle::new(center, LOADER_INNER)
.with_bg(background_color)
.render(target);
self.label.render(target);
}
}
#[cfg(feature = "ui_debug")]
impl<U> crate::trace::Trace for CoinJoinProgress<U>
where
U: Component + crate::trace::Trace,
{
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("CoinJoinProgress");
t.child("label", &self.label);
t.child("content", &self.content);
}
}

@ -0,0 +1,234 @@
use crate::{
strutil::TString,
ui::{
component::{
image::BlendedImage,
text::{
paragraphs::{Paragraph, ParagraphSource, ParagraphVecShort, Paragraphs, VecExt},
TextStyle,
},
Child, Component, Event, EventCtx, Never,
},
geometry::{Insets, LinearPlacement, Rect},
shape::Renderer,
},
};
use super::theme;
pub enum DialogMsg<T, U> {
Content(T),
Controls(U),
}
pub struct Dialog<T, U> {
content: Child<T>,
controls: Child<U>,
}
impl<T, U> Dialog<T, U>
where
T: Component,
U: Component,
{
pub fn new(content: T, controls: U) -> Self {
Self {
content: Child::new(content),
controls: Child::new(controls),
}
}
pub fn inner(&self) -> &T {
self.content.inner()
}
}
impl<T, U> Component for Dialog<T, U>
where
T: Component,
U: Component,
{
type Msg = DialogMsg<T::Msg, U::Msg>;
fn place(&mut self, bounds: Rect) -> Rect {
let controls_area = self.controls.place(bounds);
let content_area = bounds
.inset(Insets::bottom(controls_area.height()))
.inset(Insets::bottom(theme::BUTTON_SPACING))
.inset(Insets::left(theme::CONTENT_BORDER));
self.content.place(content_area);
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
self.content
.event(ctx, event)
.map(Self::Msg::Content)
.or_else(|| self.controls.event(ctx, event).map(Self::Msg::Controls))
}
fn paint(&mut self) {
self.content.paint();
self.controls.paint();
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
self.content.render(target);
self.controls.render(target);
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
self.content.bounds(sink);
self.controls.bounds(sink);
}
}
#[cfg(feature = "ui_debug")]
impl<T, U> crate::trace::Trace for Dialog<T, U>
where
T: crate::trace::Trace,
U: crate::trace::Trace,
{
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("Dialog");
t.child("content", &self.content);
t.child("controls", &self.controls);
}
}
#[derive(Clone)]
pub struct IconDialog<U> {
image: Child<BlendedImage>,
paragraphs: Paragraphs<ParagraphVecShort<'static>>,
controls: Child<U>,
}
impl<U> IconDialog<U>
where
U: Component,
{
pub fn new(icon: BlendedImage, title: impl Into<TString<'static>>, controls: U) -> Self {
Self {
image: Child::new(icon),
paragraphs: Paragraphs::new(ParagraphVecShort::from_iter([Paragraph::new(
&theme::TEXT_DEMIBOLD,
title,
)
.centered()]))
.with_placement(
LinearPlacement::vertical()
.align_at_center()
.with_spacing(Self::VALUE_SPACE),
),
controls: Child::new(controls),
}
}
pub fn with_paragraph(mut self, para: Paragraph<'static>) -> Self {
if !para.content().is_empty() {
self.paragraphs.inner_mut().add(para);
}
self
}
pub fn with_text(self, style: &'static TextStyle, text: impl Into<TString<'static>>) -> Self {
self.with_paragraph(Paragraph::new(style, text).centered())
}
pub fn with_description(self, description: impl Into<TString<'static>>) -> Self {
self.with_text(&theme::TEXT_NORMAL_OFF_WHITE, description)
}
pub fn with_value(self, value: impl Into<TString<'static>>) -> Self {
self.with_text(&theme::TEXT_MONO, value)
}
pub fn new_shares(lines: [impl Into<TString<'static>>; 4], controls: U) -> Self {
let [l0, l1, l2, l3] = lines;
Self {
image: Child::new(BlendedImage::new(
theme::IMAGE_BG_CIRCLE,
theme::IMAGE_FG_SUCCESS,
theme::SUCCESS_COLOR,
theme::FG,
theme::BG,
)),
paragraphs: ParagraphVecShort::from_iter([
Paragraph::new(&theme::TEXT_NORMAL_OFF_WHITE, l0).centered(),
Paragraph::new(&theme::TEXT_DEMIBOLD, l1).centered(),
Paragraph::new(&theme::TEXT_NORMAL_OFF_WHITE, l2).centered(),
Paragraph::new(&theme::TEXT_DEMIBOLD, l3).centered(),
])
.into_paragraphs()
.with_placement(LinearPlacement::vertical().align_at_center()),
controls: Child::new(controls),
}
}
pub const ICON_AREA_PADDING: i16 = 2;
pub const ICON_AREA_HEIGHT: i16 = 60;
pub const VALUE_SPACE: i16 = 5;
}
impl<U> Component for IconDialog<U>
where
U: Component,
{
type Msg = DialogMsg<Never, U::Msg>;
fn place(&mut self, bounds: Rect) -> Rect {
let bounds = bounds
.inset(theme::borders())
.inset(Insets::top(Self::ICON_AREA_PADDING));
let controls_area = self.controls.place(bounds);
let content_area = bounds.inset(Insets::bottom(controls_area.height()));
let (image_area, content_area) = content_area.split_top(Self::ICON_AREA_HEIGHT);
self.image.place(image_area);
self.paragraphs.place(content_area);
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
self.paragraphs.event(ctx, event);
self.controls.event(ctx, event).map(Self::Msg::Controls)
}
fn paint(&mut self) {
self.image.paint();
self.paragraphs.paint();
self.controls.paint();
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
self.image.render(target);
self.paragraphs.render(target);
self.controls.render(target);
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
self.image.bounds(sink);
self.paragraphs.bounds(sink);
self.controls.bounds(sink);
}
}
#[cfg(feature = "ui_debug")]
impl<U> crate::trace::Trace for IconDialog<U>
where
U: crate::trace::Trace,
{
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("IconDialog");
t.child("image", &self.image);
t.child("content", &self.paragraphs);
t.child("controls", &self.controls);
}
}
#[cfg(feature = "micropython")]
impl<U> crate::ui::flow::Swipable for IconDialog<U> {}

@ -0,0 +1,262 @@
use crate::{
strutil::TString,
ui::{
component::{image::Image, Child, Component, Event, EventCtx, Label},
display,
geometry::{Insets, Rect},
model_mercury::component::{
fido_icons::get_fido_icon_data,
swipe::{Swipe, SwipeDirection},
theme, ScrollBar,
},
shape,
shape::Renderer,
},
};
use super::CancelConfirmMsg;
use core::cell::Cell;
const ICON_HEIGHT: i16 = 70;
const SCROLLBAR_INSET_TOP: i16 = 5;
const SCROLLBAR_HEIGHT: i16 = 10;
const APP_NAME_PADDING: i16 = 12;
const APP_NAME_HEIGHT: i16 = 30;
pub enum FidoMsg {
Confirmed(usize),
Cancelled,
}
pub struct FidoConfirm<F: Fn(usize) -> TString<'static>, U> {
page_swipe: Swipe,
app_name: Label<'static>,
account_name: Label<'static>,
icon: Child<Image>,
/// Function/closure that will return appropriate page on demand.
get_account: F,
scrollbar: ScrollBar,
fade: Cell<bool>,
controls: U,
}
impl<F, U> FidoConfirm<F, U>
where
F: Fn(usize) -> TString<'static>,
U: Component<Msg = CancelConfirmMsg>,
{
pub fn new(
app_name: TString<'static>,
get_account: F,
page_count: usize,
icon_name: Option<TString<'static>>,
controls: U,
) -> Self {
let icon_data = get_fido_icon_data(icon_name);
// Preparing scrollbar and setting its page-count.
let mut scrollbar = ScrollBar::horizontal();
scrollbar.set_count_and_active_page(page_count, 0);
// Preparing swipe component and setting possible initial
// swipe directions according to number of pages.
let mut page_swipe = Swipe::horizontal();
page_swipe.allow_right = scrollbar.has_previous_page();
page_swipe.allow_left = scrollbar.has_next_page();
// NOTE: This is an ugly hotfix for the erroneous behavior of
// TextLayout used in the account_name Label. In this
// particular case, TextLayout calculates the wrong height of
// fitted text that's higher than the TextLayout bound itself.
//
// The following two lines should be swapped when the problem with
// TextLayout is fixed.
//
// See also, continuation of this hotfix in the place() function.
// let current_account = get_account(scrollbar.active_page);
let current_account = "".into();
Self {
app_name: Label::centered(app_name, theme::TEXT_DEMIBOLD),
account_name: Label::centered(current_account, theme::TEXT_DEMIBOLD),
page_swipe,
icon: Child::new(Image::new(icon_data)),
get_account,
scrollbar,
fade: Cell::new(false),
controls,
}
}
fn on_page_swipe(&mut self, ctx: &mut EventCtx, swipe: SwipeDirection) {
// Change the page number.
match swipe {
SwipeDirection::Left if self.scrollbar.has_next_page() => {
self.scrollbar.go_to_next_page();
}
SwipeDirection::Right if self.scrollbar.has_previous_page() => {
self.scrollbar.go_to_previous_page();
}
_ => {} // page did not change
};
// Disable swipes on the boundaries. Not allowing carousel effect.
self.page_swipe.allow_right = self.scrollbar.has_previous_page();
self.page_swipe.allow_left = self.scrollbar.has_next_page();
let current_account = (self.get_account)(self.active_page());
self.account_name.set_text(current_account);
// Redraw the page.
ctx.request_paint();
// Reset backlight to normal level on next paint.
self.fade.set(true);
}
fn active_page(&self) -> usize {
self.scrollbar.active_page
}
}
impl<F, U> Component for FidoConfirm<F, U>
where
F: Fn(usize) -> TString<'static>,
U: Component<Msg = CancelConfirmMsg>,
{
type Msg = FidoMsg;
fn place(&mut self, bounds: Rect) -> Rect {
self.page_swipe.place(bounds);
// Place the control buttons.
let controls_area = self.controls.place(bounds);
// Get the image and content areas.
let content_area = bounds.inset(Insets::bottom(controls_area.height()));
let (image_area, content_area) = content_area.split_top(ICON_HEIGHT);
// In case of showing a scrollbar, getting its area and placing it.
let remaining_area = if self.scrollbar.page_count > 1 {
let (scrollbar_area, remaining_area) = content_area
.inset(Insets::top(SCROLLBAR_INSET_TOP))
.split_top(SCROLLBAR_HEIGHT);
self.scrollbar.place(scrollbar_area);
remaining_area
} else {
content_area
};
// Place the icon image.
self.icon.place(image_area);
// Place the text labels.
let (app_name_area, account_name_area) = remaining_area
.inset(Insets::top(APP_NAME_PADDING))
.split_top(APP_NAME_HEIGHT);
self.app_name.place(app_name_area);
self.account_name.place(account_name_area);
// NOTE: This is a hotfix used due to the erroneous behavior of TextLayout.
// This line should be removed when the problem with TextLayout is fixed.
// See also the code for FidoConfirm::new().
self.account_name
.set_text((self.get_account)(self.scrollbar.active_page));
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
if let Some(swipe) = self.page_swipe.event(ctx, event) {
// Swipe encountered, update the page.
self.on_page_swipe(ctx, swipe);
}
if let Some(msg) = self.controls.event(ctx, event) {
// Some button was clicked, send results.
match msg {
CancelConfirmMsg::Confirmed => return Some(FidoMsg::Confirmed(self.active_page())),
CancelConfirmMsg::Cancelled => return Some(FidoMsg::Cancelled),
}
}
None
}
fn paint(&mut self) {
self.icon.paint();
self.controls.paint();
self.app_name.paint();
if self.scrollbar.page_count > 1 {
self.scrollbar.paint();
}
// Erasing the old text content before writing the new one.
let account_name_area = self.account_name.area();
let real_area = account_name_area
.with_height(account_name_area.height() + self.account_name.font().text_baseline() + 1);
display::rect_fill(real_area, theme::BG);
// Account name is optional.
// Showing it only if it differs from app name.
// (Dummy requests usually have some text as both app_name and account_name.)
let account_name = self.account_name.text();
let app_name = self.app_name.text();
if !account_name.is_empty() && account_name != app_name {
self.account_name.paint();
}
if self.fade.take() {
// Note that this is blocking and takes some time.
display::fade_backlight(theme::BACKLIGHT_NORMAL);
}
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
self.icon.render(target);
self.controls.render(target);
self.app_name.render(target);
if self.scrollbar.page_count > 1 {
self.scrollbar.render(target);
}
// Erasing the old text content before writing the new one.
let account_name_area = self.account_name.area();
let real_area = account_name_area
.with_height(account_name_area.height() + self.account_name.font().text_baseline() + 1);
shape::Bar::new(real_area).with_bg(theme::BG).render(target);
// Account name is optional.
// Showing it only if it differs from app name.
// (Dummy requests usually have some text as both app_name and account_name.)
let account_name = self.account_name.text();
let app_name = self.app_name.text();
if !account_name.is_empty() && account_name != app_name {
self.account_name.render(target);
}
if self.fade.take() {
// Note that this is blocking and takes some time.
display::fade_backlight(theme::BACKLIGHT_NORMAL);
}
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
self.icon.bounds(sink);
self.app_name.bounds(sink);
self.account_name.bounds(sink);
}
}
#[cfg(feature = "ui_debug")]
impl<F, T> crate::trace::Trace for FidoConfirm<F, T>
where
F: Fn(usize) -> TString<'static>,
{
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("FidoConfirm");
}
}

@ -0,0 +1,83 @@
//! generated from webauthn_icons.rs.mako
//! (by running `make templates` in `core`)
//! do not edit manually!
use crate::strutil::TString;
const ICON_APPLE: &[u8] = include_res!("model_mercury/res/fido/icon_apple.toif");
const ICON_AWS: &[u8] = include_res!("model_mercury/res/fido/icon_aws.toif");
const ICON_BINANCE: &[u8] = include_res!("model_mercury/res/fido/icon_binance.toif");
const ICON_BITBUCKET: &[u8] = include_res!("model_mercury/res/fido/icon_bitbucket.toif");
const ICON_BITFINEX: &[u8] = include_res!("model_mercury/res/fido/icon_bitfinex.toif");
const ICON_BITWARDEN: &[u8] = include_res!("model_mercury/res/fido/icon_bitwarden.toif");
const ICON_CLOUDFLARE: &[u8] = include_res!("model_mercury/res/fido/icon_cloudflare.toif");
const ICON_COINBASE: &[u8] = include_res!("model_mercury/res/fido/icon_coinbase.toif");
const ICON_DASHLANE: &[u8] = include_res!("model_mercury/res/fido/icon_dashlane.toif");
const ICON_DROPBOX: &[u8] = include_res!("model_mercury/res/fido/icon_dropbox.toif");
const ICON_DUO: &[u8] = include_res!("model_mercury/res/fido/icon_duo.toif");
const ICON_FACEBOOK: &[u8] = include_res!("model_mercury/res/fido/icon_facebook.toif");
const ICON_FASTMAIL: &[u8] = include_res!("model_mercury/res/fido/icon_fastmail.toif");
const ICON_FEDORA: &[u8] = include_res!("model_mercury/res/fido/icon_fedora.toif");
const ICON_GANDI: &[u8] = include_res!("model_mercury/res/fido/icon_gandi.toif");
const ICON_GEMINI: &[u8] = include_res!("model_mercury/res/fido/icon_gemini.toif");
const ICON_GITHUB: &[u8] = include_res!("model_mercury/res/fido/icon_github.toif");
const ICON_GITLAB: &[u8] = include_res!("model_mercury/res/fido/icon_gitlab.toif");
const ICON_GOOGLE: &[u8] = include_res!("model_mercury/res/fido/icon_google.toif");
const ICON_INVITY: &[u8] = include_res!("model_mercury/res/fido/icon_invity.toif");
const ICON_KEEPER: &[u8] = include_res!("model_mercury/res/fido/icon_keeper.toif");
const ICON_KRAKEN: &[u8] = include_res!("model_mercury/res/fido/icon_kraken.toif");
const ICON_LOGIN_GOV: &[u8] = include_res!("model_mercury/res/fido/icon_login.gov.toif");
const ICON_MICROSOFT: &[u8] = include_res!("model_mercury/res/fido/icon_microsoft.toif");
const ICON_MOJEID: &[u8] = include_res!("model_mercury/res/fido/icon_mojeid.toif");
const ICON_NAMECHEAP: &[u8] = include_res!("model_mercury/res/fido/icon_namecheap.toif");
const ICON_PROTON: &[u8] = include_res!("model_mercury/res/fido/icon_proton.toif");
const ICON_SLUSHPOOL: &[u8] = include_res!("model_mercury/res/fido/icon_slushpool.toif");
const ICON_STRIPE: &[u8] = include_res!("model_mercury/res/fido/icon_stripe.toif");
const ICON_TUTANOTA: &[u8] = include_res!("model_mercury/res/fido/icon_tutanota.toif");
/// Default icon when app does not have its own
const ICON_WEBAUTHN: &[u8] = include_res!("model_mercury/res/fido/icon_webauthn.toif");
/// Translates icon name into its data.
/// Returns default `ICON_WEBAUTHN` when the icon is not found or name not
/// supplied.
pub fn get_fido_icon_data(icon_name: Option<TString<'static>>) -> &'static [u8] {
if let Some(icon_name) = icon_name {
icon_name.map(|c| match c {
"apple" => ICON_APPLE,
"aws" => ICON_AWS,
"binance" => ICON_BINANCE,
"bitbucket" => ICON_BITBUCKET,
"bitfinex" => ICON_BITFINEX,
"bitwarden" => ICON_BITWARDEN,
"cloudflare" => ICON_CLOUDFLARE,
"coinbase" => ICON_COINBASE,
"dashlane" => ICON_DASHLANE,
"dropbox" => ICON_DROPBOX,
"duo" => ICON_DUO,
"facebook" => ICON_FACEBOOK,
"fastmail" => ICON_FASTMAIL,
"fedora" => ICON_FEDORA,
"gandi" => ICON_GANDI,
"gemini" => ICON_GEMINI,
"github" => ICON_GITHUB,
"gitlab" => ICON_GITLAB,
"google" => ICON_GOOGLE,
"invity" => ICON_INVITY,
"keeper" => ICON_KEEPER,
"kraken" => ICON_KRAKEN,
"login.gov" => ICON_LOGIN_GOV,
"microsoft" => ICON_MICROSOFT,
"mojeid" => ICON_MOJEID,
"namecheap" => ICON_NAMECHEAP,
"proton" => ICON_PROTON,
"slushpool" => ICON_SLUSHPOOL,
"stripe" => ICON_STRIPE,
"tutanota" => ICON_TUTANOTA,
_ => ICON_WEBAUTHN,
})
} else {
ICON_WEBAUTHN
}
}

@ -0,0 +1,38 @@
//! generated from webauthn_icons.rs.mako
//! (by running `make templates` in `core`)
//! do not edit manually!
use crate::strutil::TString;
<%
icons: list[tuple[str, str]] = []
for app in fido:
if app.icon is not None:
# Variable names cannot have a dot in themselves
icon_name = app.key
var_name = icon_name.replace(".", "_").upper()
icons.append((icon_name, var_name))
%>\
% for icon_name, var_name in icons:
const ICON_${var_name}: &[u8] = include_res!("model_mercury/res/fido/icon_${icon_name}.toif");
% endfor
/// Default icon when app does not have its own
const ICON_WEBAUTHN: &[u8] = include_res!("model_mercury/res/fido/icon_webauthn.toif");
/// Translates icon name into its data.
/// Returns default `ICON_WEBAUTHN` when the icon is not found or name not
/// supplied.
pub fn get_fido_icon_data(icon_name: Option<TString<'static>>) -> &'static [u8] {
if let Some(icon_name) = icon_name {
icon_name.map(|c| match c {
% for icon_name, var_name in icons:
"${icon_name}" => ICON_${var_name},
% endfor
_ => ICON_WEBAUTHN,
})
} else {
ICON_WEBAUTHN
}
}

@ -0,0 +1,152 @@
use crate::{
strutil::TString,
ui::{
component::{text::TextStyle, Component, Event, EventCtx, Never},
geometry::{Alignment, Offset, Rect},
model_mercury::theme,
shape::{Renderer, Text},
},
};
/// Component showing a task instruction (e.g. "Swipe up") and optionally task
/// description (e.g. "Confirm transaction") to a user. A host of this component
/// is responsible of providing the exact area considering also the spacing. The
/// height must be 18px (only instruction) or 37px (both description and
/// instruction). The content and style of both description and instruction is
/// configurable separatedly.
#[derive(Clone)]
pub struct Footer<'a> {
area: Rect,
text_instruction: TString<'a>,
text_description: Option<TString<'a>>,
style_instruction: &'static TextStyle,
style_description: &'static TextStyle,
}
impl<'a> Footer<'a> {
/// height of the component with only instruction [px]
pub const HEIGHT_SIMPLE: i16 = 18;
/// height of the component with both description and instruction [px]
pub const HEIGHT_DEFAULT: i16 = 37;
pub fn new<T: Into<TString<'a>>>(instruction: T) -> Self {
Self {
area: Rect::zero(),
text_instruction: instruction.into(),
text_description: None,
style_instruction: &theme::TEXT_SUB_GREY,
style_description: &theme::TEXT_SUB_GREY_LIGHT,
}
}
pub fn with_description<T: Into<TString<'a>>>(self, description: T) -> Self {
Self {
text_description: Some(description.into()),
..self
}
}
pub fn update_instruction<T: Into<TString<'a>>>(&mut self, ctx: &mut EventCtx, s: T) {
self.text_instruction = s.into();
ctx.request_paint();
}
pub fn update_description<T: Into<TString<'a>>>(&mut self, ctx: &mut EventCtx, s: T) {
self.text_description = Some(s.into());
ctx.request_paint();
}
pub fn update_instruction_style(&mut self, ctx: &mut EventCtx, style: &'static TextStyle) {
self.style_instruction = style;
ctx.request_paint();
}
pub fn update_description_style(&mut self, ctx: &mut EventCtx, style: &'static TextStyle) {
self.style_description = style;
ctx.request_paint();
}
pub fn height(&self) -> i16 {
if self.text_description.is_some() {
Footer::HEIGHT_DEFAULT
} else {
Footer::HEIGHT_SIMPLE
}
}
}
impl<'a> Component for Footer<'a> {
type Msg = Never;
fn place(&mut self, bounds: Rect) -> Rect {
let h = bounds.height();
assert!(h == Footer::HEIGHT_SIMPLE || h == Footer::HEIGHT_DEFAULT);
self.area = bounds;
bounds
}
fn event(&mut self, _ctx: &mut EventCtx, _event: Event) -> Option<Self::Msg> {
None
}
fn paint(&mut self) {
// TODO: remove when ui-t3t1 done
todo!()
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
// show description only if there is space for it
if self.area.height() == Footer::HEIGHT_DEFAULT {
if let Some(description) = self.text_description {
let area_description = self.area.split_top(Footer::HEIGHT_SIMPLE).0;
let text_description_font_descent = self
.style_description
.text_font
.visible_text_height_ex("Ay")
.1;
let text_description_baseline =
area_description.bottom_center() - Offset::y(text_description_font_descent);
description.map(|t| {
Text::new(text_description_baseline, t)
.with_font(self.style_description.text_font)
.with_fg(self.style_description.text_color)
.with_align(Alignment::Center)
.render(target);
});
}
}
let area_instruction = self.area.split_bottom(Footer::HEIGHT_SIMPLE).1;
let text_instruction_font_descent = self
.style_instruction
.text_font
.visible_text_height_ex("Ay")
.1;
let text_instruction_baseline =
area_instruction.bottom_center() - Offset::y(text_instruction_font_descent);
self.text_instruction.map(|t| {
Text::new(text_instruction_baseline, t)
.with_font(self.style_instruction.text_font)
.with_fg(self.style_instruction.text_color)
.with_align(Alignment::Center)
.render(target);
});
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
sink(self.area);
}
}
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for Footer<'_> {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("Footer");
if let Some(description) = self.text_description {
t.string("description", description);
}
t.string("instruction", self.text_instruction);
}
}

@ -6,13 +6,16 @@ use crate::{
base::ComponentExt, label::Label, text::TextStyle, Child, Component, Event, EventCtx,
},
display::Icon,
geometry::{Alignment, Insets, Offset, Rect},
geometry::{Alignment, Insets, Rect},
shape::Renderer,
},
};
use super::{Button, ButtonMsg, CancelInfoConfirmMsg};
use super::{constant::SPACING, Button, ButtonMsg, ButtonStyleSheet, CancelInfoConfirmMsg, Footer};
const TITLE_HEIGHT: i16 = 42;
#[derive(Clone)]
pub struct Frame<T> {
border: Insets,
title: Child<Label<'static>>,
@ -20,6 +23,7 @@ pub struct Frame<T> {
button: Option<Child<Button>>,
button_msg: CancelInfoConfirmMsg,
content: Child<T>,
footer: Option<Footer<'static>>,
}
pub enum FrameMsg<T> {
@ -31,32 +35,30 @@ impl<T> Frame<T>
where
T: Component,
{
pub fn new(
style: TextStyle,
alignment: Alignment,
title: TString<'static>,
content: T,
) -> Self {
pub fn new(alignment: Alignment, title: TString<'static>, content: T) -> Self {
Self {
title: Child::new(Label::new(title, alignment, style)),
title: Child::new(
Label::new(title, alignment, theme::label_title_main()).vertically_centered(),
),
subtitle: None,
border: theme::borders(),
button: None,
button_msg: CancelInfoConfirmMsg::Cancelled,
content: Child::new(content),
footer: None,
}
}
pub fn left_aligned(style: TextStyle, title: TString<'static>, content: T) -> Self {
Self::new(style, Alignment::Start, title, content)
pub fn left_aligned(title: TString<'static>, content: T) -> Self {
Self::new(Alignment::Start, title, content)
}
pub fn right_aligned(style: TextStyle, title: TString<'static>, content: T) -> Self {
Self::new(style, Alignment::End, title, content)
pub fn right_aligned(title: TString<'static>, content: T) -> Self {
Self::new(Alignment::End, title, content)
}
pub fn centered(style: TextStyle, title: TString<'static>, content: T) -> Self {
Self::new(style, Alignment::Center, title, content)
pub fn centered(title: TString<'static>, content: T) -> Self {
Self::new(Alignment::Center, title, content)
}
pub fn with_border(mut self, border: Insets) -> Self {
@ -64,7 +66,14 @@ where
self
}
pub fn with_subtitle(mut self, style: TextStyle, subtitle: TString<'static>) -> Self {
pub fn title_styled(mut self, style: TextStyle) -> Self {
self.title = Child::new(self.title.into_inner().styled(style));
self
}
pub fn with_subtitle(mut self, subtitle: TString<'static>) -> Self {
let style = theme::TEXT_SUB_GREY_LIGHT;
self.title = Child::new(self.title.into_inner().top_aligned());
self.subtitle = Some(Child::new(Label::new(
subtitle,
self.title.inner().alignment(),
@ -73,7 +82,7 @@ where
self
}
fn with_button(mut self, icon: Icon, msg: CancelInfoConfirmMsg) -> Self {
fn with_button(mut self, icon: Icon, msg: CancelInfoConfirmMsg, enabled: bool) -> Self {
let touch_area = Insets {
left: self.border.left * 4,
bottom: self.border.bottom * 4,
@ -82,18 +91,43 @@ where
self.button = Some(Child::new(
Button::with_icon(icon)
.with_expanded_touch_area(touch_area)
.styled(theme::button_moreinfo()),
.initially_enabled(enabled)
.styled(theme::button_default()),
));
self.button_msg = msg;
self
}
pub fn with_cancel_button(self) -> Self {
self.with_button(theme::ICON_CORNER_CANCEL, CancelInfoConfirmMsg::Cancelled)
self.with_button(theme::ICON_CLOSE, CancelInfoConfirmMsg::Cancelled, true)
}
pub fn with_menu_button(self) -> Self {
self.with_button(theme::ICON_MENU, CancelInfoConfirmMsg::Info, true)
}
pub fn with_info_button(self) -> Self {
self.with_button(theme::ICON_CORNER_INFO, CancelInfoConfirmMsg::Info)
pub fn with_warning_button(self) -> Self {
self.with_button(theme::ICON_WARNING, CancelInfoConfirmMsg::Info, false)
}
pub fn button_styled(mut self, style: ButtonStyleSheet) -> Self {
if self.button.is_some() {
self.button = Some(Child::new(self.button.unwrap().into_inner().styled(style)));
}
self
}
pub fn with_footer(
mut self,
instruction: TString<'static>,
description: Option<TString<'static>>,
) -> Self {
let mut footer = Footer::new(instruction);
if let Some(description_text) = description {
footer = footer.with_description(description_text);
}
self.footer = Some(footer);
self
}
pub fn inner(&self) -> &T {
@ -109,10 +143,10 @@ where
pub fn update_content<F, R>(&mut self, ctx: &mut EventCtx, update_fn: F) -> R
where
F: Fn(&mut T) -> R,
F: Fn(&mut EventCtx, &mut T) -> R,
{
self.content.mutate(ctx, |ctx, c| {
let res = update_fn(c);
let res = update_fn(ctx, c);
c.request_complete_repaint(ctx);
res
})
@ -126,38 +160,33 @@ where
type Msg = FrameMsg<T::Msg>;
fn place(&mut self, bounds: Rect) -> Rect {
const TITLE_SPACE: i16 = theme::BUTTON_SPACING;
let (mut header_area, mut content_area) = bounds.split_top(TITLE_HEIGHT);
content_area = content_area.inset(Insets::top(SPACING));
header_area = header_area.inset(Insets::sides(SPACING));
let bounds = bounds.inset(self.border);
// Allowing for little longer titles to fit in
const TITLE_EXTRA_SPACE: Insets = Insets::right(2);
if let Some(b) = &mut self.button {
let button_side = theme::CORNER_BUTTON_SIDE;
let (header_area, button_area) = bounds.split_right(button_side);
let (button_area, _) = button_area.split_top(button_side);
let (rest, button_area) = header_area.split_right(TITLE_HEIGHT);
header_area = rest;
b.place(button_area);
let title_area = self.title.place(header_area.outset(TITLE_EXTRA_SPACE));
}
if self.subtitle.is_some() {
let title_area = self.title.place(header_area);
let remaining = header_area.inset(Insets::top(title_area.height()));
let subtitle_area = self.subtitle.place(remaining);
let title_height = title_area.height() + subtitle_area.height();
let header_height = title_height.max(button_side);
if title_height < button_side {
self.title
.place(title_area.translate(Offset::y((button_side - title_height) / 2)));
self.subtitle
.place(subtitle_area.translate(Offset::y((button_side - title_height) / 2)));
}
let content_area = bounds.inset(Insets::top(header_height + TITLE_SPACE));
self.content.place(content_area);
let _subtitle_area = self.subtitle.place(remaining);
} else {
let title_area = self.title.place(bounds.outset(TITLE_EXTRA_SPACE));
let remaining = bounds.inset(Insets::top(title_area.height()));
let subtitle_area = self.subtitle.place(remaining);
let remaining = remaining.inset(Insets::top(subtitle_area.height()));
let content_area = remaining.inset(Insets::top(TITLE_SPACE));
self.content.place(content_area);
self.title.place(header_area);
}
if let Some(footer) = &mut self.footer {
// FIXME: spacer at the bottom might be applied also for usage without footer
// but not for VerticalMenu
content_area = content_area.inset(Insets::bottom(SPACING));
let (remaining, footer_area) = content_area.split_bottom(footer.height());
footer.place(footer_area);
content_area = remaining;
}
self.content.place(content_area);
bounds
}
@ -175,12 +204,14 @@ where
self.subtitle.paint();
self.button.paint();
self.content.paint();
self.footer.paint();
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
self.title.render(target);
self.subtitle.render(target);
self.button.render(target);
self.content.render(target);
self.footer.render(target);
}
#[cfg(feature = "ui_bounds")]
@ -189,6 +220,7 @@ where
self.subtitle.bounds(sink);
self.button.bounds(sink);
self.content.bounds(sink);
self.footer.bounds(sink);
}
}
@ -207,5 +239,22 @@ where
if let Some(button) = &self.button {
t.child("button", button);
}
if let Some(footer) = &self.footer {
t.child("footer", footer);
}
}
}
#[cfg(feature = "micropython")]
impl<T> crate::ui::flow::Swipable for Frame<T>
where
T: Component + crate::ui::flow::Swipable,
{
fn can_swipe(&self, direction: crate::ui::component::SwipeDirection) -> bool {
self.inner().can_swipe(direction)
}
fn swiped(&mut self, ctx: &mut EventCtx, direction: crate::ui::component::SwipeDirection) {
self.update_content(ctx, |ctx, inner| inner.swiped(ctx, direction))
}
}

@ -0,0 +1,605 @@
mod render;
use crate::{
micropython::gc::Gc,
strutil::TString,
time::{Duration, Instant},
translations::TR,
trezorhal::usb::usb_configured,
ui::{
component::{Component, Event, EventCtx, Pad, TimerToken},
display::{self, tjpgd::jpeg_info, toif::Icon, Color, Font},
event::{TouchEvent, USBEvent},
geometry::{Alignment, Alignment2D, Insets, Offset, Point, Rect},
layout::util::get_user_custom_image,
model_mercury::{constant, theme::IMAGE_HOMESCREEN},
shape::{self, Renderer},
},
};
use crate::{
trezorhal::{buffers::BufferJpegWork, uzlib::UZLIB_WINDOW_SIZE},
ui::{
constant::HEIGHT,
display::{
tjpgd::BufferInput,
toif::{Toif, ToifFormat},
},
model_mercury::component::homescreen::render::{
HomescreenJpeg, HomescreenToif, HOMESCREEN_TOIF_SIZE,
},
},
};
use render::{
homescreen, homescreen_blurred, HomescreenNotification, HomescreenText,
HOMESCREEN_IMAGE_HEIGHT, HOMESCREEN_IMAGE_WIDTH,
};
use super::{theme, Loader, LoaderMsg};
const AREA: Rect = constant::screen();
const TOP_CENTER: Point = AREA.top_center();
const LABEL_Y: i16 = HEIGHT - 18;
const LOCKED_Y: i16 = HEIGHT / 2 - 13;
const TAP_Y: i16 = HEIGHT / 2 + 14;
const HOLD_Y: i16 = 200;
const COINJOIN_Y: i16 = 30;
const LOADER_OFFSET: Offset = Offset::y(-10);
const LOADER_DELAY: Duration = Duration::from_millis(500);
const LOADER_DURATION: Duration = Duration::from_millis(2000);
pub struct Homescreen {
label: TString<'static>,
notification: Option<(TString<'static>, u8)>,
custom_image: Option<Gc<[u8]>>,
hold_to_lock: bool,
loader: Loader,
pad: Pad,
paint_notification_only: bool,
delay: Option<TimerToken>,
}
pub enum HomescreenMsg {
Dismissed,
}
impl Homescreen {
pub fn new(
label: TString<'static>,
notification: Option<(TString<'static>, u8)>,
hold_to_lock: bool,
) -> Self {
Self {
label,
notification,
custom_image: get_user_custom_image().ok(),
hold_to_lock,
loader: Loader::with_lock_icon().with_durations(LOADER_DURATION, LOADER_DURATION / 3),
pad: Pad::with_background(theme::BG),
paint_notification_only: false,
delay: None,
}
}
fn level_to_style(level: u8) -> (Color, Icon) {
match level {
3 => (theme::YELLOW, theme::ICON_COINJOIN),
2 => (theme::VIOLET, theme::ICON_MAGIC),
1 => (theme::YELLOW, theme::ICON_WARN),
_ => (theme::RED, theme::ICON_WARN),
}
}
fn get_notification(&self) -> Option<HomescreenNotification> {
if !usb_configured() {
let (color, icon) = Self::level_to_style(0);
Some(HomescreenNotification {
text: TR::homescreen__title_no_usb_connection.into(),
icon,
color,
})
} else if let Some((notification, level)) = self.notification {
let (color, icon) = Self::level_to_style(level);
Some(HomescreenNotification {
text: notification,
icon,
color,
})
} else {
None
}
}
fn paint_loader(&mut self) {
TR::progress__locking_device.map_translated(|t| {
display::text_center(
TOP_CENTER + Offset::y(HOLD_Y),
t,
Font::NORMAL,
theme::FG,
theme::BG,
)
});
self.loader.paint()
}
fn render_loader<'s>(&'s self, target: &mut impl Renderer<'s>) {
TR::progress__locking_device.map_translated(|t| {
shape::Text::new(TOP_CENTER + Offset::y(HOLD_Y), t)
.with_align(Alignment::Center)
.with_font(Font::NORMAL)
.with_fg(theme::FG);
});
self.loader.render(target)
}
pub fn set_paint_notification(&mut self) {
self.paint_notification_only = true;
}
fn event_usb(&mut self, ctx: &mut EventCtx, event: Event) {
if let Event::USB(USBEvent::Connected(_)) = event {
self.paint_notification_only = true;
ctx.request_paint();
}
}
fn event_hold(&mut self, ctx: &mut EventCtx, event: Event) -> bool {
match event {
Event::Touch(TouchEvent::TouchStart(_)) => {
if self.loader.is_animating() {
self.loader.start_growing(ctx, Instant::now());
} else {
self.delay = Some(ctx.request_timer(LOADER_DELAY));
}
}
Event::Touch(TouchEvent::TouchEnd(_)) => {
self.delay = None;
let now = Instant::now();
if self.loader.is_completely_grown(now) {
return true;
}
if self.loader.is_animating() {
self.loader.start_shrinking(ctx, now);
}
}
Event::Timer(token) if Some(token) == self.delay => {
self.delay = None;
self.pad.clear();
self.paint_notification_only = false;
self.loader.start_growing(ctx, Instant::now());
}
_ => {}
}
match self.loader.event(ctx, event) {
Some(LoaderMsg::GrownCompletely) => {
// Wait for TouchEnd before returning.
}
Some(LoaderMsg::ShrunkCompletely) => {
self.loader.reset();
self.pad.clear();
self.paint_notification_only = false;
ctx.request_paint()
}
None => {}
}
false
}
}
impl Component for Homescreen {
type Msg = HomescreenMsg;
fn place(&mut self, bounds: Rect) -> Rect {
self.pad.place(AREA);
self.loader.place(AREA.translate(LOADER_OFFSET));
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
Self::event_usb(self, ctx, event);
if self.hold_to_lock {
Self::event_hold(self, ctx, event).then_some(HomescreenMsg::Dismissed)
} else {
None
}
}
fn paint(&mut self) {
self.pad.paint();
if self.loader.is_animating() || self.loader.is_completely_grown(Instant::now()) {
self.paint_loader();
} else {
let mut label_style = theme::TEXT_DEMIBOLD;
label_style.text_color = theme::FG;
let text = HomescreenText {
text: self.label,
style: label_style,
offset: Offset::y(LABEL_Y),
icon: None,
};
let notification = self.get_notification();
let mut show_default = true;
if let Some(ref data) = self.custom_image {
if is_image_jpeg(data.as_ref()) {
let input = BufferInput(data.as_ref());
let mut pool = BufferJpegWork::get_cleared();
let mut hs_img = HomescreenJpeg::new(input, pool.buffer.as_mut_slice());
homescreen(
&mut hs_img,
&[text],
notification,
self.paint_notification_only,
);
show_default = false;
} else if is_image_toif(data.as_ref()) {
let input = unwrap!(Toif::new(data.as_ref()));
let mut window = [0; UZLIB_WINDOW_SIZE];
let mut hs_img =
HomescreenToif::new(input.decompression_context(Some(&mut window)));
homescreen(
&mut hs_img,
&[text],
notification,
self.paint_notification_only,
);
show_default = false;
}
}
if show_default {
let input = BufferInput(IMAGE_HOMESCREEN);
let mut pool = BufferJpegWork::get_cleared();
let mut hs_img = HomescreenJpeg::new(input, pool.buffer.as_mut_slice());
homescreen(
&mut hs_img,
&[text],
notification,
self.paint_notification_only,
);
}
}
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
self.pad.render(target);
if self.loader.is_animating() || self.loader.is_completely_grown(Instant::now()) {
self.render_loader(target);
} else {
let img_data = match self.custom_image {
Some(ref img) => img.as_ref(),
None => IMAGE_HOMESCREEN,
};
if is_image_jpeg(img_data) {
shape::JpegImage::new(self.pad.area.center(), img_data)
.with_align(Alignment2D::CENTER)
.render(target);
} else if is_image_toif(img_data) {
shape::ToifImage::new(self.pad.area.center(), unwrap!(Toif::new(img_data)))
.with_align(Alignment2D::CENTER)
.render(target);
}
self.label.map(|t| {
let r = Rect::new(Point::new(6, 198), Point::new(234, 233));
shape::Bar::new(r)
.with_bg(Color::black())
.with_alpha(89)
.with_radius(3)
.render(target);
let style = theme::TEXT_DEMIBOLD;
let pos = Point::new(self.pad.area.center().x, LABEL_Y);
shape::Text::new(pos, t)
.with_align(Alignment::Center)
.with_font(style.text_font)
.with_fg(theme::FG)
.render(target);
});
if let Some(notif) = self.get_notification() {
const NOTIFICATION_HEIGHT: i16 = 36;
const NOTIFICATION_BORDER: i16 = 6;
const TEXT_ICON_SPACE: i16 = 8;
let banner = self
.pad
.area
.inset(Insets::sides(NOTIFICATION_BORDER))
.with_height(NOTIFICATION_HEIGHT)
.translate(Offset::y(NOTIFICATION_BORDER));
shape::Bar::new(banner)
.with_radius(2)
.with_bg(notif.color)
.render(target);
notif.text.map(|t| {
let style = theme::TEXT_BOLD;
let icon_width = notif.icon.toif.width() + TEXT_ICON_SPACE;
let text_pos = Point::new(
style
.text_font
.horz_center(banner.x0 + icon_width, banner.x1, t),
style.text_font.vert_center(banner.y0, banner.y1, "A"),
);
shape::Text::new(text_pos, t)
.with_font(style.text_font)
.with_fg(style.text_color)
.render(target);
let icon_pos = Point::new(text_pos.x - icon_width, banner.center().y);
shape::ToifImage::new(icon_pos, notif.icon.toif)
.with_fg(style.text_color)
.with_align(Alignment2D::CENTER_LEFT)
.render(target);
});
}
}
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
self.loader.bounds(sink);
sink(self.pad.area);
}
}
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for Homescreen {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("Homescreen");
t.string("label", self.label);
}
}
pub struct Lockscreen<'a> {
label: TString<'a>,
custom_image: Option<Gc<[u8]>>,
bootscreen: bool,
coinjoin_authorized: bool,
}
impl<'a> Lockscreen<'a> {
pub fn new(label: TString<'a>, bootscreen: bool, coinjoin_authorized: bool) -> Self {
Lockscreen {
label,
custom_image: get_user_custom_image().ok(),
bootscreen,
coinjoin_authorized,
}
}
}
impl Component for Lockscreen<'_> {
type Msg = HomescreenMsg;
fn place(&mut self, bounds: Rect) -> Rect {
bounds
}
fn event(&mut self, _ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
if let Event::Touch(TouchEvent::TouchEnd(_)) = event {
return Some(HomescreenMsg::Dismissed);
}
None
}
fn paint(&mut self) {
let (locked, tap) = if self.bootscreen {
(
TR::lockscreen__title_not_connected,
TR::lockscreen__tap_to_connect,
)
} else {
(TR::lockscreen__title_locked, TR::lockscreen__tap_to_unlock)
};
let mut label_style = theme::TEXT_DEMIBOLD;
label_style.text_color = theme::GREY_LIGHT;
let mut texts: &[HomescreenText] = &[
HomescreenText {
text: "".into(),
style: theme::TEXT_NORMAL,
offset: Offset::new(2, COINJOIN_Y),
icon: Some(theme::ICON_COINJOIN),
},
HomescreenText {
text: locked.into(),
style: theme::TEXT_BOLD,
offset: Offset::y(LOCKED_Y),
icon: Some(theme::ICON_LOCK),
},
HomescreenText {
text: tap.into(),
style: theme::TEXT_NORMAL,
offset: Offset::y(TAP_Y),
icon: None,
},
HomescreenText {
text: self.label,
style: label_style,
offset: Offset::y(LABEL_Y),
icon: None,
},
];
if !self.coinjoin_authorized {
texts = &texts[1..];
}
let mut show_default = true;
if let Some(ref data) = self.custom_image {
if is_image_jpeg(data.as_ref()) {
let input = BufferInput(data.as_ref());
let mut pool = BufferJpegWork::get_cleared();
let mut hs_img = HomescreenJpeg::new(input, pool.buffer.as_mut_slice());
homescreen_blurred(&mut hs_img, texts);
show_default = false;
} else if is_image_toif(data.as_ref()) {
let input = unwrap!(Toif::new(data.as_ref()));
let mut window = [0; UZLIB_WINDOW_SIZE];
let mut hs_img =
HomescreenToif::new(input.decompression_context(Some(&mut window)));
homescreen_blurred(&mut hs_img, texts);
show_default = false;
}
}
if show_default {
let input = BufferInput(IMAGE_HOMESCREEN);
let mut pool = BufferJpegWork::get_cleared();
let mut hs_img = HomescreenJpeg::new(input, pool.buffer.as_mut_slice());
homescreen_blurred(&mut hs_img, texts);
}
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
let img_data = match self.custom_image {
Some(ref img) => img.as_ref(),
None => IMAGE_HOMESCREEN,
};
let center = constant::screen().center();
if is_image_jpeg(img_data) {
shape::JpegImage::new(center, img_data)
.with_align(Alignment2D::CENTER)
.with_blur(4)
.with_dim(140)
.render(target);
} else if is_image_toif(img_data) {
shape::ToifImage::new(center, unwrap!(Toif::new(img_data)))
.with_align(Alignment2D::CENTER)
//.with_blur(5)
.render(target);
}
let (locked, tap) = if self.bootscreen {
(
TR::lockscreen__title_not_connected,
TR::lockscreen__tap_to_connect,
)
} else {
(TR::lockscreen__title_locked, TR::lockscreen__tap_to_unlock)
};
let mut label_style = theme::TEXT_DEMIBOLD;
label_style.text_color = theme::GREY_LIGHT;
let mut texts: &[HomescreenText] = &[
HomescreenText {
text: "".into(),
style: theme::TEXT_NORMAL,
offset: Offset::new(2, COINJOIN_Y),
icon: Some(theme::ICON_COINJOIN),
},
HomescreenText {
text: locked.into(),
style: theme::TEXT_BOLD,
offset: Offset::y(LOCKED_Y),
icon: Some(theme::ICON_LOCK),
},
HomescreenText {
text: tap.into(),
style: theme::TEXT_NORMAL,
offset: Offset::y(TAP_Y),
icon: None,
},
HomescreenText {
text: self.label,
style: label_style,
offset: Offset::y(LABEL_Y),
icon: None,
},
];
if !self.coinjoin_authorized {
texts = &texts[1..];
}
for item in texts.iter() {
item.text.map(|t| {
const TEXT_ICON_SPACE: i16 = 2;
let icon_width = match item.icon {
Some(icon) => icon.toif.width() + TEXT_ICON_SPACE,
None => 0,
};
let area = constant::screen();
let text_pos = Point::new(
item.style
.text_font
.horz_center(area.x0 + icon_width, area.x1, t),
0,
) + item.offset;
shape::Text::new(text_pos, t)
.with_font(item.style.text_font)
.with_fg(item.style.text_color)
.render(target);
if let Some(icon) = item.icon {
let icon_pos = Point::new(text_pos.x - icon_width, text_pos.y);
shape::ToifImage::new(icon_pos, icon.toif)
.with_align(Alignment2D::BOTTOM_LEFT)
.with_fg(item.style.text_color)
.render(target);
}
});
}
}
}
pub fn check_homescreen_format(buffer: &[u8]) -> bool {
#[cfg(not(feature = "new_rendering"))]
let result = is_image_jpeg(buffer) && crate::ui::display::tjpgd::jpeg_test(buffer);
#[cfg(feature = "new_rendering")]
let result = is_image_jpeg(buffer); // !@# TODO: test like if `new_rendering` is off
result
}
fn is_image_jpeg(buffer: &[u8]) -> bool {
let jpeg = jpeg_info(buffer);
if let Some((size, mcu_height)) = jpeg {
if size.x == HOMESCREEN_IMAGE_WIDTH && size.y == HOMESCREEN_IMAGE_HEIGHT && mcu_height <= 16
{
return true;
}
}
false
}
fn is_image_toif(buffer: &[u8]) -> bool {
let toif = Toif::new(buffer);
if let Ok(toif) = toif {
if toif.size().x == HOMESCREEN_TOIF_SIZE
&& toif.size().y == HOMESCREEN_TOIF_SIZE
&& toif.format() == ToifFormat::FullColorBE
{
return true;
}
}
false
}
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for Lockscreen<'_> {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("Lockscreen");
}
}

@ -0,0 +1,768 @@
use crate::{
strutil::TString,
trezorhal::{
buffers::{
BufferBlurring, BufferBlurringTotals, BufferJpeg, BufferLine16bpp, BufferLine4bpp,
BufferText,
},
display,
dma2d::{dma2d_setup_4bpp_over_16bpp, dma2d_start_blend, dma2d_wait_for_transfer},
uzlib::UzlibContext,
},
ui::{
component::text::TextStyle,
constant::{screen, HEIGHT, WIDTH},
display::{
position_buffer, rect_fill_rounded_buffer, set_window,
tjpgd::{BufferInput, BufferOutput, JDEC},
Color, Icon,
},
geometry::{Offset, Point, Rect},
model_mercury::theme,
util::icon_text_center,
},
};
#[derive(Clone, Copy)]
pub struct HomescreenText<'a> {
pub text: TString<'a>,
pub style: TextStyle,
pub offset: Offset,
pub icon: Option<Icon>,
}
#[derive(Clone, Copy)]
pub struct HomescreenNotification {
pub text: TString<'static>,
pub icon: Icon,
pub color: Color,
}
#[derive(Clone, Copy)]
struct HomescreenTextInfo {
pub text_area: Rect,
pub text_width: i16,
pub text_color: Color,
pub icon_area: Option<Rect>,
}
pub const HOMESCREEN_IMAGE_WIDTH: i16 = WIDTH;
pub const HOMESCREEN_IMAGE_HEIGHT: i16 = HEIGHT;
pub const HOMESCREEN_TOIF_SIZE: i16 = 144;
pub const HOMESCREEN_TOIF_Y_OFFSET: i16 = 27;
pub const HOMESCREEN_TOIF_X_OFFSET: usize =
((WIDTH.saturating_sub(HOMESCREEN_TOIF_SIZE)) / 2) as usize;
const HOMESCREEN_MAX_ICON_SIZE: i16 = 20;
const NOTIFICATION_HEIGHT: i16 = 36;
const NOTIFICATION_BORDER: i16 = 6;
const TEXT_ICON_SPACE: i16 = 2;
const HOMESCREEN_DIM_HEIGHT: i16 = 35;
const HOMESCREEN_DIM_START: i16 = HOMESCREEN_IMAGE_HEIGHT - 42;
const HOMESCREEN_DIM: f32 = 0.65;
const HOMESCREEN_DIM_BORDER: i16 = theme::BUTTON_SPACING;
const LOCKSCREEN_DIM: f32 = 0.55;
const LOCKSCREEN_DIM_BG: f32 = 0.0;
const LOCKSCREEN_DIM_ALL: bool = true;
const BLUR_SIZE: usize = 9;
const BLUR_DIV: u32 =
((65536_f32 * (1_f32 - LOCKSCREEN_DIM_BG)) as u32) / ((BLUR_SIZE * BLUR_SIZE) as u32);
const DECOMP_LINES: usize = BLUR_SIZE + 1;
const BLUR_RADIUS: i16 = (BLUR_SIZE / 2) as i16;
const COLORS: usize = 3;
const RED_IDX: usize = 0;
const GREEN_IDX: usize = 1;
const BLUE_IDX: usize = 2;
pub trait HomescreenDecompressor {
fn get_height(&self) -> i16;
fn decompress(&mut self);
fn get_data(&mut self) -> &mut BufferJpeg;
}
pub struct HomescreenJpeg<'i> {
pub output: BufferOutput,
pub input: BufferInput<'i>,
pub jdec: Option<JDEC<'i>>,
}
impl<'i> HomescreenJpeg<'i> {
pub fn new(mut input: BufferInput<'i>, pool: &'i mut [u8]) -> Self {
Self {
output: BufferOutput::new(WIDTH, 16),
jdec: JDEC::new(&mut input, pool).ok(),
input,
}
}
}
impl<'i> HomescreenDecompressor for HomescreenJpeg<'i> {
fn get_height(&self) -> i16 {
if let Some(dec) = self.jdec.as_ref() {
return dec.mcu_height();
}
1
}
fn decompress(&mut self) {
self.jdec
.as_mut()
.map(|dec| dec.decomp(&mut self.input, &mut self.output));
}
fn get_data(&mut self) -> &mut BufferJpeg {
self.output.buffer()
}
}
pub struct HomescreenToif<'i> {
pub output: BufferOutput,
pub decomp_context: UzlibContext<'i>,
line: i16,
}
impl<'i> HomescreenToif<'i> {
pub fn new(context: UzlibContext<'i>) -> Self {
Self {
output: BufferOutput::new(WIDTH, 16),
decomp_context: context,
line: 0,
}
}
}
impl<'i> HomescreenDecompressor for HomescreenToif<'i> {
fn get_height(&self) -> i16 {
1
}
fn decompress(&mut self) {
// SAFETY: Aligning to u8 slice is safe, because the original slice is aligned
// to 16 bits, therefore there are also no residuals (prefix/suffix).
// The data in the slices are integers, so these are valid for both u16
// and u8.
if self.line >= HOMESCREEN_TOIF_Y_OFFSET
&& self.line < HOMESCREEN_TOIF_Y_OFFSET + HOMESCREEN_TOIF_SIZE
{
let (_, workbuf, _) = unsafe { self.output.buffer().buffer.align_to_mut::<u8>() };
let result = self.decomp_context.uncompress(
&mut workbuf[2 * HOMESCREEN_TOIF_X_OFFSET
..2 * HOMESCREEN_TOIF_X_OFFSET + 2 * HOMESCREEN_TOIF_SIZE as usize],
);
if result.is_err() {
self.output.buffer().buffer.fill(0);
} else {
for i in 0..HOMESCREEN_TOIF_SIZE as usize {
workbuf.swap(
2 * HOMESCREEN_TOIF_X_OFFSET + 2 * i,
2 * HOMESCREEN_TOIF_X_OFFSET + 2 * i + 1,
);
}
}
} else {
self.output.buffer().buffer.fill(0);
}
self.line += 1;
}
fn get_data(&mut self) -> &mut BufferJpeg {
self.output.buffer()
}
}
fn homescreen_get_fg_text(
y_tmp: i16,
text_info: HomescreenTextInfo,
text_buffer: &BufferText,
fg_buffer: &mut BufferLine4bpp,
) -> bool {
if y_tmp >= text_info.text_area.y0 && y_tmp < text_info.text_area.y1 {
let y_pos = y_tmp - text_info.text_area.y0;
position_buffer(
&mut fg_buffer.buffer,
&text_buffer.buffer[(y_pos * WIDTH / 2) as usize..((y_pos + 1) * WIDTH / 2) as usize],
4,
text_info.text_area.x0,
text_info.text_width,
);
}
y_tmp == (text_info.text_area.y1 - 1)
}
fn homescreen_get_fg_icon(
y_tmp: i16,
text_info: HomescreenTextInfo,
icon_data: &[u8],
fg_buffer: &mut BufferLine4bpp,
) {
if let Some(icon_area) = text_info.icon_area {
let icon_size = icon_area.size();
if y_tmp >= icon_area.y0 && y_tmp < icon_area.y1 {
let y_pos = y_tmp - icon_area.y0;
position_buffer(
&mut fg_buffer.buffer,
&icon_data
[(y_pos * icon_size.x / 2) as usize..((y_pos + 1) * icon_size.x / 2) as usize],
4,
icon_area.x0,
icon_size.x,
);
}
}
}
fn homescreen_position_text(
text: &HomescreenText,
buffer: &mut BufferText,
icon_buffer: &mut [u8],
) -> HomescreenTextInfo {
let text_width = text
.text
.map(|t| display::text_width(t, text.style.text_font.into()));
let font_max_height = display::text_max_height(text.style.text_font.into());
let font_baseline = display::text_baseline(text.style.text_font.into());
let text_width_clamped = text_width.clamp(0, screen().width());
let icon_size = if let Some(icon) = text.icon {
let size = icon.toif.size();
assert!(size.x <= HOMESCREEN_MAX_ICON_SIZE);
assert!(size.y <= HOMESCREEN_MAX_ICON_SIZE);
icon.toif.uncompress(icon_buffer);
size
} else {
Offset::zero()
};
let text_top = screen().y0 + text.offset.y - font_max_height + font_baseline;
let text_bottom = screen().y0 + text.offset.y + font_baseline;
let total_width = text_width_clamped + icon_size.x + TEXT_ICON_SPACE;
let icon_left = screen().center().x + text.offset.x - total_width / 2;
let text_left = icon_left + icon_size.x + TEXT_ICON_SPACE;
let text_right = screen().center().x + text.offset.x + total_width / 2;
let text_area = Rect::new(
Point::new(text_left, text_top),
Point::new(text_right, text_bottom),
);
let icon_area = if text.icon.is_some() {
Some(Rect::from_top_left_and_size(
Point::new(icon_left, text_bottom - icon_size.y - font_baseline),
icon_size,
))
} else {
None
};
text.text
.map(|t| display::text_into_buffer(t, text.style.text_font.into(), buffer, 0));
HomescreenTextInfo {
text_area,
text_width,
text_color: text.style.text_color,
icon_area,
}
}
#[inline(always)]
fn homescreen_dim_area(x: i16, y: i16) -> bool {
y >= HOMESCREEN_DIM_START
&& (y > HOMESCREEN_DIM_START + 1
&& y < (HOMESCREEN_DIM_START + HOMESCREEN_DIM_HEIGHT - 1)
&& x > HOMESCREEN_DIM_BORDER
&& x < WIDTH - HOMESCREEN_DIM_BORDER)
|| (y > HOMESCREEN_DIM_START
&& y < (HOMESCREEN_DIM_START + HOMESCREEN_DIM_HEIGHT)
&& x > HOMESCREEN_DIM_BORDER + 1
&& x < WIDTH - (HOMESCREEN_DIM_BORDER + 1))
|| ((HOMESCREEN_DIM_START..=(HOMESCREEN_DIM_START + HOMESCREEN_DIM_HEIGHT)).contains(&y)
&& x > HOMESCREEN_DIM_BORDER + 2
&& x < WIDTH - (HOMESCREEN_DIM_BORDER + 2))
}
fn homescreen_line_blurred(
icon_data: &[u8],
text_buffer: &mut BufferText,
fg_buffer: &mut BufferLine4bpp,
img_buffer: &mut BufferLine16bpp,
text_info: HomescreenTextInfo,
blurring: &BlurringContext,
y: i16,
) -> bool {
fg_buffer.buffer.fill(0);
for x in 0..HOMESCREEN_IMAGE_WIDTH {
let c = if LOCKSCREEN_DIM_ALL {
let x = x as usize;
let coef = (65536_f32 * LOCKSCREEN_DIM) as u32;
let r = (blurring.totals.buffer[RED_IDX][x] as u32 * BLUR_DIV) >> 16;
let g = (blurring.totals.buffer[GREEN_IDX][x] as u32 * BLUR_DIV) >> 16;
let b = (blurring.totals.buffer[BLUE_IDX][x] as u32 * BLUR_DIV) >> 16;
let r = (((coef * r) >> 8) & 0xF800) as u16;
let g = (((coef * g) >> 13) & 0x07E0) as u16;
let b = (((coef * b) >> 19) & 0x001F) as u16;
r | g | b
} else {
let x = x as usize;
let r = (((blurring.totals.buffer[RED_IDX][x] as u32 * BLUR_DIV) >> 8) & 0xF800) as u16;
let g =
(((blurring.totals.buffer[GREEN_IDX][x] as u32 * BLUR_DIV) >> 13) & 0x07E0) as u16;
let b =
(((blurring.totals.buffer[BLUE_IDX][x] as u32 * BLUR_DIV) >> 19) & 0x001F) as u16;
r | g | b
};
let j = (2 * x) as usize;
img_buffer.buffer[j + 1] = (c >> 8) as u8;
img_buffer.buffer[j] = (c & 0xFF) as u8;
}
let done = homescreen_get_fg_text(y, text_info, text_buffer, fg_buffer);
homescreen_get_fg_icon(y, text_info, icon_data, fg_buffer);
dma2d_wait_for_transfer();
dma2d_setup_4bpp_over_16bpp(text_info.text_color.into());
unsafe {
dma2d_start_blend(&fg_buffer.buffer, &img_buffer.buffer, WIDTH);
}
done
}
#[allow(clippy::too_many_arguments)]
fn homescreen_line(
icon_data: &[u8],
text_buffer: &mut BufferText,
text_info: HomescreenTextInfo,
data_buffer: &mut BufferJpeg,
fg_buffer: &mut BufferLine4bpp,
img_buffer: &mut BufferLine16bpp,
mcu_height: i16,
y: i16,
) -> bool {
let image_data = get_data(data_buffer, y, mcu_height);
fg_buffer.buffer.fill(0);
for x in 0..HOMESCREEN_IMAGE_WIDTH {
let d = image_data[x as usize];
let c = if homescreen_dim_area(x, y) {
let coef = (65536_f32 * HOMESCREEN_DIM) as u32;
let r = (d & 0xF800) >> 8;
let g = (d & 0x07E0) >> 3;
let b = (d & 0x001F) << 3;
let r = (((coef * r as u32) >> 8) & 0xF800) as u16;
let g = (((coef * g as u32) >> 13) & 0x07E0) as u16;
let b = (((coef * b as u32) >> 19) & 0x001F) as u16;
r | g | b
} else {
d
};
let j = 2 * x as usize;
img_buffer.buffer[j + 1] = (c >> 8) as u8;
img_buffer.buffer[j] = (c & 0xFF) as u8;
}
let done = homescreen_get_fg_text(y, text_info, text_buffer, fg_buffer);
homescreen_get_fg_icon(y, text_info, icon_data, fg_buffer);
dma2d_wait_for_transfer();
dma2d_setup_4bpp_over_16bpp(text_info.text_color.into());
unsafe {
dma2d_start_blend(&fg_buffer.buffer, &img_buffer.buffer, WIDTH);
}
done
}
fn homescreen_next_text(
texts: &[HomescreenText],
text_buffer: &mut BufferText,
icon_data: &mut [u8],
text_info: HomescreenTextInfo,
text_idx: usize,
) -> (HomescreenTextInfo, usize) {
let mut next_text_idx = text_idx;
let mut next_text_info = text_info;
if next_text_idx < texts.len() {
if let Some(txt) = texts.get(next_text_idx) {
text_buffer.buffer.fill(0);
next_text_info = homescreen_position_text(txt, text_buffer, icon_data);
next_text_idx += 1;
}
}
(next_text_info, next_text_idx)
}
#[inline(always)]
fn update_accs_add(data: &[u16], idx: usize, acc_r: &mut u16, acc_g: &mut u16, acc_b: &mut u16) {
let d = data[idx];
let r = (d & 0xF800) >> 8;
let g = (d & 0x07E0) >> 3;
let b = (d & 0x001F) << 3;
*acc_r += r;
*acc_g += g;
*acc_b += b;
}
#[inline(always)]
fn update_accs_sub(data: &[u16], idx: usize, acc_r: &mut u16, acc_g: &mut u16, acc_b: &mut u16) {
let d = data[idx];
let r = (d & 0xF800) >> 8;
let g = (d & 0x07E0) >> 3;
let b = (d & 0x001F) << 3;
*acc_r -= r;
*acc_g -= g;
*acc_b -= b;
}
struct BlurringContext {
mem: BufferBlurring,
pub totals: BufferBlurringTotals,
line_num: i16,
add_idx: usize,
rem_idx: usize,
}
impl BlurringContext {
pub fn new() -> Self {
Self {
mem: BufferBlurring::get_cleared(),
totals: BufferBlurringTotals::get_cleared(),
line_num: 0,
add_idx: 0,
rem_idx: 0,
}
}
fn clear(&mut self) {
let lines = &mut self.mem.buffer[0..DECOMP_LINES];
for (i, total) in self.totals.buffer.iter_mut().enumerate() {
for line in lines.iter_mut() {
line[i].fill(0);
}
total.fill(0);
}
}
// computes color averages for one line of image data
fn compute_line_avgs(&mut self, buffer: &mut BufferJpeg, mcu_height: i16) {
let lines = &mut self.mem.buffer[0..DECOMP_LINES];
let mut acc_r = 0;
let mut acc_g = 0;
let mut acc_b = 0;
let data = get_data(buffer, self.line_num, mcu_height);
for i in -BLUR_RADIUS..=BLUR_RADIUS {
let ic = i.clamp(0, HOMESCREEN_IMAGE_WIDTH - 1) as usize;
update_accs_add(data, ic, &mut acc_r, &mut acc_g, &mut acc_b);
}
for i in 0..HOMESCREEN_IMAGE_WIDTH {
lines[self.add_idx][RED_IDX][i as usize] = acc_r;
lines[self.add_idx][GREEN_IDX][i as usize] = acc_g;
lines[self.add_idx][BLUE_IDX][i as usize] = acc_b;
// clamping handles left and right edges
let ic = (i - BLUR_RADIUS).clamp(0, HOMESCREEN_IMAGE_WIDTH - 1) as usize;
let ic2 =
(i + BLUR_SIZE as i16 - BLUR_RADIUS).clamp(0, HOMESCREEN_IMAGE_WIDTH - 1) as usize;
update_accs_add(data, ic2, &mut acc_r, &mut acc_g, &mut acc_b);
update_accs_sub(data, ic, &mut acc_r, &mut acc_g, &mut acc_b);
}
self.line_num += 1;
}
// adds one line of averages to sliding total averages
fn vertical_avg_add(&mut self) {
let lines = &mut self.mem.buffer[0..DECOMP_LINES];
for i in 0..HOMESCREEN_IMAGE_WIDTH as usize {
self.totals.buffer[RED_IDX][i] += lines[self.add_idx][RED_IDX][i];
self.totals.buffer[GREEN_IDX][i] += lines[self.add_idx][GREEN_IDX][i];
self.totals.buffer[BLUE_IDX][i] += lines[self.add_idx][BLUE_IDX][i];
}
}
// adds one line and removes one line of averages to/from sliding total averages
fn vertical_avg(&mut self) {
let lines = &mut self.mem.buffer[0..DECOMP_LINES];
for i in 0..HOMESCREEN_IMAGE_WIDTH as usize {
self.totals.buffer[RED_IDX][i] += lines[self.add_idx][RED_IDX][i];
self.totals.buffer[GREEN_IDX][i] += lines[self.add_idx][GREEN_IDX][i];
self.totals.buffer[BLUE_IDX][i] += lines[self.add_idx][BLUE_IDX][i];
self.totals.buffer[RED_IDX][i] -= lines[self.rem_idx][RED_IDX][i];
self.totals.buffer[GREEN_IDX][i] -= lines[self.rem_idx][GREEN_IDX][i];
self.totals.buffer[BLUE_IDX][i] -= lines[self.rem_idx][BLUE_IDX][i];
}
}
fn inc_add(&mut self) {
self.add_idx += 1;
if self.add_idx >= DECOMP_LINES {
self.add_idx = 0;
}
}
fn inc_rem(&mut self) {
self.rem_idx += 1;
if self.rem_idx >= DECOMP_LINES {
self.rem_idx = 0;
}
}
fn get_line_num(&self) -> i16 {
self.line_num
}
}
#[inline(always)]
fn get_data(buffer: &mut BufferJpeg, line_num: i16, mcu_height: i16) -> &mut [u16] {
let data_start = ((line_num % mcu_height) * WIDTH) as usize;
let data_end = (((line_num % mcu_height) + 1) * WIDTH) as usize;
&mut buffer.buffer[data_start..data_end]
}
pub fn homescreen_blurred(data: &mut dyn HomescreenDecompressor, texts: &[HomescreenText]) {
let mut icon_data = [0_u8; (HOMESCREEN_MAX_ICON_SIZE * HOMESCREEN_MAX_ICON_SIZE / 2) as usize];
let mut text_buffer = BufferText::get_cleared();
let mut fg_buffer_0 = BufferLine4bpp::get_cleared();
let mut img_buffer_0 = BufferLine16bpp::get_cleared();
let mut fg_buffer_1 = BufferLine4bpp::get_cleared();
let mut img_buffer_1 = BufferLine16bpp::get_cleared();
let mut next_text_idx = 1;
let mut text_info =
homescreen_position_text(unwrap!(texts.first()), &mut text_buffer, &mut icon_data);
let mcu_height = data.get_height();
data.decompress();
set_window(screen());
let mut blurring = BlurringContext::new();
// handling top edge case: preload the edge value N+1 times
blurring.compute_line_avgs(data.get_data(), mcu_height);
for _ in 0..=BLUR_RADIUS {
blurring.vertical_avg_add();
}
blurring.inc_add();
// load enough values to be able to compute first line averages
for _ in 0..BLUR_RADIUS {
blurring.compute_line_avgs(data.get_data(), mcu_height);
blurring.vertical_avg_add();
blurring.inc_add();
if (blurring.get_line_num() % mcu_height) == 0 {
data.decompress();
}
}
for y in 0..HEIGHT {
// several lines have been already decompressed before this loop, adjust for
// that
if y < HOMESCREEN_IMAGE_HEIGHT - (BLUR_RADIUS + 1) {
blurring.compute_line_avgs(data.get_data(), mcu_height);
}
let done = if y % 2 == 0 {
homescreen_line_blurred(
&icon_data,
&mut text_buffer,
&mut fg_buffer_0,
&mut img_buffer_0,
text_info,
&blurring,
y,
)
} else {
homescreen_line_blurred(
&icon_data,
&mut text_buffer,
&mut fg_buffer_1,
&mut img_buffer_1,
text_info,
&blurring,
y,
)
};
if done {
(text_info, next_text_idx) = homescreen_next_text(
texts,
&mut text_buffer,
&mut icon_data,
text_info,
next_text_idx,
);
}
blurring.vertical_avg();
// handling bottom edge case: stop incrementing counter, adding the edge value
// for the rest of image
// the extra -1 is to indicate that this was the last decompressed line,
// in the next pass the docompression and compute_line_avgs won't happen
if y < HOMESCREEN_IMAGE_HEIGHT - (BLUR_RADIUS + 1) - 1 {
blurring.inc_add();
}
if y == HOMESCREEN_IMAGE_HEIGHT {
// reached end of image, clear avgs (display black)
blurring.clear();
}
// only start incrementing remove index when enough lines have been loaded
if y >= (BLUR_RADIUS) {
blurring.inc_rem();
}
if (blurring.get_line_num() % mcu_height) == 0 && (blurring.get_line_num() < HEIGHT) {
data.decompress();
}
}
dma2d_wait_for_transfer();
}
pub fn homescreen(
data: &mut dyn HomescreenDecompressor,
texts: &[HomescreenText],
notification: Option<HomescreenNotification>,
notification_only: bool,
) {
let mut icon_data = [0_u8; (HOMESCREEN_MAX_ICON_SIZE * HOMESCREEN_MAX_ICON_SIZE / 2) as usize];
let mut text_buffer = BufferText::get_cleared();
let mut fg_buffer_0 = BufferLine4bpp::get_cleared();
let mut img_buffer_0 = BufferLine16bpp::get_cleared();
let mut fg_buffer_1 = BufferLine4bpp::get_cleared();
let mut img_buffer_1 = BufferLine16bpp::get_cleared();
let mut next_text_idx = 0;
let mut text_info = if let Some(notification) = notification {
rect_fill_rounded_buffer(
Rect::from_top_left_and_size(
Point::new(NOTIFICATION_BORDER, 0),
Offset::new(WIDTH - NOTIFICATION_BORDER * 2, NOTIFICATION_HEIGHT),
),
2,
&mut text_buffer,
);
let area = Rect::new(
Point::new(0, NOTIFICATION_BORDER),
Point::new(WIDTH, NOTIFICATION_HEIGHT + NOTIFICATION_BORDER),
);
HomescreenTextInfo {
text_area: area,
text_width: WIDTH,
text_color: notification.color,
icon_area: None,
}
} else {
next_text_idx += 1;
homescreen_position_text(unwrap!(texts.first()), &mut text_buffer, &mut icon_data)
};
set_window(screen());
let mcu_height = data.get_height();
for y in 0..HEIGHT {
if (y % mcu_height) == 0 {
data.decompress();
}
let done = if y % 2 == 0 {
homescreen_line(
&icon_data,
&mut text_buffer,
text_info,
data.get_data(),
&mut fg_buffer_0,
&mut img_buffer_0,
mcu_height,
y,
)
} else {
homescreen_line(
&icon_data,
&mut text_buffer,
text_info,
data.get_data(),
&mut fg_buffer_1,
&mut img_buffer_1,
mcu_height,
y,
)
};
if done {
if notification.is_some() && next_text_idx == 0 {
//finished notification area, let interrupt and draw the text
let notification = unwrap!(notification);
let style = TextStyle {
background_color: notification.color,
..theme::TEXT_BOLD
};
dma2d_wait_for_transfer();
drop(fg_buffer_0);
drop(fg_buffer_1);
icon_text_center(
text_info.text_area.center(),
notification.icon,
8,
notification.text,
style,
Offset::new(1, -2),
);
fg_buffer_0 = BufferLine4bpp::get_cleared();
fg_buffer_1 = BufferLine4bpp::get_cleared();
set_window(
screen()
.split_top(NOTIFICATION_HEIGHT + NOTIFICATION_BORDER)
.1,
);
}
if notification_only && next_text_idx == 0 {
dma2d_wait_for_transfer();
return;
}
(text_info, next_text_idx) = homescreen_next_text(
texts,
&mut text_buffer,
&mut icon_data,
text_info,
next_text_idx,
);
}
}
dma2d_wait_for_transfer();
}

@ -0,0 +1,326 @@
use crate::{
trezorhal::bip39,
ui::{
component::{text::common::TextBox, Component, Event, EventCtx},
display,
geometry::{Alignment2D, Offset, Rect},
model_mercury::{
component::{
keyboard::{
common::{paint_pending_marker, render_pending_marker, MultiTapKeyboard},
mnemonic::{MnemonicInput, MnemonicInputMsg, MNEMONIC_KEY_COUNT},
},
Button, ButtonContent, ButtonMsg,
},
theme,
},
shape,
shape::Renderer,
},
};
use heapless::String;
const MAX_LENGTH: usize = 8;
pub struct Bip39Input {
button: Button<>,
// used only to keep track of suggestion text color
button_suggestion: Button<>,
textbox: TextBox<MAX_LENGTH>,
multi_tap: MultiTapKeyboard,
options_num: Option<usize>,
suggested_word: Option<&'static str>,
}
impl MnemonicInput for Bip39Input {
/// Return the key set. Keys are further specified as indices into this
/// array.
fn keys() -> [&'static str; MNEMONIC_KEY_COUNT] {
["abc", "def", "ghi", "jkl", "mno", "pqr", "stu", "vwx", "yz"]
}
/// Returns `true` if given key index can continue towards a valid mnemonic
/// word, `false` otherwise.
fn can_key_press_lead_to_a_valid_word(&self, key: usize) -> bool {
// Currently pending key is always enabled.
let key_is_pending = self.multi_tap.pending_key() == Some(key);
// Keys that contain letters from the completion mask are enabled as well.
let key_matches_mask =
bip39::word_completion_mask(self.textbox.content()) & Self::key_mask(key) != 0;
key_is_pending || key_matches_mask
}
/// Key button was clicked. If this button is pending, let's cycle the
/// pending character in textbox. If not, let's just append the first
/// character.
fn on_key_click(&mut self, ctx: &mut EventCtx, key: usize) {
let edit = self.multi_tap.click_key(ctx, key, Self::keys()[key]);
self.textbox.apply(ctx, edit);
self.complete_word_from_dictionary(ctx);
}
/// Backspace button was clicked, let's delete the last character of input
/// and clear the pending marker.
fn on_backspace_click(&mut self, ctx: &mut EventCtx) {
self.multi_tap.clear_pending_state(ctx);
self.textbox.delete_last(ctx);
self.complete_word_from_dictionary(ctx);
}
/// Backspace button was long pressed, let's delete all characters of input
/// and clear the pending marker.
fn on_backspace_long_press(&mut self, ctx: &mut EventCtx) {
self.multi_tap.clear_pending_state(ctx);
self.textbox.clear(ctx);
self.complete_word_from_dictionary(ctx);
}
fn is_empty(&self) -> bool {
self.textbox.is_empty()
}
fn mnemonic(&self) -> Option<&'static str> {
self.suggested_word
}
}
impl Component for Bip39Input {
type Msg = MnemonicInputMsg;
fn place(&mut self, bounds: Rect) -> Rect {
self.button.place(bounds);
self.button_suggestion.place(bounds)
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
self.button_suggestion.event(ctx, event);
if self.multi_tap.is_timeout_event(event) {
self.on_timeout(ctx)
} else if let Some(ButtonMsg::Clicked) = self.button.event(ctx, event) {
self.on_input_click(ctx)
} else {
None
}
}
fn paint(&mut self) {
let area = self.button.area();
let style = self.button.style();
// First, paint the button background.
self.button.paint_background(style);
// Paint the entered content (the prefix of the suggested word).
let text = self.textbox.content();
let width = style.font.text_width(text);
// Content starts in the left-center point, offset by 16px to the right and 8px
// to the bottom.
let text_baseline = area.top_left().center(area.bottom_left()) + Offset::new(16, 8);
display::text_left(
text_baseline,
text,
style.font,
style.text_color,
style.button_color,
);
// Paint the rest of the suggested dictionary word.
if let Some(word) = self.suggested_word.and_then(|w| w.get(text.len()..)) {
let word_baseline = text_baseline + Offset::new(width, 0);
let style = self.button_suggestion.style();
display::text_left(
word_baseline,
word,
style.font,
style.text_color,
style.button_color,
);
}
// Paint the pending marker.
if self.multi_tap.pending_key().is_some() {
paint_pending_marker(text_baseline, text, style.font, style.text_color);
}
// Paint the icon.
if let ButtonContent::Icon(icon) = self.button.content() {
// Icon is painted in the right-center point, of expected size 16x16 pixels, and
// 16px from the right edge.
let icon_center = area.top_right().center(area.bottom_right()) - Offset::new(16 + 8, 0);
icon.draw(
icon_center,
Alignment2D::CENTER,
style.text_color,
style.button_color,
);
}
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
let area = self.button.area();
let style = self.button.style();
// First, paint the button background.
self.button.render_background(target, style);
// Paint the entered content (the prefix of the suggested word).
let text = self.textbox.content();
let width = style.font.text_width(text);
// Content starts in the left-center point, offset by 16px to the right and 8px
// to the bottom.
let text_baseline = area.top_left().center(area.bottom_left()) + Offset::new(16, 8);
shape::Text::new(text_baseline, text)
.with_font(style.font)
.with_fg(style.text_color)
.render(target);
// Paint the rest of the suggested dictionary word.
if let Some(word) = self.suggested_word.and_then(|w| w.get(text.len()..)) {
let word_baseline = text_baseline + Offset::new(width, 0);
let style = self.button_suggestion.style();
shape::Text::new(word_baseline, word)
.with_font(style.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, style.font, style.text_color);
}
// Paint the icon.
if let ButtonContent::Icon(icon) = self.button.content() {
// Icon is painted in the right-center point, of expected size 16x16 pixels, and
// 16px from the right edge.
let icon_center = area.top_right().center(area.bottom_right()) - Offset::new(16 + 8, 0);
shape::ToifImage::new(icon_center, icon.toif)
.with_align(Alignment2D::CENTER)
.with_fg(style.text_color)
.render(target);
}
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
self.button.bounds(sink);
}
}
impl Bip39Input {
pub fn new() -> Self {
Self {
button: Button::empty(),
textbox: TextBox::empty(),
multi_tap: MultiTapKeyboard::new(),
options_num: None,
suggested_word: None,
button_suggestion: Button::empty(),
}
}
pub fn prefilled_word(word: &str) -> Self {
// Word may be empty string, fallback to normal input
if word.is_empty() {
return Self::new();
}
// Styling the input to reflect already filled word
Self {
button: Button::with_icon(theme::ICON_LIST_CHECK).styled(theme::button_pin_confirm()),
textbox: TextBox::new(unwrap!(String::try_from(word))),
multi_tap: MultiTapKeyboard::new(),
options_num: bip39::options_num(word),
suggested_word: bip39::complete_word(word),
button_suggestion: Button::empty().styled(theme::button_suggestion_confirm()),
}
}
/// Compute a bitmask of all letters contained in given key text. Lowest bit
/// is 'a', second lowest 'b', etc.
fn key_mask(key: usize) -> u32 {
let mut mask = 0;
for ch in Self::keys()[key].as_bytes() {
// We assume the key text is lower-case alphabetic ASCII, making the subtraction
// and the shift panic-free.
mask |= 1 << (ch - b'a');
}
mask
}
/// Input button was clicked. If the content matches the suggested word,
/// let's confirm it, otherwise just auto-complete.
fn on_input_click(&mut self, ctx: &mut EventCtx) -> Option<MnemonicInputMsg> {
if let (Some(word), Some(num)) = (self.suggested_word, self.options_num) {
return if num == 1 && word.starts_with(self.textbox.content())
|| num > 1 && word.eq(self.textbox.content())
{
// Confirm button.
self.textbox.clear(ctx);
Some(MnemonicInputMsg::Confirmed)
} else {
// Auto-complete button.
self.textbox.replace(ctx, word);
self.complete_word_from_dictionary(ctx);
Some(MnemonicInputMsg::Completed)
};
}
None
}
/// Timeout occurred. If we can auto-complete current input, let's just
/// reset the pending marker. If not, input is invalid, let's backspace the
/// last character.
fn on_timeout(&mut self, ctx: &mut EventCtx) -> Option<MnemonicInputMsg> {
self.multi_tap.clear_pending_state(ctx);
if self.suggested_word.is_none() {
self.textbox.delete_last(ctx);
self.complete_word_from_dictionary(ctx);
}
Some(MnemonicInputMsg::TimedOut)
}
fn complete_word_from_dictionary(&mut self, ctx: &mut EventCtx) {
self.options_num = bip39::options_num(self.textbox.content());
self.suggested_word = bip39::complete_word(self.textbox.content());
// Change the style of the button depending on the completed word.
if let (Some(word), Some(num)) = (self.suggested_word, self.options_num) {
if num == 1 && word.starts_with(self.textbox.content())
|| num > 1 && word.eq(self.textbox.content())
{
// Confirm button.
self.button.enable(ctx);
self.button.set_stylesheet(ctx, theme::button_pin_confirm());
self.button
.set_content(ctx, ButtonContent::Icon(theme::ICON_LIST_CHECK));
self.button_suggestion
.set_stylesheet(ctx, theme::button_suggestion_confirm());
} else {
// Auto-complete button.
self.button.enable(ctx);
self.button
.set_stylesheet(ctx, theme::button_pin_autocomplete());
self.button
.set_content(ctx, ButtonContent::Icon(theme::ICON_CLICK));
self.button_suggestion
.set_stylesheet(ctx, theme::button_suggestion_autocomplete());
}
} else {
// Disabled button.
self.button.disable(ctx);
self.button.set_stylesheet(ctx, theme::button_pin());
self.button.set_content(ctx, ButtonContent::Text("".into()));
}
}
}
// DEBUG-ONLY SECTION BELOW
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for Bip39Input {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("Bip39Input");
t.child("textbox", &self.textbox);
}
}

@ -0,0 +1,152 @@
use crate::{
time::Duration,
ui::{
component::{text::common::TextEdit, Event, EventCtx, TimerToken},
display::{self, Color, Font},
geometry::{Offset, Point, Rect},
shape,
shape::Renderer,
},
};
/// Contains state commonly used in implementations multi-tap keyboards.
pub struct MultiTapKeyboard {
/// Configured timeout after which we cancel currently pending key.
timeout: Duration,
/// The currently pending state.
pending: Option<Pending>,
}
struct Pending {
/// Index of the pending key.
key: usize,
/// Index of the key press (how many times the `key` was pressed, minus
/// one).
press: usize,
/// Timer for clearing the pending state.
timer: TimerToken,
}
impl MultiTapKeyboard {
/// Create a new, empty, multi-tap state.
pub fn new() -> Self {
Self {
timeout: Duration::from_secs(1),
pending: None,
}
}
/// Return the index of the currently pending key, if any.
pub fn pending_key(&self) -> Option<usize> {
self.pending.as_ref().map(|p| p.key)
}
/// Return the index of the pending key press.
pub fn pending_press(&self) -> Option<usize> {
self.pending.as_ref().map(|p| p.press)
}
/// Return the token for the currently pending timer.
pub fn pending_timer(&self) -> Option<TimerToken> {
self.pending.as_ref().map(|p| p.timer)
}
/// Returns `true` if `event` is an `Event::Timer` for the currently pending
/// timer.
pub fn is_timeout_event(&self, event: Event) -> bool {
matches!((event, self.pending_timer()), (Event::Timer(t), Some(pt)) if pt == t)
}
/// Reset to the empty state. Takes `EventCtx` to request a paint pass (to
/// either hide or show any pending marker our caller might want to draw
/// later).
pub fn clear_pending_state(&mut self, ctx: &mut EventCtx) {
if self.pending.is_some() {
self.pending = None;
ctx.request_paint();
}
}
/// Register a click to a key. `MultiTapKeyboard` itself does not have any
/// concept of the key set, so both the key index and the key content is
/// taken here. Returns a text editing operation the caller should apply on
/// the output buffer. Takes `EventCtx` to request a timeout for cancelling
/// the pending state. Caller is required to handle the timer event and
/// call `Self::clear_pending_state` when the timer hits.
pub fn click_key(&mut self, ctx: &mut EventCtx, key: usize, key_text: &str) -> TextEdit {
let (is_pending, press) = match &self.pending {
Some(pending) if pending.key == key => {
// This key is pending. Cycle the last inserted character through the
// key content.
(true, pending.press.wrapping_add(1))
}
_ => {
// This key is not pending. Append the first character in the key.
(false, 0)
}
};
// If the key has more then one character, we need to set it as pending, so we
// can cycle through on the repeated clicks. We also request a timer so we can
// reset the pending state after a deadline.
//
// Note: It might seem that we should make sure to `request_paint` in case we
// progress into a pending state (to display the pending marker), but such
// transition only happens as a result of an append op, so the painting should
// be requested by handling the `TextEdit`.
self.pending = if key_text.len() > 1 {
Some(Pending {
key,
press,
timer: ctx.request_timer(self.timeout),
})
} else {
None
};
assert!(!key_text.is_empty());
// Now we can be sure that a looped iterator will return a value
let ch = unwrap!(key_text.chars().cycle().nth(press));
if is_pending {
TextEdit::ReplaceLast(ch)
} else {
TextEdit::Append(ch)
}
}
}
/// Create a visible "underscoring" of the last letter of a text.
pub fn paint_pending_marker(text_baseline: Point, text: &str, font: Font, color: Color) {
// Measure the width of the last character of input.
if let Some(last) = text.chars().last() {
let width = font.text_width(text);
let last_width = font.char_width(last);
// Draw the marker 2px under the start of the baseline of the last character.
let marker_origin = text_baseline + Offset::new(width - last_width, 2);
// Draw the marker 1px longer than the last character, and 3px thick.
let marker_rect =
Rect::from_top_left_and_size(marker_origin, Offset::new(last_width + 1, 3));
display::rect_fill(marker_rect, color);
}
}
/// Create a visible "underscoring" of the last letter of a text.
pub fn render_pending_marker<'s>(
target: &mut impl Renderer<'s>,
text_baseline: Point,
text: &str,
font: Font,
color: Color,
) {
// Measure the width of the last character of input.
if let Some(last) = text.chars().last() {
let width = font.text_width(text);
let last_width = font.char_width(last);
// Draw the marker 2px under the start of the baseline of the last character.
let marker_origin = text_baseline + Offset::new(width - last_width, 2);
// Draw the marker 1px longer than the last character, and 3px thick.
let marker_rect =
Rect::from_top_left_and_size(marker_origin, Offset::new(last_width + 1, 3));
shape::Bar::new(marker_rect).with_bg(color).render(target);
}
}

@ -0,0 +1,234 @@
use crate::{
strutil::TString,
ui::{
component::{maybe::paint_overlapping, Child, Component, Event, EventCtx, Label, Maybe},
geometry::{Alignment2D, Grid, Offset, Rect},
model_mercury::{
component::{Button, ButtonMsg, Swipe, SwipeDirection},
theme,
},
shape::Renderer,
},
};
pub const MNEMONIC_KEY_COUNT: usize = 9;
pub enum MnemonicKeyboardMsg {
Confirmed,
Previous,
}
pub struct MnemonicKeyboard<T> {
/// Initial prompt, displayed on empty input.
prompt: Child<Maybe<Label<'static>>>,
/// Backspace button.
back: Child<Maybe<Button>>,
/// Input area, acting as the auto-complete and confirm button.
input: Child<Maybe<T>>,
/// Key buttons.
keys: [Child<Button>; MNEMONIC_KEY_COUNT],
/// Swipe controller - allowing for going to the previous word.
swipe: Swipe,
/// Whether going back is allowed (is not on the very first word).
can_go_back: bool,
}
impl<T> MnemonicKeyboard<T>
where
T: MnemonicInput,
{
pub fn new(input: T, prompt: TString<'static>, can_go_back: bool) -> Self {
// Input might be already pre-filled
let prompt_visible = input.is_empty();
Self {
prompt: Child::new(Maybe::new(
theme::BG,
Label::centered(prompt, theme::label_keyboard_prompt()),
prompt_visible,
)),
back: Child::new(Maybe::new(
theme::BG,
Button::with_icon_blend(
theme::IMAGE_BG_BACK_BTN_TALL,
theme::ICON_BACK,
Offset::new(30, 17),
)
.styled(theme::button_reset())
.with_long_press(theme::ERASE_HOLD_DURATION),
!prompt_visible,
)),
input: Child::new(Maybe::new(theme::BG, input, !prompt_visible)),
keys: T::keys()
.map(|t| Button::with_text(t.into()).styled(theme::button_pin()))
.map(Child::new),
swipe: Swipe::new().right(),
can_go_back,
}
}
fn on_input_change(&mut self, ctx: &mut EventCtx) {
self.toggle_key_buttons(ctx);
self.toggle_prompt_or_input(ctx);
}
/// Either enable or disable the key buttons, depending on the dictionary
/// completion mask and the pending key.
fn toggle_key_buttons(&mut self, ctx: &mut EventCtx) {
for (key, btn) in self.keys.iter_mut().enumerate() {
let enabled = self
.input
.inner()
.inner()
.can_key_press_lead_to_a_valid_word(key);
btn.mutate(ctx, |ctx, b| b.enable_if(ctx, enabled));
}
}
/// After edit operations, we need to either show or hide the prompt, the
/// input, and the back button.
fn toggle_prompt_or_input(&mut self, ctx: &mut EventCtx) {
let prompt_visible = self.input.inner().inner().is_empty();
self.prompt
.mutate(ctx, |ctx, p| p.show_if(ctx, prompt_visible));
self.input
.mutate(ctx, |ctx, i| i.show_if(ctx, !prompt_visible));
self.back
.mutate(ctx, |ctx, b| b.show_if(ctx, !prompt_visible));
}
pub fn mnemonic(&self) -> Option<&'static str> {
self.input.inner().inner().mnemonic()
}
}
impl<T> Component for MnemonicKeyboard<T>
where
T: MnemonicInput,
{
type Msg = MnemonicKeyboardMsg;
fn place(&mut self, bounds: Rect) -> Rect {
let (_, bounds) = bounds
.inset(theme::borders())
.split_bottom(4 * theme::MNEMONIC_BUTTON_HEIGHT + 3 * theme::KEYBOARD_SPACING);
let grid = Grid::new(bounds, 4, 3).with_spacing(theme::KEYBOARD_SPACING);
let back_area = grid.row_col(0, 0);
let input_area = grid.row_col(0, 1).union(grid.row_col(0, 3));
let prompt_center = grid.row_col(0, 0).union(grid.row_col(0, 3)).center();
let prompt_size = self.prompt.inner().inner().max_size();
let prompt_area = Rect::snap(prompt_center, prompt_size, Alignment2D::CENTER);
self.swipe.place(bounds);
self.prompt.place(prompt_area);
self.back.place(back_area);
self.input.place(input_area);
for (key, btn) in self.keys.iter_mut().enumerate() {
btn.place(grid.cell(key + grid.cols)); // Start in the second row.
}
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
// Swipe will cause going back to the previous word when allowed.
if self.can_go_back {
if let Some(SwipeDirection::Right) = self.swipe.event(ctx, event) {
return Some(MnemonicKeyboardMsg::Previous);
}
}
match self.input.event(ctx, event) {
Some(MnemonicInputMsg::Confirmed) => {
// Confirmed, bubble up.
return Some(MnemonicKeyboardMsg::Confirmed);
}
Some(_) => {
// Either a timeout or a completion.
self.on_input_change(ctx);
return None;
}
_ => {}
}
match self.back.event(ctx, event) {
Some(ButtonMsg::Clicked) => {
self.input
.mutate(ctx, |ctx, i| i.inner_mut().on_backspace_click(ctx));
self.on_input_change(ctx);
return None;
}
Some(ButtonMsg::LongPressed) => {
self.input
.mutate(ctx, |ctx, i| i.inner_mut().on_backspace_long_press(ctx));
self.on_input_change(ctx);
return None;
}
_ => {}
}
for (key, btn) in self.keys.iter_mut().enumerate() {
if let Some(ButtonMsg::Clicked) = btn.event(ctx, event) {
self.input
.mutate(ctx, |ctx, i| i.inner_mut().on_key_click(ctx, key));
self.on_input_change(ctx);
return None;
}
}
None
}
fn paint(&mut self) {
paint_overlapping(&mut [&mut self.prompt, &mut self.input, &mut self.back]);
for btn in &mut self.keys {
btn.paint();
}
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
self.prompt.render(target);
self.input.render(target);
self.back.render(target);
for btn in &self.keys {
btn.render(target);
}
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
self.prompt.bounds(sink);
self.input.bounds(sink);
self.back.bounds(sink);
for btn in &self.keys {
btn.bounds(sink)
}
}
}
pub trait MnemonicInput: Component<Msg = MnemonicInputMsg> {
fn keys() -> [&'static str; MNEMONIC_KEY_COUNT];
fn can_key_press_lead_to_a_valid_word(&self, key: usize) -> bool;
fn on_key_click(&mut self, ctx: &mut EventCtx, key: usize);
fn on_backspace_click(&mut self, ctx: &mut EventCtx);
fn on_backspace_long_press(&mut self, ctx: &mut EventCtx);
fn is_empty(&self) -> bool;
fn mnemonic(&self) -> Option<&'static str>;
}
pub enum MnemonicInputMsg {
Confirmed,
Completed,
TimedOut,
}
#[cfg(feature = "ui_debug")]
impl<T> crate::trace::Trace for MnemonicKeyboard<T>
where
T: MnemonicInput + crate::trace::Trace,
{
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("MnemonicKeyboard");
t.child("prompt", &self.prompt);
t.child("input", &self.input);
}
}

@ -0,0 +1,8 @@
pub mod bip39;
pub mod mnemonic;
pub mod passphrase;
pub mod pin;
pub mod slip39;
pub mod word_count;
mod common;

@ -0,0 +1,444 @@
use crate::{
strutil::TString,
ui::{
component::{
base::ComponentExt, text::common::TextBox, Child, Component, Event, EventCtx, Never,
},
display,
geometry::{Grid, Offset, Rect},
model_mercury::component::{
button::{Button, ButtonContent, ButtonMsg},
keyboard::common::{paint_pending_marker, render_pending_marker, MultiTapKeyboard},
swipe::{Swipe, SwipeDirection},
theme, ScrollBar,
},
shape,
shape::Renderer,
util::long_line_content_with_ellipsis,
},
};
use core::cell::Cell;
pub enum PassphraseKeyboardMsg {
Confirmed,
Cancelled,
}
pub struct PassphraseKeyboard {
page_swipe: Swipe,
input: Child<Input>,
back: Child<Button>,
confirm: Child<Button>,
keys: [Child<Button>; KEY_COUNT],
scrollbar: ScrollBar,
fade: Cell<bool>,
}
const STARTING_PAGE: usize = 1;
const PAGE_COUNT: usize = 4;
const KEY_COUNT: usize = 10;
#[rustfmt::skip]
const KEYBOARD: [[&str; KEY_COUNT]; PAGE_COUNT] = [
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
[" ", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz", "*#"],
[" ", "ABC", "DEF", "GHI", "JKL", "MNO", "PQRS", "TUV", "WXYZ", "*#"],
["_<>", ".:@", "/|\\", "!()", "+%&", "-[]", "?{}", ",'`", ";\"~", "$^="],
];
const MAX_LENGTH: usize = 50;
const INPUT_AREA_HEIGHT: i16 = ScrollBar::DOT_SIZE + 9;
impl PassphraseKeyboard {
pub fn new() -> Self {
Self {
page_swipe: Swipe::horizontal(),
input: Input::new().into_child(),
confirm: Button::with_icon(theme::ICON_CONFIRM)
.styled(theme::button_confirm())
.into_child(),
back: Button::with_icon_blend(
theme::IMAGE_BG_BACK_BTN,
theme::ICON_BACK,
Offset::new(30, 12),
)
.styled(theme::button_reset())
.initially_enabled(false)
.with_long_press(theme::ERASE_HOLD_DURATION)
.into_child(),
keys: KEYBOARD[STARTING_PAGE].map(|text| {
Child::new(Button::new(Self::key_content(text)).styled(theme::button_pin()))
}),
scrollbar: ScrollBar::horizontal(),
fade: Cell::new(false),
}
}
fn key_text(content: &ButtonContent) -> TString<'static> {
match content {
ButtonContent::Text(text) => *text,
ButtonContent::Icon(_) => " ".into(),
ButtonContent::IconAndText(_) => " ".into(),
ButtonContent::Empty => "".into(),
ButtonContent::IconBlend(_, _, _) => "".into(),
}
}
fn key_content(text: &'static str) -> ButtonContent {
match text {
" " => ButtonContent::Icon(theme::ICON_SPACE),
t => ButtonContent::Text(t.into()),
}
}
fn on_page_swipe(&mut self, ctx: &mut EventCtx, swipe: SwipeDirection) {
// Change the page number.
let key_page = self.scrollbar.active_page;
let key_page = match swipe {
SwipeDirection::Left => (key_page as isize + 1) as usize % PAGE_COUNT,
SwipeDirection::Right => (key_page as isize - 1) as usize % PAGE_COUNT,
_ => key_page,
};
self.scrollbar.go_to(key_page);
// Clear the pending state.
self.input
.mutate(ctx, |ctx, i| i.multi_tap.clear_pending_state(ctx));
// Update buttons.
self.replace_button_content(ctx, key_page);
// Reset backlight to normal level on next paint.
self.fade.set(true);
// So that swipe does not visually enable the input buttons when max length
// reached
self.update_input_btns_state(ctx);
}
fn replace_button_content(&mut self, ctx: &mut EventCtx, page: usize) {
for (i, btn) in self.keys.iter_mut().enumerate() {
let text = KEYBOARD[page][i];
let content = Self::key_content(text);
btn.mutate(ctx, |ctx, b| b.set_content(ctx, content));
btn.request_complete_repaint(ctx);
}
}
/// Possibly changing the buttons' state after change of the input.
fn after_edit(&mut self, ctx: &mut EventCtx) {
self.update_back_btn_state(ctx);
self.update_input_btns_state(ctx);
}
/// When the input is empty, disable the back button.
fn update_back_btn_state(&mut self, ctx: &mut EventCtx) {
if self.input.inner().textbox.is_empty() {
self.back.mutate(ctx, |ctx, b| b.disable(ctx));
} else {
self.back.mutate(ctx, |ctx, b| b.enable(ctx));
}
}
/// When the input has reached max length, disable all the input buttons.
fn update_input_btns_state(&mut self, ctx: &mut EventCtx) {
let active_states = self.get_buttons_active_states();
for (key, btn) in self.keys.iter_mut().enumerate() {
btn.mutate(ctx, |ctx, b| {
if active_states[key] {
b.enable(ctx);
} else {
b.disable(ctx);
}
});
}
}
/// Precomputing the active states not to overlap borrows in
/// `self.keys.iter_mut` loop.
fn get_buttons_active_states(&self) -> [bool; KEY_COUNT] {
let mut active_states: [bool; KEY_COUNT] = [false; KEY_COUNT];
for (key, state) in active_states.iter_mut().enumerate() {
*state = self.is_button_active(key);
}
active_states
}
/// 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.is_full();
let key_is_pending = {
if let Some(pending) = self.input.inner().multi_tap.pending_key() {
pending == key
} else {
false
}
};
textbox_not_full || key_is_pending
}
pub fn passphrase(&self) -> &str {
self.input.inner().textbox.content()
}
}
impl Component for PassphraseKeyboard {
type Msg = PassphraseKeyboardMsg;
fn place(&mut self, bounds: Rect) -> Rect {
let bounds = bounds.inset(theme::borders());
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, _) = scroll_area.split_top(ScrollBar::DOT_SIZE);
let key_grid = Grid::new(key_grid_area, 4, 3).with_spacing(theme::BUTTON_SPACING);
let confirm_btn_area = key_grid.cell(11);
let back_btn_area = key_grid.cell(9);
self.page_swipe.place(bounds);
self.input.place(input_area);
self.confirm.place(confirm_btn_area);
self.back.place(back_btn_area);
self.scrollbar.place(scroll_area);
self.scrollbar
.set_count_and_active_page(PAGE_COUNT, STARTING_PAGE);
// Place all the character buttons.
for (key, btn) in &mut self.keys.iter_mut().enumerate() {
// Assign the keys in each page to buttons on a 5x3 grid, starting
// from the second row.
let area = key_grid.cell(if key < 9 {
// The grid has 3 columns, and we skip the first row.
key
} else {
// For the last key (the "0" position) we skip one cell.
key + 1
});
btn.place(area);
}
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
if self.input.inner().multi_tap.is_timeout_event(event) {
self.input
.mutate(ctx, |ctx, i| i.multi_tap.clear_pending_state(ctx));
return None;
}
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);
return None;
}
if let Some(ButtonMsg::Clicked) = self.confirm.event(ctx, event) {
// Confirm button was clicked, we're done.
return Some(PassphraseKeyboardMsg::Confirmed);
}
match self.back.event(ctx, event) {
Some(ButtonMsg::Clicked) => {
// Backspace button was clicked. If we have any content in the textbox, let's
// delete the last character. Otherwise cancel.
return if self.input.inner().textbox.is_empty() {
Some(PassphraseKeyboardMsg::Cancelled)
} else {
self.input.mutate(ctx, |ctx, i| {
i.multi_tap.clear_pending_state(ctx);
i.textbox.delete_last(ctx);
});
self.after_edit(ctx);
None
};
}
Some(ButtonMsg::LongPressed) => {
self.input.mutate(ctx, |ctx, i| {
i.multi_tap.clear_pending_state(ctx);
i.textbox.clear(ctx);
});
self.after_edit(ctx);
return None;
}
_ => {}
}
// Process key button events in case we did not reach maximum passphrase length.
// (All input buttons should be disallowed in that case, this is just a safety
// measure.)
// Also we need to allow for cycling through the last character.
let active_states = self.get_buttons_active_states();
for (key, btn) in self.keys.iter_mut().enumerate() {
if !active_states[key] {
// Button is not active
continue;
}
if let Some(ButtonMsg::Clicked) = btn.event(ctx, event) {
// Key button was clicked. If this button is pending, let's cycle the pending
// character in textbox. If not, let's just append the first character.
let text = Self::key_text(btn.inner().content());
self.input.mutate(ctx, |ctx, i| {
let edit = text.map(|c| i.multi_tap.click_key(ctx, key, c));
i.textbox.apply(ctx, edit);
});
self.after_edit(ctx);
return None;
}
}
None
}
fn paint(&mut self) {
self.input.paint();
self.scrollbar.paint();
self.confirm.paint();
self.back.paint();
for btn in &mut self.keys {
btn.paint();
}
if self.fade.take() {
// Note that this is blocking and takes some time.
display::fade_backlight(theme::BACKLIGHT_NORMAL);
}
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
self.input.render(target);
self.scrollbar.render(target);
self.confirm.render(target);
self.back.render(target);
for btn in &self.keys {
btn.render(target);
}
if self.fade.take() {
// Note that this is blocking and takes some time.
display::fade_backlight(theme::BACKLIGHT_NORMAL);
}
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
self.input.bounds(sink);
self.scrollbar.bounds(sink);
self.confirm.bounds(sink);
self.back.bounds(sink);
for btn in &self.keys {
btn.bounds(sink)
}
}
}
struct Input {
area: Rect,
textbox: TextBox<MAX_LENGTH>,
multi_tap: MultiTapKeyboard,
}
impl Input {
fn new() -> Self {
Self {
area: Rect::zero(),
textbox: TextBox::empty(),
multi_tap: MultiTapKeyboard::new(),
}
}
}
impl Component for Input {
type Msg = Never;
fn place(&mut self, bounds: Rect) -> Rect {
self.area = bounds;
self.area
}
fn event(&mut self, _ctx: &mut EventCtx, _event: Event) -> Option<Self::Msg> {
None
}
fn paint(&mut self) {
let style = theme::label_keyboard();
let text_baseline = self.area.top_left() + Offset::y(style.text_font.text_height())
- Offset::y(style.text_font.text_baseline());
let text = self.textbox.content();
// Preparing the new text to be displayed.
// Possible optimization is to redraw the background only when pending character
// is replaced, or only draw rectangle over the pending character and
// marker.
display::rect_fill(self.area, theme::BG);
// 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);
display::text_left(
text_baseline,
&text_to_display,
style.text_font,
style.text_color,
style.background_color,
);
// Paint the pending marker.
if self.multi_tap.pending_key().is_some() {
paint_pending_marker(
text_baseline,
&text_to_display,
style.text_font,
style.text_color,
);
}
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
let style = theme::label_keyboard();
let text_baseline = self.area.top_left() + Offset::y(style.text_font.text_height())
- Offset::y(style.text_font.text_baseline());
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,
);
}
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
sink(self.area)
}
}
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for PassphraseKeyboard {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("PassphraseKeyboard");
t.string("passphrase", self.passphrase().into());
}
}

@ -0,0 +1,568 @@
use core::mem;
use heapless::String;
use crate::{
strutil::TString,
time::Duration,
trezorhal::random,
ui::{
component::{
base::ComponentExt, text::TextStyle, Child, Component, Event, EventCtx, Label, Maybe,
Never, Pad, TimerToken,
},
display::{self, Font},
event::TouchEvent,
geometry::{Alignment, Alignment2D, Grid, Insets, Offset, Rect},
model_mercury::component::{
button::{Button, ButtonContent, ButtonMsg, ButtonMsg::Clicked},
theme,
},
shape,
shape::Renderer,
},
};
pub enum PinKeyboardMsg {
Confirmed,
Cancelled,
}
const MAX_LENGTH: usize = 50;
const MAX_VISIBLE_DOTS: usize = 14;
const MAX_VISIBLE_DIGITS: usize = 16;
const DIGIT_COUNT: usize = 10; // 0..10
const HEADER_PADDING_SIDE: i16 = 5;
const HEADER_PADDING_BOTTOM: i16 = 12;
const HEADER_PADDING: Insets = Insets::new(
theme::borders().top,
HEADER_PADDING_SIDE,
HEADER_PADDING_BOTTOM,
HEADER_PADDING_SIDE,
);
pub struct PinKeyboard<'a> {
allow_cancel: bool,
major_prompt: Child<Label<'a>>,
minor_prompt: Child<Label<'a>>,
major_warning: Option<Child<Label<'a>>>,
textbox: Child<PinDots>,
textbox_pad: Pad,
erase_btn: Child<Maybe<Button>>,
cancel_btn: Child<Maybe<Button>>,
confirm_btn: Child<Button>,
digit_btns: [Child<Button>; DIGIT_COUNT],
warning_timer: Option<TimerToken>,
}
impl<'a> PinKeyboard<'a> {
// Label position fine-tuning.
const MAJOR_OFF: Offset = Offset::y(11);
const MINOR_OFF: Offset = Offset::y(11);
pub fn new(
major_prompt: TString<'a>,
minor_prompt: TString<'a>,
major_warning: Option<TString<'a>>,
allow_cancel: bool,
) -> Self {
// Control buttons.
let erase_btn = Button::with_icon_blend(
theme::IMAGE_BG_BACK_BTN,
theme::ICON_BACK,
Offset::new(30, 12),
)
.styled(theme::button_reset())
.with_long_press(theme::ERASE_HOLD_DURATION)
.initially_enabled(false);
let erase_btn = Maybe::hidden(theme::BG, erase_btn).into_child();
let cancel_btn = Button::with_icon(theme::ICON_CANCEL).styled(theme::button_cancel());
let cancel_btn = Maybe::new(theme::BG, cancel_btn, allow_cancel).into_child();
Self {
allow_cancel,
major_prompt: Label::left_aligned(major_prompt, theme::label_keyboard()).into_child(),
minor_prompt: Label::right_aligned(minor_prompt, theme::label_keyboard_minor())
.into_child(),
major_warning: major_warning.map(|text| {
Label::left_aligned(text, theme::label_keyboard_warning()).into_child()
}),
textbox: PinDots::new(theme::label_default()).into_child(),
textbox_pad: Pad::with_background(theme::label_default().background_color),
erase_btn,
cancel_btn,
confirm_btn: Button::with_icon(theme::ICON_CONFIRM)
.styled(theme::button_confirm())
.initially_enabled(false)
.into_child(),
digit_btns: Self::generate_digit_buttons(),
warning_timer: None,
}
}
fn generate_digit_buttons() -> [Child<Button>; DIGIT_COUNT] {
// Generate a random sequence of digits from 0 to 9.
let mut digits = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"];
random::shuffle(&mut digits);
digits
.map(|c| Button::with_text(c.into()))
.map(|b| b.styled(theme::button_pin()))
.map(Child::new)
}
fn pin_modified(&mut self, ctx: &mut EventCtx) {
let is_full = self.textbox.inner().is_full();
let is_empty = self.textbox.inner().is_empty();
self.textbox_pad.clear();
self.textbox.request_complete_repaint(ctx);
if is_empty {
self.major_prompt.request_complete_repaint(ctx);
self.minor_prompt.request_complete_repaint(ctx);
self.major_warning.request_complete_repaint(ctx);
}
let cancel_enabled = is_empty && self.allow_cancel;
for btn in &mut self.digit_btns {
btn.mutate(ctx, |ctx, btn| btn.enable_if(ctx, !is_full));
}
self.erase_btn.mutate(ctx, |ctx, btn| {
btn.show_if(ctx, !is_empty);
btn.inner_mut().enable_if(ctx, !is_empty);
});
self.cancel_btn.mutate(ctx, |ctx, btn| {
btn.show_if(ctx, cancel_enabled);
btn.inner_mut().enable_if(ctx, is_empty);
});
self.confirm_btn
.mutate(ctx, |ctx, btn| btn.enable_if(ctx, !is_empty));
}
pub fn pin(&self) -> &str {
self.textbox.inner().pin()
}
}
impl Component for PinKeyboard<'_> {
type Msg = PinKeyboardMsg;
fn place(&mut self, bounds: Rect) -> Rect {
// Ignore the top padding for now, we need it to reliably register textbox touch
// events.
let borders_no_top = Insets {
top: 0,
..theme::borders()
};
// Prompts and PIN dots display.
let (header, keypad) = bounds
.inset(borders_no_top)
.split_bottom(4 * theme::PIN_BUTTON_HEIGHT + 3 * theme::BUTTON_SPACING);
let prompt = header.inset(HEADER_PADDING);
// the inset -3 is a workaround for long text in "re-enter wipe code"
let major_area = prompt.translate(Self::MAJOR_OFF).inset(Insets::right(-3));
let minor_area = prompt.translate(Self::MINOR_OFF);
// Control buttons.
let grid = Grid::new(keypad, 4, 3).with_spacing(theme::BUTTON_SPACING);
// Prompts and PIN dots display.
self.textbox_pad.place(header);
self.textbox.place(header);
self.major_prompt.place(major_area);
self.minor_prompt.place(minor_area);
self.major_warning.as_mut().map(|c| c.place(major_area));
// Control buttons.
let erase_cancel_area = grid.row_col(3, 0);
self.erase_btn.place(erase_cancel_area);
self.cancel_btn.place(erase_cancel_area);
self.confirm_btn.place(grid.row_col(3, 2));
// Digit buttons.
for (i, btn) in self.digit_btns.iter_mut().enumerate() {
// Assign the digits to buttons on a 4x3 grid, starting from the first row.
let area = grid.cell(if i < 9 {
i
} else {
// For the last key (the "0" position) we skip one cell.
i + 1
});
btn.place(area);
}
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
match event {
// Set up timer to switch off warning prompt.
Event::Attach if self.major_warning.is_some() => {
self.warning_timer = Some(ctx.request_timer(Duration::from_secs(2)));
}
// Hide warning, show major prompt.
Event::Timer(token) if Some(token) == self.warning_timer => {
self.major_warning = None;
self.textbox_pad.clear();
self.minor_prompt.request_complete_repaint(ctx);
ctx.request_paint();
}
_ => {}
}
self.textbox.event(ctx, event);
if let Some(Clicked) = self.confirm_btn.event(ctx, event) {
return Some(PinKeyboardMsg::Confirmed);
}
if let Some(Clicked) = self.cancel_btn.event(ctx, event) {
return Some(PinKeyboardMsg::Cancelled);
}
match self.erase_btn.event(ctx, event) {
Some(ButtonMsg::Clicked) => {
self.textbox.mutate(ctx, |ctx, t| t.pop(ctx));
self.pin_modified(ctx);
return None;
}
Some(ButtonMsg::LongPressed) => {
self.textbox.mutate(ctx, |ctx, t| t.clear(ctx));
self.pin_modified(ctx);
return None;
}
_ => {}
}
for btn in &mut self.digit_btns {
if let Some(Clicked) = btn.event(ctx, event) {
if let ButtonContent::Text(text) = btn.inner().content() {
text.map(|text| {
self.textbox.mutate(ctx, |ctx, t| t.push(ctx, text));
});
self.pin_modified(ctx);
return None;
}
}
}
None
}
fn paint(&mut self) {
self.erase_btn.paint();
self.textbox_pad.paint();
if self.textbox.inner().is_empty() {
if let Some(ref mut w) = self.major_warning {
w.paint();
} else {
self.major_prompt.paint();
}
self.minor_prompt.paint();
self.cancel_btn.paint();
} else {
self.textbox.paint();
}
self.confirm_btn.paint();
for btn in &mut self.digit_btns {
btn.paint();
}
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
self.erase_btn.render(target);
self.textbox_pad.render(target);
if self.textbox.inner().is_empty() {
if let Some(ref w) = self.major_warning {
w.render(target);
} else {
self.major_prompt.render(target);
}
self.minor_prompt.render(target);
self.cancel_btn.render(target);
} else {
self.textbox.render(target);
}
self.confirm_btn.render(target);
for btn in &self.digit_btns {
btn.render(target);
}
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
self.major_prompt.bounds(sink);
self.minor_prompt.bounds(sink);
self.erase_btn.bounds(sink);
self.cancel_btn.bounds(sink);
self.confirm_btn.bounds(sink);
self.textbox.bounds(sink);
for b in &self.digit_btns {
b.bounds(sink)
}
}
}
struct PinDots {
area: Rect,
pad: Pad,
style: TextStyle,
digits: String<MAX_LENGTH>,
display_digits: bool,
}
impl PinDots {
const DOT: i16 = 6;
const PADDING: i16 = 6;
const TWITCH: i16 = 4;
fn new(style: TextStyle) -> Self {
Self {
area: Rect::zero(),
pad: Pad::with_background(style.background_color),
style,
digits: String::new(),
display_digits: false,
}
}
fn size(&self) -> Offset {
let ndots = self.digits.len().min(MAX_VISIBLE_DOTS);
let mut width = Self::DOT * (ndots as i16);
width += Self::PADDING * (ndots.saturating_sub(1) as i16);
Offset::new(width, Self::DOT)
}
fn is_empty(&self) -> bool {
self.digits.is_empty()
}
fn is_full(&self) -> bool {
self.digits.len() == self.digits.capacity()
}
fn clear(&mut self, ctx: &mut EventCtx) {
self.digits.clear();
ctx.request_paint()
}
fn push(&mut self, ctx: &mut EventCtx, text: &str) {
if self.digits.push_str(text).is_err() {
// `self.pin` is full and wasn't able to accept all of
// `text`. Should not happen.
};
ctx.request_paint()
}
fn pop(&mut self, ctx: &mut EventCtx) {
if self.digits.pop().is_some() {
ctx.request_paint()
}
}
fn pin(&self) -> &str {
&self.digits
}
fn paint_digits(&self, area: Rect) {
let center = area.center() + Offset::y(Font::MONO.text_height() / 2);
let right = center + Offset::x(Font::MONO.text_width("0") * (MAX_VISIBLE_DOTS as i16) / 2);
let digits = self.digits.len();
if digits <= MAX_VISIBLE_DOTS {
display::text_center(
center,
&self.digits,
Font::MONO,
self.style.text_color,
self.style.background_color,
);
} else {
let offset: usize = digits.saturating_sub(MAX_VISIBLE_DIGITS);
display::text_right(
right,
&self.digits[offset..],
Font::MONO,
self.style.text_color,
self.style.background_color,
);
}
}
fn render_digits<'s>(&self, area: Rect, target: &mut impl Renderer<'s>) {
let center = area.center() + Offset::y(Font::MONO.text_height() / 2);
let right = center + Offset::x(Font::MONO.text_width("0") * (MAX_VISIBLE_DOTS as i16) / 2);
let digits = self.digits.len();
if digits <= MAX_VISIBLE_DOTS {
shape::Text::new(center, &self.digits)
.with_align(Alignment::Center)
.with_font(Font::MONO)
.with_fg(self.style.text_color)
.render(target);
} else {
let offset: usize = digits.saturating_sub(MAX_VISIBLE_DIGITS);
shape::Text::new(right, &self.digits[offset..])
.with_align(Alignment::End)
.with_font(Font::MONO)
.with_fg(self.style.text_color)
.render(target);
}
}
fn paint_dots(&self, area: Rect) {
let mut cursor = self.size().snap(area.center(), Alignment2D::CENTER);
let digits = self.digits.len();
let dots_visible = digits.min(MAX_VISIBLE_DOTS);
let step = Self::DOT + Self::PADDING;
// Jiggle when overflowed.
if digits > dots_visible && digits % 2 == 0 {
cursor.x += Self::TWITCH
}
// Small leftmost dot.
if digits > dots_visible + 1 {
theme::DOT_SMALL.draw(
cursor - Offset::x(2 * step),
Alignment2D::TOP_LEFT,
self.style.text_color,
self.style.background_color,
);
}
// Greyed out dot.
if digits > dots_visible {
theme::DOT_ACTIVE.draw(
cursor - Offset::x(step),
Alignment2D::TOP_LEFT,
theme::GREY_LIGHT,
self.style.background_color,
);
}
// Draw a dot for each PIN digit.
for _ in 0..dots_visible {
theme::DOT_ACTIVE.draw(
cursor,
Alignment2D::TOP_LEFT,
self.style.text_color,
self.style.background_color,
);
cursor.x += step;
}
}
fn render_dots<'s>(&self, area: Rect, target: &mut impl Renderer<'s>) {
let mut cursor = self.size().snap(area.center(), Alignment2D::CENTER);
let digits = self.digits.len();
let dots_visible = digits.min(MAX_VISIBLE_DOTS);
let step = Self::DOT + Self::PADDING;
// Jiggle when overflowed.
if digits > dots_visible && digits % 2 == 0 {
cursor.x += Self::TWITCH
}
// Small leftmost dot.
if digits > dots_visible + 1 {
shape::ToifImage::new(cursor - Offset::x(2 * step), theme::DOT_SMALL.toif)
.with_align(Alignment2D::TOP_LEFT)
.with_fg(self.style.text_color)
.render(target);
}
// Greyed out dot.
if digits > dots_visible {
shape::ToifImage::new(cursor - Offset::x(step), theme::DOT_ACTIVE.toif)
.with_align(Alignment2D::TOP_LEFT)
.with_fg(theme::GREY_LIGHT)
.render(target);
}
// Draw a dot for each PIN digit.
for _ in 0..dots_visible {
shape::ToifImage::new(cursor, theme::DOT_ACTIVE.toif)
.with_align(Alignment2D::TOP_LEFT)
.with_fg(self.style.text_color)
.render(target);
cursor.x += step;
}
}
}
impl Component for PinDots {
type Msg = Never;
fn place(&mut self, bounds: Rect) -> Rect {
self.pad.place(bounds);
self.area = bounds;
self.area
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
match event {
Event::Touch(TouchEvent::TouchStart(pos)) => {
if self.area.contains(pos) {
self.display_digits = true;
self.pad.clear();
ctx.request_paint();
};
None
}
Event::Touch(TouchEvent::TouchEnd(_)) => {
if mem::replace(&mut self.display_digits, false) {
self.pad.clear();
ctx.request_paint();
};
None
}
_ => None,
}
}
fn paint(&mut self) {
let dot_area = self.area.inset(HEADER_PADDING);
self.pad.paint();
if self.display_digits {
self.paint_digits(dot_area)
} else {
self.paint_dots(dot_area)
}
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
let dot_area = self.area.inset(HEADER_PADDING);
self.pad.render(target);
if self.display_digits {
self.render_digits(dot_area, target)
} else {
self.render_dots(dot_area, target)
}
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
sink(self.area);
sink(self.area.inset(HEADER_PADDING));
}
}
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for PinKeyboard<'_> {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("PinKeyboard");
// So that debuglink knows the locations of the buttons
let mut digits_order: String<10> = String::new();
for btn in self.digit_btns.iter() {
let btn_content = btn.inner().content();
if let ButtonContent::Text(text) = btn_content {
text.map(|text| {
unwrap!(digits_order.push_str(text));
});
}
}
t.string("digits_order", digits_order.as_str().into());
t.string("pin", self.textbox.inner().pin().into());
t.bool("display_digits", self.textbox.inner().display_digits);
}
}

@ -0,0 +1,399 @@
use core::iter;
use heapless::String;
use crate::{
trezorhal::slip39,
ui::{
component::{
text::common::{TextBox, TextEdit},
Component, Event, EventCtx,
},
display,
geometry::{Alignment2D, Offset, Rect},
model_mercury::{
component::{
keyboard::{
common::{paint_pending_marker, render_pending_marker, MultiTapKeyboard},
mnemonic::{MnemonicInput, MnemonicInputMsg, MNEMONIC_KEY_COUNT},
},
Button, ButtonContent, ButtonMsg,
},
theme,
},
shape,
shape::Renderer,
util::ResultExt,
},
};
const MAX_LENGTH: usize = 8;
pub struct Slip39Input {
button: Button,
textbox: TextBox<MAX_LENGTH>,
multi_tap: MultiTapKeyboard,
final_word: Option<&'static str>,
input_mask: Slip39Mask,
}
impl MnemonicInput for Slip39Input {
/// Return the key set. Keys are further specified as indices into this
/// array.
fn keys() -> [&'static str; MNEMONIC_KEY_COUNT] {
["ab", "cd", "ef", "ghij", "klm", "nopq", "rs", "tuv", "wxyz"]
}
/// Returns `true` if given key index can continue towards a valid mnemonic
/// word, `false` otherwise.
fn can_key_press_lead_to_a_valid_word(&self, key: usize) -> bool {
if self.input_mask.is_final() {
false
} else {
// Currently pending key is always enabled.
// Keys that mach the completion mask are enabled as well.
self.multi_tap.pending_key() == Some(key) || self.input_mask.contains_key(key)
}
}
/// Key button was clicked. If this button is pending, let's cycle the
/// pending character in textbox. If not, let's just append the first
/// character.
fn on_key_click(&mut self, ctx: &mut EventCtx, key: usize) {
let edit = self.multi_tap.click_key(ctx, key, Self::keys()[key]);
if let TextEdit::Append(_) = edit {
// This key press wasn't just a pending key rotation, so let's push the key
// digit to the buffer.
self.textbox.append(ctx, Self::key_digit(key));
} else {
// Ignore the pending char rotation. We use the pending key to paint
// the last character, but the mnemonic word computation depends
// only on the pressed key, not on the specific character inside it.
// Request paint of pending char.
ctx.request_paint();
}
self.complete_word_from_dictionary(ctx);
}
/// Backspace button was clicked, let's delete the last character of input
/// and clear the pending marker.
fn on_backspace_click(&mut self, ctx: &mut EventCtx) {
self.multi_tap.clear_pending_state(ctx);
self.textbox.delete_last(ctx);
self.complete_word_from_dictionary(ctx);
}
/// Backspace button was long pressed, let's delete all characters of input
/// and clear the pending marker.
fn on_backspace_long_press(&mut self, ctx: &mut EventCtx) {
self.multi_tap.clear_pending_state(ctx);
self.textbox.clear(ctx);
self.complete_word_from_dictionary(ctx);
}
fn is_empty(&self) -> bool {
self.textbox.is_empty()
}
fn mnemonic(&self) -> Option<&'static str> {
self.final_word
}
}
impl Component for Slip39Input {
type Msg = MnemonicInputMsg;
fn place(&mut self, bounds: Rect) -> Rect {
self.button.place(bounds)
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
if self.multi_tap.is_timeout_event(event) {
// Timeout occurred. Reset the pending key.
self.multi_tap.clear_pending_state(ctx);
return Some(MnemonicInputMsg::TimedOut);
}
if let Some(ButtonMsg::Clicked) = self.button.event(ctx, event) {
// Input button was clicked. If the whole word is totally identified, let's
// confirm it, otherwise don't do anything.
if self.input_mask.is_final() {
return Some(MnemonicInputMsg::Confirmed);
}
}
None
}
fn paint(&mut self) {
let area = self.button.area();
let style = self.button.style();
// First, paint the button background.
self.button.paint_background(style);
// Content starts in the left-center point, offset by 16px to the right and 8px
// to the bottom.
let text_baseline = area.top_left().center(area.bottom_left()) + Offset::new(16, 8);
// To simplify things, we always copy the printed string here, even if it
// wouldn't be strictly necessary.
let mut text: String<MAX_LENGTH> = String::new();
if let Some(word) = self.final_word {
// We're done with input, paint the full word.
text.push_str(word)
.assert_if_debugging_ui("Text buffer is too small");
} else {
// Paint an asterisk for each letter of input.
for ch in iter::repeat('*').take(self.textbox.content().len()) {
text.push(ch)
.assert_if_debugging_ui("Text buffer is too small");
}
// If we're in the pending state, paint the pending character at the end.
if let (Some(key), Some(press)) =
(self.multi_tap.pending_key(), self.multi_tap.pending_press())
{
assert!(!Self::keys()[key].is_empty());
// Now we can be sure that the looped iterator will return a value.
let ch = unwrap!(Self::keys()[key].chars().cycle().nth(press));
text.pop();
text.push(ch)
.assert_if_debugging_ui("Text buffer is too small");
}
}
display::text_left(
text_baseline,
text.as_str(),
style.font,
style.text_color,
style.button_color,
);
// Paint the pending marker.
if self.multi_tap.pending_key().is_some() && self.final_word.is_none() {
paint_pending_marker(text_baseline, text.as_str(), style.font, style.text_color);
}
// Paint the icon.
if let ButtonContent::Icon(icon) = self.button.content() {
// Icon is painted in the right-center point, of expected size 16x16 pixels, and
// 16px from the right edge.
let icon_center = area.top_right().center(area.bottom_right()) - Offset::new(16 + 8, 0);
icon.draw(
icon_center,
Alignment2D::CENTER,
style.text_color,
style.button_color,
);
}
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
let area = self.button.area();
let style = self.button.style();
// First, paint the button background.
self.button.render_background(target, style);
// Content starts in the left-center point, offset by 16px to the right and 8px
// to the bottom.
let text_baseline = area.top_left().center(area.bottom_left()) + Offset::new(16, 8);
// To simplify things, we always copy the printed string here, even if it
// wouldn't be strictly necessary.
let mut text: String<MAX_LENGTH> = String::new();
if let Some(word) = self.final_word {
// We're done with input, paint the full word.
text.push_str(word)
.assert_if_debugging_ui("Text buffer is too small");
} else {
// Paint an asterisk for each letter of input.
for ch in iter::repeat('*').take(self.textbox.content().len()) {
text.push(ch)
.assert_if_debugging_ui("Text buffer is too small");
}
// If we're in the pending state, paint the pending character at the end.
if let (Some(key), Some(press)) =
(self.multi_tap.pending_key(), self.multi_tap.pending_press())
{
assert!(!Self::keys()[key].is_empty());
// Now we can be sure that the looped iterator will return a value.
let ch = unwrap!(Self::keys()[key].chars().cycle().nth(press));
text.pop();
text.push(ch)
.assert_if_debugging_ui("Text buffer is too small");
}
}
shape::Text::new(text_baseline, text.as_str())
.with_font(style.font)
.with_fg(style.text_color)
.render(target);
// Paint the pending marker.
if self.multi_tap.pending_key().is_some() && self.final_word.is_none() {
render_pending_marker(
target,
text_baseline,
text.as_str(),
style.font,
style.text_color,
);
}
// Paint the icon.
if let ButtonContent::Icon(icon) = self.button.content() {
// Icon is painted in the right-center point, of expected size 16x16 pixels, and
// 16px from the right edge.
let icon_center = area.top_right().center(area.bottom_right()) - Offset::new(16 + 8, 0);
shape::ToifImage::new(icon_center, icon.toif)
.with_align(Alignment2D::CENTER)
.with_fg(style.text_color)
.render(target);
}
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
self.button.bounds(sink);
}
}
impl Slip39Input {
pub fn new() -> Self {
Self {
// Button has the same style the whole time
button: Button::empty().styled(theme::button_pin_confirm()),
textbox: TextBox::empty(),
multi_tap: MultiTapKeyboard::new(),
final_word: None,
input_mask: Slip39Mask::full(),
}
}
pub fn prefilled_word(word: &str) -> Self {
// Word may be empty string, fallback to normal input
if word.is_empty() {
return Self::new();
}
let (buff, input_mask, final_word) = Self::setup_from_prefilled_word(word);
Self {
// Button has the same style the whole time
button: Button::empty().styled(theme::button_pin_confirm()),
textbox: TextBox::new(buff),
multi_tap: MultiTapKeyboard::new(),
final_word,
input_mask,
}
}
fn setup_from_prefilled_word(
word: &str,
) -> (String<MAX_LENGTH>, Slip39Mask, Option<&'static str>) {
let mut buff: String<MAX_LENGTH> = String::new();
// Gradually appending encoded key digits to the buffer and checking if
// have not already formed a final word.
for ch in word.chars() {
let mut index = 0;
for (i, key) in Self::keys().iter().enumerate() {
if key.contains(ch) {
index = i;
break;
}
}
buff.push(Self::key_digit(index))
.assert_if_debugging_ui("Text buffer is too small");
let sequence: Option<u16> = buff.parse().ok();
let input_mask = sequence
.and_then(slip39::word_completion_mask)
.map(Slip39Mask)
.unwrap_or_else(Slip39Mask::full);
let final_word = if input_mask.is_final() {
sequence.and_then(slip39::button_sequence_to_word)
} else {
None
};
// As soon as we have a final word, we can stop.
if final_word.is_some() {
return (buff, input_mask, final_word);
}
}
(buff, Slip39Mask::full(), None)
}
/// Convert a key index into the key digit. This is what we push into the
/// input buffer.
///
/// # Examples
///
/// ```
/// Self::key_digit(0) == '1';
/// Self::key_digit(1) == '2';
/// ```
fn key_digit(key: usize) -> char {
let index = key + 1;
unwrap!(char::from_digit(index as u32, 10))
}
fn complete_word_from_dictionary(&mut self, ctx: &mut EventCtx) {
let sequence = self.input_sequence();
self.input_mask = sequence
.and_then(slip39::word_completion_mask)
.map(Slip39Mask)
.unwrap_or_else(Slip39Mask::full);
self.final_word = if self.input_mask.is_final() {
sequence.and_then(slip39::button_sequence_to_word)
} else {
None
};
// Change the style of the button depending on the input.
if self.final_word.is_some() {
// Confirm button.
self.button.enable(ctx);
self.button
.set_content(ctx, ButtonContent::Icon(theme::ICON_LIST_CHECK));
} else {
// Disabled button.
self.button.disable(ctx);
self.button.set_content(ctx, ButtonContent::Text("".into()));
}
}
fn input_sequence(&self) -> Option<u16> {
self.textbox.content().parse().ok()
}
}
struct Slip39Mask(u16);
impl Slip39Mask {
/// Return a mask with all keys allowed.
fn full() -> Self {
Self(0x1FF) // All buttons are allowed. 9-bit bitmap all set to 1.
}
/// Returns `true` if `key` can lead to a valid SLIP39 word with this mask.
fn contains_key(&self, key: usize) -> bool {
self.0 & (1 << key) != 0
}
/// Returns `true` if mask has exactly one bit set to 1, or is equal to 0.
fn is_final(&self) -> bool {
self.0.count_ones() <= 1
}
}
// DEBUG-ONLY SECTION BELOW
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for Slip39Input {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("Slip39Input");
t.child("textbox", &self.textbox);
}
}

@ -0,0 +1,80 @@
use crate::ui::{
component::{Component, Event, EventCtx},
geometry::{Grid, GridCellSpan, Rect},
model_mercury::{
component::button::{Button, ButtonMsg},
theme,
},
shape::Renderer,
};
const NUMBERS: [u32; 5] = [12, 18, 20, 24, 33];
const LABELS: [&str; 5] = ["12", "18", "20", "24", "33"];
const CELLS: [(usize, usize); 5] = [(0, 0), (0, 2), (0, 4), (1, 0), (1, 2)];
pub struct SelectWordCount {
button: [Button; NUMBERS.len()],
}
pub enum SelectWordCountMsg {
Selected(u32),
}
impl SelectWordCount {
pub fn new() -> Self {
SelectWordCount {
button: LABELS.map(|t| Button::with_text(t.into()).styled(theme::button_pin())),
}
}
}
impl Component for SelectWordCount {
type Msg = SelectWordCountMsg;
fn place(&mut self, bounds: Rect) -> Rect {
let (_, bounds) = bounds.split_bottom(2 * theme::BUTTON_HEIGHT + theme::BUTTON_SPACING);
let grid = Grid::new(bounds, 2, 6).with_spacing(theme::BUTTON_SPACING);
for (btn, (x, y)) in self.button.iter_mut().zip(CELLS) {
btn.place(grid.cells(GridCellSpan {
from: (x, y),
to: (x, y + 1),
}));
}
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
for (i, btn) in self.button.iter_mut().enumerate() {
if let Some(ButtonMsg::Clicked) = btn.event(ctx, event) {
return Some(SelectWordCountMsg::Selected(NUMBERS[i]));
}
}
None
}
fn paint(&mut self) {
for btn in self.button.iter_mut() {
btn.paint()
}
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
for btn in self.button.iter() {
btn.render(target)
}
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
for btn in self.button.iter() {
btn.bounds(sink)
}
}
}
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for SelectWordCount {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("SelectWordCount");
}
}

@ -1,18 +1,76 @@
#[cfg(feature = "translations")]
mod address_details;
pub mod bl_confirm;
mod button;
#[cfg(feature = "translations")]
mod coinjoin_progress;
mod dialog;
mod fido;
mod footer;
mod vertical_menu;
#[rustfmt::skip]
mod fido_icons;
mod error;
mod frame;
#[cfg(feature = "micropython")]
mod homescreen;
mod keyboard;
mod loader;
#[cfg(feature = "translations")]
mod number_input;
#[cfg(feature = "translations")]
mod page;
mod progress;
mod prompt_screen;
mod result;
mod scroll;
#[cfg(feature = "translations")]
mod share_words;
mod simple_page;
mod status_screen;
mod swipe;
mod swipe_up_screen;
mod welcome_screen;
#[cfg(feature = "translations")]
pub use address_details::AddressDetails;
pub use button::{
Button, ButtonContent, ButtonMsg, ButtonStyle, ButtonStyleSheet, CancelInfoConfirmMsg, IconText,
Button, ButtonContent, ButtonMsg, ButtonStyle, ButtonStyleSheet, CancelConfirmMsg,
CancelInfoConfirmMsg, IconText,
};
#[cfg(feature = "translations")]
pub use coinjoin_progress::CoinJoinProgress;
pub use dialog::{Dialog, DialogMsg, IconDialog};
pub use error::ErrorScreen;
pub use fido::{FidoConfirm, FidoMsg};
pub use footer::Footer;
pub use frame::{Frame, FrameMsg};
#[cfg(feature = "micropython")]
pub use homescreen::{check_homescreen_format, Homescreen, HomescreenMsg, Lockscreen};
pub use keyboard::{
bip39::Bip39Input,
mnemonic::{MnemonicInput, MnemonicKeyboard, MnemonicKeyboardMsg},
passphrase::{PassphraseKeyboard, PassphraseKeyboardMsg},
pin::{PinKeyboard, PinKeyboardMsg},
slip39::Slip39Input,
word_count::{SelectWordCount, SelectWordCountMsg},
};
pub use loader::{Loader, LoaderMsg, LoaderStyle, LoaderStyleSheet};
#[cfg(feature = "translations")]
pub use number_input::{NumberInputDialog, NumberInputDialogMsg};
#[cfg(feature = "translations")]
pub use page::ButtonPage;
pub use progress::Progress;
pub use prompt_screen::PromptScreen;
pub use result::{ResultFooter, ResultScreen, ResultStyle};
pub use scroll::ScrollBar;
#[cfg(feature = "translations")]
pub use share_words::ShareWords;
pub use simple_page::SimplePage;
pub use status_screen::StatusScreen;
pub use swipe::{Swipe, SwipeDirection};
pub use swipe_up_screen::{SwipeUpScreen, SwipeUpScreenMsg};
pub use vertical_menu::{VerticalMenu, VerticalMenuChoiceMsg};
pub use welcome_screen::WelcomeScreen;
use super::{constant, theme};

@ -0,0 +1,272 @@
use crate::{
error::Error,
strutil::{self, TString},
translations::TR,
ui::{
component::{
base::ComponentExt,
paginated::Paginate,
text::paragraphs::{Paragraph, Paragraphs},
Child, Component, Event, EventCtx, Pad,
},
display::{self, Font},
geometry::{Alignment, Grid, Insets, Offset, Rect},
shape::{self, Renderer},
},
};
use super::{theme, Button, ButtonMsg};
pub enum NumberInputDialogMsg {
Selected,
InfoRequested,
}
pub struct NumberInputDialog<F>
where
F: Fn(u32) -> TString<'static>,
{
area: Rect,
description_func: F,
input: Child<NumberInput>,
paragraphs: Child<Paragraphs<Paragraph<'static>>>,
paragraphs_pad: Pad,
info_button: Child<Button>,
confirm_button: Child<Button>,
}
impl<F> NumberInputDialog<F>
where
F: Fn(u32) -> TString<'static>,
{
pub fn new(min: u32, max: u32, init_value: u32, description_func: F) -> Result<Self, Error> {
let text = description_func(init_value);
Ok(Self {
area: Rect::zero(),
description_func,
input: NumberInput::new(min, max, init_value).into_child(),
paragraphs: Paragraphs::new(Paragraph::new(&theme::TEXT_NORMAL, text)).into_child(),
paragraphs_pad: Pad::with_background(theme::BG),
info_button: Button::with_text(TR::buttons__info.into()).into_child(),
confirm_button: Button::with_text(TR::buttons__continue.into())
.styled(theme::button_confirm())
.into_child(),
})
}
fn update_text(&mut self, ctx: &mut EventCtx, value: u32) {
let text = (self.description_func)(value);
self.paragraphs.mutate(ctx, move |ctx, para| {
para.inner_mut().update(text);
// Recompute bounding box.
para.change_page(0);
ctx.request_paint()
});
self.paragraphs_pad.clear();
ctx.request_paint();
}
pub fn value(&self) -> u32 {
self.input.inner().value
}
}
impl<F> Component for NumberInputDialog<F>
where
F: Fn(u32) -> TString<'static>,
{
type Msg = NumberInputDialogMsg;
fn place(&mut self, bounds: Rect) -> Rect {
self.area = bounds;
let button_height = theme::BUTTON_HEIGHT;
let content_area = self.area.inset(Insets::top(2 * theme::BUTTON_SPACING));
let (input_area, content_area) = content_area.split_top(button_height);
let (content_area, button_area) = content_area.split_bottom(button_height);
let content_area = content_area.inset(Insets::new(
theme::BUTTON_SPACING,
0,
theme::BUTTON_SPACING,
theme::CONTENT_BORDER,
));
let grid = Grid::new(button_area, 1, 2).with_spacing(theme::KEYBOARD_SPACING);
self.input.place(input_area);
self.paragraphs.place(content_area);
self.paragraphs_pad.place(content_area);
self.info_button.place(grid.row_col(0, 0));
self.confirm_button.place(grid.row_col(0, 1));
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
if let Some(NumberInputMsg::Changed(i)) = self.input.event(ctx, event) {
self.update_text(ctx, i);
}
self.paragraphs.event(ctx, event);
if let Some(ButtonMsg::Clicked) = self.info_button.event(ctx, event) {
return Some(Self::Msg::InfoRequested);
}
if let Some(ButtonMsg::Clicked) = self.confirm_button.event(ctx, event) {
return Some(Self::Msg::Selected);
};
None
}
fn paint(&mut self) {
self.input.paint();
self.paragraphs_pad.paint();
self.paragraphs.paint();
self.info_button.paint();
self.confirm_button.paint();
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
self.input.render(target);
self.paragraphs_pad.render(target);
self.paragraphs.render(target);
self.info_button.render(target);
self.confirm_button.render(target);
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
sink(self.area);
self.input.bounds(sink);
self.paragraphs.bounds(sink);
self.info_button.bounds(sink);
self.confirm_button.bounds(sink);
}
}
#[cfg(feature = "ui_debug")]
impl<F> crate::trace::Trace for NumberInputDialog<F>
where
F: Fn(u32) -> TString<'static>,
{
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("NumberInputDialog");
t.child("input", &self.input);
t.child("paragraphs", &self.paragraphs);
t.child("info_button", &self.info_button);
t.child("confirm_button", &self.confirm_button);
}
}
pub enum NumberInputMsg {
Changed(u32),
}
pub struct NumberInput {
area: Rect,
dec: Child<Button>,
inc: Child<Button>,
min: u32,
max: u32,
value: u32,
}
impl NumberInput {
pub fn new(min: u32, max: u32, value: u32) -> Self {
let dec = Button::with_text("-".into())
.styled(theme::button_counter())
.into_child();
let inc = Button::with_text("+".into())
.styled(theme::button_counter())
.into_child();
let value = value.clamp(min, max);
Self {
area: Rect::zero(),
dec,
inc,
min,
max,
value,
}
}
}
impl Component for NumberInput {
type Msg = NumberInputMsg;
fn place(&mut self, bounds: Rect) -> Rect {
let grid = Grid::new(bounds, 1, 3).with_spacing(theme::KEYBOARD_SPACING);
self.dec.place(grid.row_col(0, 0));
self.inc.place(grid.row_col(0, 2));
self.area = grid.row_col(0, 1);
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
let mut changed = false;
if let Some(ButtonMsg::Clicked) = self.dec.event(ctx, event) {
self.value = self.min.max(self.value.saturating_sub(1));
changed = true;
};
if let Some(ButtonMsg::Clicked) = self.inc.event(ctx, event) {
self.value = self.max.min(self.value.saturating_add(1));
changed = true;
};
if changed {
self.dec
.mutate(ctx, |ctx, btn| btn.enable_if(ctx, self.value > self.min));
self.inc
.mutate(ctx, |ctx, btn| btn.enable_if(ctx, self.value < self.max));
ctx.request_paint();
return Some(NumberInputMsg::Changed(self.value));
}
None
}
fn paint(&mut self) {
let mut buf = [0u8; 10];
if let Some(text) = strutil::format_i64(self.value as i64, &mut buf) {
let digit_font = Font::DEMIBOLD;
let y_offset = digit_font.text_height() / 2 + Button::BASELINE_OFFSET.y;
display::rect_fill(self.area, theme::BG);
display::text_center(
self.area.center() + Offset::y(y_offset),
text,
digit_font,
theme::FG,
theme::BG,
);
}
self.dec.paint();
self.inc.paint();
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
let mut buf = [0u8; 10];
if let Some(text) = strutil::format_i64(self.value as i64, &mut buf) {
let digit_font = Font::DEMIBOLD;
let y_offset = digit_font.text_height() / 2 + Button::BASELINE_OFFSET.y;
shape::Bar::new(self.area).with_bg(theme::BG).render(target);
shape::Text::new(self.area.center() + Offset::y(y_offset), text)
.with_align(Alignment::Center)
.with_fg(theme::FG)
.with_font(digit_font)
.render(target);
}
self.dec.render(target);
self.inc.render(target);
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
self.dec.bounds(sink);
self.inc.bounds(sink);
sink(self.area)
}
}
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for NumberInput {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("NumberInput");
t.int("value", self.value as i64);
}
}

@ -0,0 +1,846 @@
use crate::{
error::Error,
strutil::TString,
time::Instant,
translations::TR,
ui::{
component::{paginated::PageMsg, Component, ComponentExt, Event, EventCtx, Pad, Paginate},
constant,
display::{self, Color},
geometry::{Insets, Rect},
shape::Renderer,
util::animation_disabled,
},
};
use super::{
theme, Button, ButtonContent, ButtonMsg, ButtonStyleSheet, Loader, LoaderMsg, ScrollBar, Swipe,
SwipeDirection,
};
use core::cell::Cell;
/// Allows pagination of inner component. Shows scroll bar, confirm & cancel
/// buttons. Optionally handles hold-to-confirm with loader.
pub struct ButtonPage<T> {
/// Inner component.
content: T,
/// Cleared when page changes.
pad: Pad,
/// Swipe controller.
swipe: Swipe,
scrollbar: ScrollBar,
/// Hold-to-confirm mode whenever this is `Some(loader)`.
loader: Option<Loader>,
button_cancel: Option<Button>,
button_confirm: Button,
button_prev: Button,
button_next: Button,
/// Show cancel button instead of back button.
cancel_from_any_page: bool,
/// Whether to pass-through left swipe to parent component.
swipe_left: bool,
/// Whether to pass-through right swipe to parent component.
swipe_right: bool,
/// Fade to given backlight level on next paint().
fade: Cell<Option<u16>>,
}
impl<T> ButtonPage<T>
where
T: Paginate,
T: Component,
{
pub fn with_hold(mut self) -> Result<Self, Error> {
self.button_confirm =
Button::with_text(TR::buttons__hold_to_confirm.into()).styled(theme::button_confirm());
self.loader = Some(Loader::new());
Ok(self)
}
}
impl<T> ButtonPage<T>
where
T: Paginate,
T: Component,
{
pub fn new(content: T, background: Color) -> Self {
Self {
content,
pad: Pad::with_background(background),
swipe: Swipe::new(),
scrollbar: ScrollBar::vertical(),
loader: None,
button_cancel: Some(Button::with_icon(theme::ICON_CANCEL)),
button_confirm: Button::with_icon(theme::ICON_CONFIRM).styled(theme::button_confirm()),
button_prev: Button::with_icon(theme::ICON_UP).initially_enabled(false),
button_next: Button::with_icon(theme::ICON_DOWN),
cancel_from_any_page: false,
swipe_left: false,
swipe_right: false,
fade: Cell::new(None),
}
}
pub fn without_cancel(mut self) -> Self {
self.button_cancel = None;
self
}
pub fn with_cancel_confirm(
mut self,
left: Option<TString<'static>>,
right: Option<TString<'static>>,
) -> Self {
let cancel = match left {
Some(verb) => verb.map(|s| match s {
"^" => Button::with_icon(theme::ICON_UP),
"<" => Button::with_icon(theme::ICON_BACK),
_ => Button::with_text(verb),
}),
_ => Button::with_icon(theme::ICON_CANCEL),
};
let confirm = match right {
Some(verb) => Button::with_text(verb).styled(theme::button_confirm()),
_ => Button::with_icon(theme::ICON_CONFIRM).styled(theme::button_confirm()),
};
self.button_cancel = Some(cancel);
self.button_confirm = confirm;
self
}
pub fn with_back_button(mut self) -> Self {
self.cancel_from_any_page = true;
self.button_prev = Button::with_icon(theme::ICON_BACK).initially_enabled(false);
self.button_cancel = Some(Button::with_icon(theme::ICON_BACK));
self
}
pub fn with_cancel_arrow(mut self) -> Self {
self.button_cancel = Some(Button::with_icon(theme::ICON_UP));
self
}
pub fn with_confirm_style(mut self, style: ButtonStyleSheet) -> Self {
self.button_confirm = self.button_confirm.styled(style);
self
}
pub fn with_swipe_left(mut self) -> Self {
self.swipe_left = true;
self
}
pub fn with_swipe_right(mut self) -> Self {
self.swipe_right = true;
self
}
fn setup_swipe(&mut self) {
self.swipe.allow_up = self.scrollbar.has_next_page();
self.swipe.allow_down = self.scrollbar.has_previous_page();
self.swipe.allow_left = self.swipe_left;
self.swipe.allow_right = self.swipe_right;
}
fn change_page(&mut self, ctx: &mut EventCtx, step: isize) {
// Advance scrollbar.
self.scrollbar.go_to_relative(step);
// Adjust the swipe parameters according to the scrollbar.
self.setup_swipe();
// Enable/disable prev button.
self.button_prev
.enable_if(ctx, self.scrollbar.has_previous_page());
// Change the page in the content, make sure it gets completely repainted and
// clear the background under it.
self.content.change_page(self.scrollbar.active_page);
self.content.request_complete_repaint(ctx);
self.pad.clear();
// Swipe has dimmed the screen, so fade back to normal backlight after the next
// paint.
self.fade.set(Some(theme::BACKLIGHT_NORMAL));
}
fn is_cancel_visible(&self) -> bool {
self.cancel_from_any_page || !self.scrollbar.has_previous_page()
}
/// Area for drawing loader (and black rectangle behind it). Can be outside
/// bounds as we repaint entire UI tree after hiding the loader.
const fn loader_area() -> Rect {
constant::screen()
.inset(theme::borders())
.inset(Insets::bottom(theme::BUTTON_HEIGHT + theme::BUTTON_SPACING))
}
fn handle_swipe(
&mut self,
ctx: &mut EventCtx,
event: Event,
) -> HandleResult<<Self as Component>::Msg> {
if let Some(swipe) = self.swipe.event(ctx, event) {
match swipe {
SwipeDirection::Up => {
// Scroll down, if possible.
return HandleResult::NextPage;
}
SwipeDirection::Down => {
// Scroll up, if possible.
return HandleResult::PrevPage;
}
SwipeDirection::Left if self.swipe_left => {
return HandleResult::Return(PageMsg::SwipeLeft);
}
SwipeDirection::Right if self.swipe_right => {
return HandleResult::Return(PageMsg::SwipeRight);
}
_ => {
// Ignore other directions.
}
}
}
HandleResult::Continue
}
fn handle_button(
&mut self,
ctx: &mut EventCtx,
event: Event,
) -> HandleResult<(Option<<Self as Component>::Msg>, Option<ButtonMsg>)> {
if self.scrollbar.has_next_page() {
if let Some(ButtonMsg::Clicked) = self.button_next.event(ctx, event) {
return HandleResult::NextPage;
}
} else {
let result = self.button_confirm.event(ctx, event);
match result {
Some(ButtonMsg::Clicked) => {
return HandleResult::Return((Some(PageMsg::Confirmed), result))
}
Some(_) => return HandleResult::Return((None, result)),
None => {}
}
}
if self.is_cancel_visible() {
if let Some(ButtonMsg::Clicked) = self.button_cancel.event(ctx, event) {
return HandleResult::Return((Some(PageMsg::Cancelled), None));
}
} else if let Some(ButtonMsg::Clicked) = self.button_prev.event(ctx, event) {
return HandleResult::PrevPage;
}
HandleResult::Continue
}
fn handle_hold(
&mut self,
ctx: &mut EventCtx,
event: Event,
button_msg: &Option<ButtonMsg>,
) -> HandleResult<<Self as Component>::Msg> {
let Some(loader) = &mut self.loader else {
return HandleResult::Continue;
};
let now = Instant::now();
if let Some(LoaderMsg::ShrunkCompletely) = loader.event(ctx, event) {
// Switch it to the initial state, so we stop painting it.
loader.reset();
// Re-draw the whole content tree.
self.content.request_complete_repaint(ctx);
// Loader overpainted our bounds, repaint entire screen from scratch.
ctx.request_repaint_root()
// This can be a result of an animation frame event, we should take
// care to not short-circuit here and deliver the event to the
// content as well.
}
match button_msg {
Some(ButtonMsg::Pressed) => {
loader.start_growing(ctx, now);
loader.pad.clear(); // Clear the remnants of the content.
}
Some(ButtonMsg::Released) => {
loader.start_shrinking(ctx, now);
}
Some(ButtonMsg::Clicked) => {
if loader.is_completely_grown(now) || animation_disabled() {
return HandleResult::Return(PageMsg::Confirmed);
} else {
loader.start_shrinking(ctx, now);
}
}
_ => {}
}
HandleResult::Continue
}
}
enum HandleResult<T> {
Return(T),
PrevPage,
NextPage,
Continue,
}
impl<T> Component for ButtonPage<T>
where
T: Paginate,
T: Component,
{
type Msg = PageMsg<T::Msg>;
fn place(&mut self, bounds: Rect) -> Rect {
let small_left_button = match (&self.button_cancel, &self.button_confirm) {
(None, _) => true,
(Some(cancel), confirm) => match (cancel.content(), confirm.content()) {
(ButtonContent::Text(t), _) => t.len() <= 4,
(ButtonContent::Icon(_), ButtonContent::Icon(_)) => false,
_ => true,
},
};
let layout = PageLayout::new(bounds, small_left_button);
self.pad.place(bounds);
self.swipe.place(bounds);
self.button_cancel.place(layout.button_left);
self.button_confirm.place(layout.button_right);
self.button_prev.place(layout.button_left);
self.button_next.place(layout.button_right);
self.scrollbar.place(layout.scrollbar);
// Layout the content. Try to fit it on a single page first, and reduce the area
// to make space for a scrollbar if it doesn't fit.
self.content.place(layout.content_single_page);
let page_count = {
let count = self.content.page_count();
if count > 1 {
self.content.place(layout.content);
self.content.page_count() // Make sure to re-count it with the
// new size.
} else {
count // Content fits on a single page.
}
};
if page_count == 1 && self.button_cancel.is_none() {
self.button_confirm.place(layout.button_both);
}
// Now that we finally have the page count, we can setup the scrollbar and the
// swiper.
self.scrollbar.set_count_and_active_page(page_count, 0);
self.setup_swipe();
self.loader.place(Self::loader_area());
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
ctx.set_page_count(self.scrollbar.page_count);
match self.handle_swipe(ctx, event) {
HandleResult::Return(r) => return Some(r),
HandleResult::PrevPage => {
self.change_page(ctx, -1);
return None;
}
HandleResult::NextPage => {
self.change_page(ctx, 1);
return None;
}
HandleResult::Continue => {}
}
if let Some(msg) = self.content.event(ctx, event) {
return Some(PageMsg::Content(msg));
}
let mut confirm_button_msg = None;
let mut button_result = None;
match self.handle_button(ctx, event) {
HandleResult::Return((Some(r), None)) => return Some(r),
HandleResult::Return((r, m)) => {
button_result = r;
confirm_button_msg = m;
}
HandleResult::PrevPage => {
self.change_page(ctx, -1);
return None;
}
HandleResult::NextPage => {
self.change_page(ctx, 1);
return None;
}
HandleResult::Continue => {}
}
if self.loader.is_some() {
return match self.handle_hold(ctx, event, &confirm_button_msg) {
HandleResult::Return(r) => Some(r),
HandleResult::Continue => None,
_ => unreachable!(),
};
}
button_result
}
fn paint(&mut self) {
self.pad.paint();
match &self.loader {
Some(l) if l.is_animating() => self.loader.paint(),
_ => {
self.content.paint();
if self.scrollbar.has_pages() {
self.scrollbar.paint();
}
}
}
if self.button_cancel.is_some() && self.is_cancel_visible() {
self.button_cancel.paint();
} else {
self.button_prev.paint();
}
if self.scrollbar.has_next_page() {
self.button_next.paint();
} else {
self.button_confirm.paint();
}
if let Some(val) = self.fade.take() {
// Note that this is blocking and takes some time.
display::fade_backlight(val);
}
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
self.pad.render(target);
match &self.loader {
Some(l) if l.is_animating() => self.loader.render(target),
_ => {
self.content.render(target);
if self.scrollbar.has_pages() {
self.scrollbar.render(target);
}
}
}
if self.button_cancel.is_some() && self.is_cancel_visible() {
self.button_cancel.render(target);
} else {
self.button_prev.render(target);
}
if self.scrollbar.has_next_page() {
self.button_next.render(target);
} else {
self.button_confirm.render(target);
}
if let Some(val) = self.fade.take() {
// Note that this is blocking and takes some time.
display::fade_backlight(val);
}
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
sink(self.pad.area);
self.scrollbar.bounds(sink);
self.content.bounds(sink);
self.button_cancel.bounds(sink);
self.button_confirm.bounds(sink);
self.button_prev.bounds(sink);
self.button_next.bounds(sink);
}
}
#[cfg(feature = "ui_debug")]
impl<T> crate::trace::Trace for ButtonPage<T>
where
T: crate::trace::Trace,
{
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("ButtonPage");
t.int("active_page", self.scrollbar.active_page as i64);
t.int("page_count", self.scrollbar.page_count as i64);
t.bool("hold", self.loader.is_some());
t.child("content", &self.content);
}
}
pub struct PageLayout {
/// Content when it fits on single page (no scrollbar).
pub content_single_page: Rect,
/// Content when multiple pages.
pub content: Rect,
/// Scroll bar when multiple pages.
pub scrollbar: Rect,
/// Controls displayed on last page.
pub button_left: Rect,
pub button_right: Rect,
pub button_both: Rect,
}
impl PageLayout {
const SCROLLBAR_WIDTH: i16 = 8;
const SCROLLBAR_SPACE: i16 = 5;
pub fn new(area: Rect, small_left_button: bool) -> Self {
let (area, button_both) = area.split_bottom(theme::BUTTON_HEIGHT);
let area = area.inset(Insets::bottom(theme::BUTTON_SPACING));
let (_space, content) = area.split_left(theme::CONTENT_BORDER);
let (content_single_page, _space) = content.split_right(theme::CONTENT_BORDER);
let (content, scrollbar) =
content.split_right(Self::SCROLLBAR_SPACE + Self::SCROLLBAR_WIDTH);
let (_space, scrollbar) = scrollbar.split_left(Self::SCROLLBAR_SPACE);
let width = if small_left_button {
theme::BUTTON_WIDTH
} else {
(button_both.width() - theme::BUTTON_SPACING) / 2
};
let (button_left, button_right) = button_both.split_left(width);
let button_right = button_right.inset(Insets::left(theme::BUTTON_SPACING));
Self {
content_single_page,
content,
scrollbar,
button_left,
button_right,
button_both,
}
}
}
#[cfg(test)]
mod tests {
use serde_json;
use crate::{
strutil::SkipPrefix,
trace::tests::trace,
ui::{
component::text::paragraphs::{Paragraph, Paragraphs},
event::TouchEvent,
geometry::Point,
model_mercury::{constant, theme},
},
};
use super::*;
const SCREEN: Rect = constant::screen().inset(theme::borders());
fn swipe(component: &mut impl Component, points: &[(i16, i16)]) {
let last = points.len().saturating_sub(1);
let mut first = true;
let mut ctx = EventCtx::new();
for (i, &(x, y)) in points.iter().enumerate() {
let p = Point::new(x, y);
let ev = if first {
TouchEvent::TouchStart(p)
} else if i == last {
TouchEvent::TouchEnd(p)
} else {
TouchEvent::TouchMove(p)
};
component.event(&mut ctx, Event::Touch(ev));
ctx.clear();
first = false;
}
}
fn swipe_up(component: &mut impl Component) {
swipe(component, &[(20, 100), (20, 60), (20, 20)])
}
fn swipe_down(component: &mut impl Component) {
swipe(component, &[(20, 20), (20, 60), (20, 100)])
}
#[test]
fn paragraphs_empty() {
let mut page = ButtonPage::new(Paragraphs::<[Paragraph<'static>; 0]>::new([]), theme::BG);
page.place(SCREEN);
let expected = serde_json::json!({
"component": "ButtonPage",
"active_page": 0,
"page_count": 1,
"content": {
"component": "Paragraphs",
"paragraphs": [],
},
"hold": false,
});
assert_eq!(trace(&page), expected);
swipe_up(&mut page);
assert_eq!(trace(&page), expected);
swipe_down(&mut page);
assert_eq!(trace(&page), expected);
}
#[test]
fn paragraphs_single() {
let mut page = ButtonPage::new(
Paragraphs::new([
Paragraph::new(
&theme::TEXT_NORMAL,
"This is the first paragraph and it should fit on the screen entirely.",
),
Paragraph::new(
&theme::TEXT_BOLD,
"Second, bold, paragraph should also fit on the screen whole I think.",
),
]),
theme::BG,
);
page.place(SCREEN);
let expected = serde_json::json!({
"component": "ButtonPage",
"active_page": 0,
"page_count": 1,
"content": {
"component": "Paragraphs",
"paragraphs": [
["This is the first", "\n", "paragraph and it should", "\n", "fit on the screen", "\n", "entirely."],
["Second, bold, paragraph", "\n", "should also fit on the", "\n", "screen whole I think."],
],
},
"hold": false,
});
assert_eq!(trace(&page), expected);
swipe_up(&mut page);
assert_eq!(trace(&page), expected);
swipe_down(&mut page);
assert_eq!(trace(&page), expected);
}
#[test]
fn paragraphs_one_long() {
let mut page = ButtonPage::new(
Paragraphs::new(
Paragraph::new(
&theme::TEXT_BOLD,
"This is somewhat long paragraph that goes on and on and on and on and on and will definitely not fit on just a single screen. You have to swipe a bit to see all the text it contains I guess. There's just so much letters in it.",
)
),
theme::BG,
);
page.place(SCREEN);
let first_page = serde_json::json!({
"component": "ButtonPage",
"active_page": 0,
"page_count": 2,
"content": {
"component": "Paragraphs",
"paragraphs": [
[
"This is somewhat long", "\n",
"paragraph that goes on", "\n",
"and on and on and on and", "\n",
"on and will definitely not", "\n",
"fit on just a single", "\n",
"screen. You have to", "\n",
"swipe a bit to see all the", "\n",
"text it contains I guess.", "...",
],
],
},
"hold": false,
});
let second_page = serde_json::json!({
"component": "ButtonPage",
"active_page": 1,
"page_count": 2,
"content": {
"component": "Paragraphs",
"paragraphs": [
["There's just so much", "\n", "letters in it."],
],
},
"hold": false,
});
assert_eq!(trace(&page), first_page);
swipe_down(&mut page);
assert_eq!(trace(&page), first_page);
swipe_up(&mut page);
assert_eq!(trace(&page), second_page);
swipe_up(&mut page);
assert_eq!(trace(&page), second_page);
swipe_down(&mut page);
assert_eq!(trace(&page), first_page);
}
#[test]
fn paragraphs_three_long() {
let mut page = ButtonPage::new(
Paragraphs::new([
Paragraph::new(
&theme::TEXT_BOLD,
"This paragraph is using a bold font. It doesn't need to be all that long.",
),
Paragraph::new(
&theme::TEXT_MONO,
"And this one is using MONO. Monospace is nice for numbers, they have the same width and can be scanned quickly. Even if they span several pages or something.",
),
Paragraph::new(
&theme::TEXT_BOLD,
"Let's add another one for a good measure. This one should overflow all the way to the third page with a bit of luck.",
),
]),
theme::BG,
);
page.place(SCREEN);
let first_page = serde_json::json!({
"component": "ButtonPage",
"active_page": 0,
"page_count": 3,
"content": {
"component": "Paragraphs",
"paragraphs": [
[
"This paragraph is using a", "\n",
"bold font. It doesn't need", "\n",
"to be all that long.",
],
[
"And this one is u", "\n",
"sing MONO. Monosp", "\n",
"ace is nice for n", "\n",
"umbers, they", "...",
],
],
},
"hold": false,
});
let second_page = serde_json::json!({
"component": "ButtonPage",
"active_page": 1,
"page_count": 3,
"content": {
"component": "Paragraphs",
"paragraphs": [
[
"...", "have the same", "\n",
"width and can be", "\n",
"scanned quickly.", "\n",
"Even if they span", "\n",
"several pages or", "\n",
"something.",
],
[
"Let's add another one", "...",
],
],
},
"hold": false,
});
let third_page = serde_json::json!({
"component": "ButtonPage",
"active_page": 2,
"page_count": 3,
"content": {
"component": "Paragraphs",
"paragraphs": [
[
"for a good measure. This", "\n",
"one should overflow all", "\n",
"the way to the third page", "\n",
"with a bit of luck.",
],
],
},
"hold": false,
});
assert_eq!(trace(&page), first_page);
swipe_down(&mut page);
assert_eq!(trace(&page), first_page);
swipe_up(&mut page);
assert_eq!(trace(&page), second_page);
swipe_up(&mut page);
assert_eq!(trace(&page), third_page);
swipe_up(&mut page);
assert_eq!(trace(&page), third_page);
swipe_down(&mut page);
assert_eq!(trace(&page), second_page);
swipe_down(&mut page);
assert_eq!(trace(&page), first_page);
swipe_down(&mut page);
assert_eq!(trace(&page), first_page);
}
#[test]
fn paragraphs_hard_break() {
let mut page = ButtonPage::new(
Paragraphs::new([
Paragraph::new(&theme::TEXT_NORMAL, "Short one.").break_after(),
Paragraph::new(&theme::TEXT_NORMAL, "Short two.").break_after(),
Paragraph::new(&theme::TEXT_NORMAL, "Short three.").break_after(),
]),
theme::BG,
);
page.place(SCREEN);
let first_page = serde_json::json!({
"component": "ButtonPage",
"active_page": 0,
"page_count": 3,
"content": {
"component": "Paragraphs",
"paragraphs": [
[
"Short one.",
],
],
},
"hold": false,
});
let second_page = serde_json::json!({
"component": "ButtonPage",
"active_page": 1,
"page_count": 3,
"content": {
"component": "Paragraphs",
"paragraphs": [
[
"Short two.",
],
],
},
"hold": false,
});
let third_page = serde_json::json!({
"component": "ButtonPage",
"active_page": 2,
"page_count": 3,
"content": {
"component": "Paragraphs",
"paragraphs": [
[
"Short three.",
],
],
},
"hold": false,
});
assert_eq!(trace(&page), first_page);
swipe_up(&mut page);
assert_eq!(trace(&page), second_page);
swipe_up(&mut page);
assert_eq!(trace(&page), third_page);
swipe_up(&mut page);
assert_eq!(trace(&page), third_page);
}
}

@ -0,0 +1,165 @@
use core::mem;
use crate::{
strutil::TString,
ui::{
component::{
base::ComponentExt,
paginated::Paginate,
text::paragraphs::{Paragraph, Paragraphs},
Child, Component, Event, EventCtx, Label, Never, Pad,
},
display::{self, Font},
geometry::{Insets, Offset, Rect},
model_mercury::constant,
shape,
shape::Renderer,
util::animation_disabled,
},
};
use super::theme;
pub struct Progress {
title: Child<Label<'static>>,
value: u16,
loader_y_offset: i16,
indeterminate: bool,
description: Child<Paragraphs<Paragraph<'static>>>,
description_pad: Pad,
}
impl Progress {
const AREA: Rect = constant::screen().inset(theme::borders());
pub fn new(
title: TString<'static>,
indeterminate: bool,
description: TString<'static>,
) -> Self {
Self {
title: Label::centered(title, theme::label_progress()).into_child(),
value: 0,
loader_y_offset: 0,
indeterminate,
description: Paragraphs::new(
Paragraph::new(&theme::TEXT_NORMAL, description).centered(),
)
.into_child(),
description_pad: Pad::with_background(theme::BG),
}
}
}
impl Component for Progress {
type Msg = Never;
fn place(&mut self, _bounds: Rect) -> Rect {
let description_lines = 1 + self
.description
.inner()
.inner()
.content()
.map(|t| t.chars().filter(|c| *c == '\n').count() as i16);
let (title, rest) = Self::AREA.split_top(self.title.inner().max_size().y);
let (loader, description) =
rest.split_bottom(Font::NORMAL.line_height() * description_lines);
let loader = loader.inset(Insets::top(theme::CONTENT_BORDER));
self.title.place(title);
self.loader_y_offset = loader.center().y - constant::screen().center().y;
self.description.place(description);
self.description_pad.place(description);
Self::AREA
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
if let Event::Progress(new_value, new_description) = event {
if mem::replace(&mut self.value, new_value) != new_value {
if !animation_disabled() {
ctx.request_paint();
}
self.description.mutate(ctx, |ctx, para| {
if para.inner_mut().content() != &new_description {
para.inner_mut().update(new_description);
para.change_page(0); // Recompute bounding box.
ctx.request_paint();
self.description_pad.clear();
}
});
}
}
None
}
fn paint(&mut self) {
self.title.paint();
if self.indeterminate {
display::loader_indeterminate(
self.value,
self.loader_y_offset,
theme::FG,
theme::BG,
None,
);
} else {
display::loader(self.value, self.loader_y_offset, theme::FG, theme::BG, None);
}
self.description_pad.paint();
self.description.paint();
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
self.title.render(target);
let center = constant::screen().center() + Offset::y(self.loader_y_offset);
let active_color = theme::FG;
let background_color = theme::BG;
let inactive_color = background_color.blend(active_color, 85);
let (start, end) = if self.indeterminate {
let start = (self.value - 100) % 1000;
let end = (self.value + 100) % 1000;
let start = ((start as i32 * 8 * shape::PI4 as i32) / 1000) as i16;
let end = ((end as i32 * 8 * shape::PI4 as i32) / 1000) as i16;
(start, end)
} else {
let end = ((self.value as i32 * 8 * shape::PI4 as i32) / 1000) as i16;
(0, end)
};
shape::Circle::new(center, constant::LOADER_OUTER)
.with_bg(inactive_color)
.render(target);
shape::Circle::new(center, constant::LOADER_OUTER)
.with_bg(active_color)
.with_start_angle(start)
.with_end_angle(end)
.render(target);
shape::Circle::new(center, constant::LOADER_INNER + 2)
.with_bg(active_color)
.render(target);
shape::Circle::new(center, constant::LOADER_INNER)
.with_bg(background_color)
.render(target);
self.description_pad.render(target);
self.description.render(target);
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
sink(Self::AREA);
self.title.bounds(sink);
self.description.bounds(sink);
}
}
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for Progress {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("Progress");
}
}

@ -0,0 +1,142 @@
use crate::{
time::Duration,
ui::{
component::{Component, Event, EventCtx},
display::Color,
geometry::{Alignment2D, Offset, Rect},
shape,
shape::Renderer,
},
};
use super::{theme, Button, ButtonContent, ButtonMsg};
/// Component requesting an action from a user. Most typically embedded as a
/// content of a Frame and promptin "Tap to confirm" or "Hold to XYZ".
#[derive(Clone)]
pub struct PromptScreen {
area: Rect,
button: Button,
circle_color: Color,
circle_pad_color: Color,
circle_inner_color: Color,
dismiss_type: DismissType,
}
#[derive(Clone)]
enum DismissType {
Tap,
Hold,
}
impl PromptScreen {
pub fn new_hold_to_confirm() -> Self {
let icon = theme::ICON_SIGN;
let button = Button::new(ButtonContent::Icon(icon))
.styled(theme::button_default())
.with_long_press(Duration::from_secs(2));
Self {
area: Rect::zero(),
circle_color: theme::GREEN,
circle_pad_color: theme::GREY_EXTRA_DARK,
circle_inner_color: theme::GREEN_LIGHT,
dismiss_type: DismissType::Hold,
button,
}
}
pub fn new_tap_to_confirm() -> Self {
let icon = theme::ICON_SIMPLE_CHECKMARK;
let button = Button::new(ButtonContent::Icon(icon)).styled(theme::button_default());
Self {
area: Rect::zero(),
circle_color: theme::GREEN,
circle_inner_color: theme::GREEN,
circle_pad_color: theme::GREY_EXTRA_DARK,
dismiss_type: DismissType::Tap,
button,
}
}
pub fn new_tap_to_cancel() -> Self {
let icon = theme::ICON_SIMPLE_CHECKMARK;
let button = Button::new(ButtonContent::Icon(icon)).styled(theme::button_default());
Self {
area: Rect::zero(),
circle_color: theme::ORANGE_LIGHT,
circle_inner_color: theme::ORANGE_LIGHT,
circle_pad_color: theme::GREY_EXTRA_DARK,
dismiss_type: DismissType::Tap,
button,
}
}
}
impl Component for PromptScreen {
type Msg = ();
fn place(&mut self, bounds: Rect) -> Rect {
self.area = bounds;
self.button.place(Rect::snap(
self.area.center(),
Offset::uniform(55),
Alignment2D::CENTER,
));
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
let btn_msg = self.button.event(ctx, event);
match (&self.dismiss_type, btn_msg) {
(DismissType::Tap, Some(ButtonMsg::Clicked)) => {
return Some(());
}
(DismissType::Hold, Some(ButtonMsg::LongPressed)) => {
return Some(());
}
_ => (),
}
None
}
fn paint(&mut self) {
todo!()
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
shape::Circle::new(self.area.center(), 70)
.with_fg(self.circle_pad_color)
.with_bg(theme::BLACK)
.with_thickness(20)
.render(target);
shape::Circle::new(self.area.center(), 50)
.with_fg(self.circle_color)
.with_bg(theme::BLACK)
.with_thickness(2)
.render(target);
shape::Circle::new(self.area.center(), 48)
.with_fg(self.circle_pad_color)
.with_bg(theme::BLACK)
.with_thickness(8)
.render(target);
matches!(self.dismiss_type, DismissType::Hold).then(|| {
shape::Circle::new(self.area.center(), 40)
.with_fg(self.circle_inner_color)
.with_bg(theme::BLACK)
.with_thickness(2)
.render(target);
});
self.button.render(target);
}
}
#[cfg(feature = "micropython")]
impl crate::ui::flow::Swipable for PromptScreen {}
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for PromptScreen {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("StatusScreen");
t.child("button", &self.button);
}
}

@ -96,7 +96,6 @@ impl Component for ResultFooter<'_> {
// footer text
self.text.paint();
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
// divider line
let bar = Rect::from_center_and_size(

@ -0,0 +1,179 @@
use crate::ui::{
component::{Component, Event, EventCtx, Never},
display::toif::Icon,
geometry::{Alignment2D, Axis, LinearPlacement, Offset, Rect},
shape,
shape::Renderer,
};
use super::theme;
pub struct ScrollBar {
area: Rect,
layout: LinearPlacement,
pub page_count: usize,
pub active_page: usize,
}
impl ScrollBar {
pub const DOT_SIZE: i16 = 8;
/// If there's more pages than this value then smaller dots are used at the
/// beginning/end of the scrollbar to denote the fact.
const MAX_DOTS: usize = 7;
/// Center to center.
const DOT_INTERVAL: i16 = 18;
pub fn new(axis: Axis) -> Self {
let layout = LinearPlacement::new(axis);
Self {
area: Rect::zero(),
layout: layout.align_at_center().with_spacing(Self::DOT_INTERVAL),
page_count: 0,
active_page: 0,
}
}
pub fn vertical() -> Self {
Self::new(Axis::Vertical)
}
pub fn horizontal() -> Self {
Self::new(Axis::Horizontal)
}
pub fn set_count_and_active_page(&mut self, page_count: usize, active_page: usize) {
self.page_count = page_count;
self.active_page = active_page;
}
pub fn has_pages(&self) -> bool {
self.page_count > 1
}
pub fn has_next_page(&self) -> bool {
self.active_page < self.page_count - 1
}
pub fn has_previous_page(&self) -> bool {
self.active_page > 0
}
pub fn go_to_next_page(&mut self) {
self.go_to_relative(1)
}
pub fn go_to_previous_page(&mut self) {
self.go_to_relative(-1)
}
pub fn go_to_relative(&mut self, step: isize) {
self.go_to(
(self.active_page as isize + step).clamp(0, self.page_count as isize - 1) as usize,
);
}
pub fn go_to(&mut self, active_page: usize) {
self.active_page = active_page;
}
}
impl Component for ScrollBar {
type Msg = Never;
fn event(&mut self, _ctx: &mut EventCtx, _event: Event) -> Option<Self::Msg> {
None
}
fn paint(&mut self) {
fn dotsize(distance: usize, nhidden: usize) -> Icon {
match (nhidden.saturating_sub(distance)).min(2 - distance) {
0 => theme::DOT_INACTIVE,
1 => theme::DOT_INACTIVE_HALF,
_ => theme::DOT_INACTIVE_QUARTER,
}
}
// Number of visible dots.
let num_shown = self.page_count.min(Self::MAX_DOTS);
// Page indices corresponding to the first (and last) dot.
let first_shown = self
.active_page
.saturating_sub(Self::MAX_DOTS / 2)
.min(self.page_count.saturating_sub(Self::MAX_DOTS));
let last_shown = first_shown + num_shown - 1;
let mut cursor = self.area.center()
- Offset::on_axis(
self.layout.axis,
Self::DOT_INTERVAL * (num_shown.saturating_sub(1) as i16) / 2,
);
for i in first_shown..(last_shown + 1) {
let icon = if i == self.active_page {
theme::DOT_ACTIVE
} else if i <= first_shown + 1 {
let before_first_shown = first_shown;
dotsize(i - first_shown, before_first_shown)
} else if i >= last_shown - 1 {
let after_last_shown = self.page_count - 1 - last_shown;
dotsize(last_shown - i, after_last_shown)
} else {
theme::DOT_INACTIVE
};
icon.draw(cursor, Alignment2D::CENTER, theme::FG, theme::BG);
cursor = cursor + Offset::on_axis(self.layout.axis, Self::DOT_INTERVAL);
}
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
fn dotsize(distance: usize, nhidden: usize) -> Icon {
match (nhidden.saturating_sub(distance)).min(2 - distance) {
0 => theme::DOT_INACTIVE,
1 => theme::DOT_INACTIVE_HALF,
_ => theme::DOT_INACTIVE_QUARTER,
}
}
// Number of visible dots.
let num_shown = self.page_count.min(Self::MAX_DOTS);
// Page indices corresponding to the first (and last) dot.
let first_shown = self
.active_page
.saturating_sub(Self::MAX_DOTS / 2)
.min(self.page_count.saturating_sub(Self::MAX_DOTS));
let last_shown = first_shown + num_shown - 1;
let mut cursor = self.area.center()
- Offset::on_axis(
self.layout.axis,
Self::DOT_INTERVAL * (num_shown.saturating_sub(1) as i16) / 2,
);
for i in first_shown..(last_shown + 1) {
let icon = if i == self.active_page {
theme::DOT_ACTIVE
} else if i <= first_shown + 1 {
let before_first_shown = first_shown;
dotsize(i - first_shown, before_first_shown)
} else if i >= last_shown - 1 {
let after_last_shown = self.page_count - 1 - last_shown;
dotsize(last_shown - i, after_last_shown)
} else {
theme::DOT_INACTIVE
};
shape::ToifImage::new(cursor, icon.toif)
.with_align(Alignment2D::CENTER)
.with_fg(theme::FG)
.render(target);
cursor = cursor + Offset::on_axis(self.layout.axis, Self::DOT_INTERVAL);
}
}
fn place(&mut self, bounds: Rect) -> Rect {
self.area = bounds;
bounds
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
sink(self.area);
}
}

@ -0,0 +1,158 @@
use super::theme;
use crate::{
strutil::TString,
translations::TR,
ui::{
component::{Component, Event, EventCtx, PageMsg, Paginate},
constant::SPACING,
geometry::{Alignment, Alignment2D, Insets, Offset, Rect},
model_mercury::component::{Footer, Swipe, SwipeDirection},
shape,
shape::Renderer,
},
};
use heapless::{String, Vec};
const MAX_WORDS: usize = 33; // super-shamir has 33 words, all other have less
/// Component showing mnemonic/share words during backup procedure. Model T3T1
/// contains one word per screen. A user is instructed to swipe up/down to see
/// next/previous word.
pub struct ShareWords<'a> {
area: Rect,
share_words: Vec<TString<'a>, MAX_WORDS>,
page_index: usize,
/// Area reserved for a shown word from mnemonic/share
area_word: Rect,
/// TODO: review when swipe concept done for T3T1
swipe: Swipe,
/// Footer component for instructions and word counting
footer: Footer<'static>,
}
impl<'a> ShareWords<'a> {
const AREA_WORD_HEIGHT: i16 = 91;
pub fn new(share_words: Vec<TString<'a>, MAX_WORDS>) -> Self {
Self {
area: Rect::zero(),
share_words,
page_index: 0,
area_word: Rect::zero(),
swipe: Swipe::new().up().down(),
footer: Footer::new(TR::instructions__swipe_up),
}
}
fn is_final_page(&self) -> bool {
self.page_index == self.share_words.len() - 1
}
}
impl<'a> Component for ShareWords<'a> {
type Msg = PageMsg<()>;
fn place(&mut self, bounds: Rect) -> Rect {
self.area = bounds;
let used_area = bounds
.inset(Insets::sides(SPACING))
.inset(Insets::bottom(SPACING));
self.area_word = Rect::snap(
used_area.center(),
Offset::new(used_area.width(), ShareWords::AREA_WORD_HEIGHT),
Alignment2D::CENTER,
);
self.footer
.place(used_area.split_bottom(Footer::HEIGHT_SIMPLE).1);
self.swipe.place(bounds); // Swipe possible on the whole screen area
self.area
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
ctx.set_page_count(self.share_words.len());
let swipe = self.swipe.event(ctx, event);
match swipe {
Some(SwipeDirection::Up) => {
if self.is_final_page() {
return Some(PageMsg::Confirmed);
}
self.change_page(self.page_index + 1);
ctx.request_paint();
}
Some(SwipeDirection::Down) => {
self.change_page(self.page_index.saturating_sub(1));
ctx.request_paint();
}
_ => (),
}
None
}
fn paint(&mut self) {
// TODO: remove when ui-t3t1 done
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
// corner highlights
let (_, top_right_shape, bot_left_shape, bot_right_shape) =
shape::CornerHighlight::from_rect(self.area_word, theme::GREY_DARK, theme::BG);
top_right_shape.render(target);
bot_left_shape.render(target);
bot_right_shape.render(target);
// the ordinal number of the current word
let ordinal_val = self.page_index as u8 + 1;
let ordinal_pos = self.area_word.top_left()
+ Offset::y(
theme::TEXT_SUB_GREY_LIGHT
.text_font
.visible_text_height("1"),
);
let ordinal = build_string!(3, inttostr!(ordinal_val), ".");
shape::Text::new(ordinal_pos, &ordinal)
.with_font(theme::TEXT_SUB_GREY_LIGHT.text_font)
.with_fg(theme::GREY)
.render(target);
// the share word
let word = self.share_words[self.page_index];
let word_baseline = self.area_word.center()
+ Offset::y(theme::TEXT_SUPER.text_font.visible_text_height("A") / 2);
word.map(|w| {
shape::Text::new(word_baseline, w)
.with_font(theme::TEXT_SUPER.text_font)
.with_align(Alignment::Center)
.render(target);
});
// footer with instructions
self.footer.render(target);
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, _sink: &mut dyn FnMut(Rect)) {}
}
impl<'a> Paginate for ShareWords<'a> {
fn page_count(&mut self) -> usize {
self.share_words.len()
}
fn change_page(&mut self, active_page: usize) {
self.page_index = active_page;
}
}
#[cfg(feature = "ui_debug")]
impl<'a> crate::trace::Trace for ShareWords<'a> {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("ShareWords");
let word = &self.share_words[self.page_index];
let content =
word.map(|w| build_string!(50, inttostr!(self.page_index as u8 + 1), ". ", w, "\n"));
t.string("screen_content", content.as_str().into());
}
}

@ -0,0 +1,200 @@
use crate::ui::{
component::{base::ComponentExt, Component, Event, EventCtx, Pad, PageMsg, Paginate},
display::{self, Color},
geometry::{Axis, Insets, Rect},
shape::Renderer,
};
use super::{theme, ScrollBar, Swipe, SwipeDirection};
use core::cell::Cell;
const SCROLLBAR_HEIGHT: i16 = 18;
const SCROLLBAR_BORDER: i16 = 4;
pub struct SimplePage<T> {
content: T,
pad: Pad,
swipe: Swipe,
scrollbar: ScrollBar,
axis: Axis,
swipe_right_to_go_back: bool,
fade: Cell<Option<u16>>,
}
impl<T> SimplePage<T>
where
T: Paginate,
T: Component,
{
pub fn new(content: T, axis: Axis, background: Color) -> Self {
Self {
content,
swipe: Swipe::new(),
pad: Pad::with_background(background),
scrollbar: ScrollBar::new(axis),
axis,
swipe_right_to_go_back: false,
fade: Cell::new(None),
}
}
pub fn horizontal(content: T, background: Color) -> Self {
Self::new(content, Axis::Horizontal, background)
}
pub fn vertical(content: T, background: Color) -> Self {
Self::new(content, Axis::Vertical, background)
}
pub fn with_swipe_right_to_go_back(mut self) -> Self {
self.swipe_right_to_go_back = true;
self
}
pub fn inner(&self) -> &T {
&self.content
}
fn setup_swipe(&mut self) {
if self.is_horizontal() {
self.swipe.allow_left = self.scrollbar.has_next_page();
self.swipe.allow_right =
self.scrollbar.has_previous_page() || self.swipe_right_to_go_back;
} else {
self.swipe.allow_up = self.scrollbar.has_next_page();
self.swipe.allow_down = self.scrollbar.has_previous_page();
self.swipe.allow_right = self.swipe_right_to_go_back;
}
}
fn change_page(&mut self, ctx: &mut EventCtx, step: isize) {
// Advance scrollbar.
self.scrollbar.go_to_relative(step);
// Adjust the swipe parameters according to the scrollbar.
self.setup_swipe();
// Change the page in the content, make sure it gets completely repainted and
// clear the background under it.
self.content.change_page(self.scrollbar.active_page);
self.content.request_complete_repaint(ctx);
self.pad.clear();
// Swipe has dimmed the screen, so fade back to normal backlight after the next
// paint.
self.fade.set(Some(theme::BACKLIGHT_NORMAL));
}
fn is_horizontal(&self) -> bool {
matches!(self.axis, Axis::Horizontal)
}
}
impl<T> Component for SimplePage<T>
where
T: Paginate,
T: Component,
{
type Msg = PageMsg<T::Msg>;
fn place(&mut self, bounds: Rect) -> Rect {
self.swipe.place(bounds);
let (content, scrollbar) = if self.is_horizontal() {
bounds.split_bottom(SCROLLBAR_HEIGHT + SCROLLBAR_BORDER)
} else {
bounds.split_right(SCROLLBAR_HEIGHT + SCROLLBAR_BORDER)
};
self.content.place(bounds);
if self.content.page_count() > 1 {
self.pad.place(content);
self.content.place(content);
} else {
self.pad.place(bounds);
}
if self.is_horizontal() {
self.scrollbar
.place(scrollbar.inset(Insets::bottom(SCROLLBAR_BORDER)));
} else {
self.scrollbar
.place(scrollbar.inset(Insets::right(SCROLLBAR_BORDER)));
}
self.scrollbar
.set_count_and_active_page(self.content.page_count(), 0);
self.setup_swipe();
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
ctx.set_page_count(self.scrollbar.page_count);
if let Some(swipe) = self.swipe.event(ctx, event) {
match (swipe, self.axis) {
(SwipeDirection::Left, Axis::Horizontal) | (SwipeDirection::Up, Axis::Vertical) => {
self.change_page(ctx, 1);
return None;
}
(SwipeDirection::Right, _)
if self.swipe_right_to_go_back && self.scrollbar.active_page == 0 =>
{
return Some(PageMsg::Cancelled);
}
(SwipeDirection::Right, Axis::Horizontal)
| (SwipeDirection::Down, Axis::Vertical) => {
self.change_page(ctx, -1);
return None;
}
_ => {
// Ignore other directions.
}
}
}
self.content.event(ctx, event).map(PageMsg::Content)
}
fn paint(&mut self) {
self.pad.paint();
self.content.paint();
if self.scrollbar.has_pages() {
self.scrollbar.paint();
}
if let Some(val) = self.fade.take() {
// Note that this is blocking and takes some time.
display::fade_backlight(val);
}
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
self.pad.render(target);
self.content.render(target);
if self.scrollbar.has_pages() {
self.scrollbar.render(target);
}
if let Some(val) = self.fade.take() {
// Note that this is blocking and takes some time.
display::fade_backlight(val);
}
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
sink(self.pad.area);
self.scrollbar.bounds(sink);
self.content.bounds(sink);
}
}
#[cfg(feature = "ui_debug")]
impl<T> crate::trace::Trace for SimplePage<T>
where
T: crate::trace::Trace,
{
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("SimplePage");
t.int("active_page", self.scrollbar.active_page as i64);
t.int("page_count", self.scrollbar.page_count as i64);
t.child("content", &self.content);
}
}

@ -0,0 +1,119 @@
use crate::ui::{
component::{Component, Event, EventCtx, Timeout},
display::{Color, Icon},
geometry::{Alignment2D, Rect},
shape,
shape::Renderer,
};
use super::{theme, Swipe, SwipeDirection};
/// Component showing status of an operation. Most typically embedded as a
/// content of a Frame and showing success (checkmark with a circle around).
pub struct StatusScreen {
area: Rect,
icon: Icon,
icon_color: Color,
circle_color: Color,
dismiss_type: DismissType,
}
enum DismissType {
SwipeUp(Swipe),
Timeout(Timeout),
}
const TIMEOUT_MS: u32 = 2000;
impl StatusScreen {
fn new(icon: Icon, icon_color: Color, circle_color: Color, dismiss_style: DismissType) -> Self {
Self {
area: Rect::zero(),
icon,
icon_color,
circle_color,
dismiss_type: dismiss_style,
}
}
pub fn new_success() -> Self {
Self::new(
theme::ICON_SIMPLE_CHECKMARK,
theme::GREEN_LIME,
theme::GREEN_LIGHT,
DismissType::SwipeUp(Swipe::new().up()),
)
}
pub fn new_success_timeout() -> Self {
Self::new(
theme::ICON_SIMPLE_CHECKMARK,
theme::GREEN_LIME,
theme::GREEN_LIGHT,
DismissType::Timeout(Timeout::new(TIMEOUT_MS)),
)
}
pub fn new_neutral() -> Self {
Self::new(
theme::ICON_SIMPLE_CHECKMARK,
theme::GREY_EXTRA_LIGHT,
theme::GREY_DARK,
DismissType::SwipeUp(Swipe::new().up()),
)
}
}
impl Component for StatusScreen {
type Msg = ();
fn place(&mut self, bounds: Rect) -> Rect {
self.area = bounds;
if let DismissType::SwipeUp(swipe) = &mut self.dismiss_type {
swipe.place(bounds);
}
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
match self.dismiss_type {
DismissType::SwipeUp(ref mut swipe) => {
let swipe_dir = swipe.event(ctx, event);
match swipe_dir {
Some(SwipeDirection::Up) => return Some(()),
_ => (),
}
}
DismissType::Timeout(ref mut timeout) => {
if let Some(_) = timeout.event(ctx, event) {
return Some(());
}
}
}
None
}
fn paint(&mut self) {
todo!()
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
shape::Circle::new(self.area.center(), 40)
.with_fg(self.circle_color)
.with_bg(theme::BLACK)
.with_thickness(2)
.render(target);
shape::ToifImage::new(self.area.center(), self.icon.toif)
.with_align(Alignment2D::CENTER)
.with_fg(self.icon_color)
.render(target);
}
}
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for StatusScreen {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("StatusScreen");
}
}

@ -0,0 +1,160 @@
use crate::ui::{
component::{Component, Event, EventCtx},
display,
event::TouchEvent,
geometry::{Point, Rect},
shape::Renderer,
};
use super::theme;
pub use crate::ui::component::SwipeDirection;
pub struct Swipe {
pub area: Rect,
pub allow_up: bool,
pub allow_down: bool,
pub allow_left: bool,
pub allow_right: bool,
backlight_start: u16,
backlight_end: u16,
origin: Option<Point>,
}
impl Swipe {
const DISTANCE: i32 = 120;
const THRESHOLD: f32 = 0.3;
pub fn new() -> Self {
Self {
area: Rect::zero(),
allow_up: false,
allow_down: false,
allow_left: false,
allow_right: false,
backlight_start: theme::BACKLIGHT_NORMAL,
backlight_end: theme::BACKLIGHT_NONE,
origin: None,
}
}
pub fn vertical() -> Self {
Self::new().up().down()
}
pub fn horizontal() -> Self {
Self::new().left().right()
}
pub fn up(mut self) -> Self {
self.allow_up = true;
self
}
pub fn down(mut self) -> Self {
self.allow_down = true;
self
}
pub fn left(mut self) -> Self {
self.allow_left = true;
self
}
pub fn right(mut self) -> Self {
self.allow_right = true;
self
}
fn is_active(&self) -> bool {
self.allow_up || self.allow_down || self.allow_left || self.allow_right
}
fn ratio(&self, dist: i16) -> f32 {
(dist as f32 / Self::DISTANCE as f32).min(1.0)
}
fn backlight(&self, ratio: f32) {
let start = self.backlight_start as f32;
let end = self.backlight_end as f32;
let value = start + ratio * (end - start);
display::set_backlight(value as u16);
}
}
impl Component for Swipe {
type Msg = SwipeDirection;
fn place(&mut self, bounds: Rect) -> Rect {
self.area = bounds;
self.area
}
fn event(&mut self, _ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
if !self.is_active() {
return None;
}
match (event, self.origin) {
(Event::Touch(TouchEvent::TouchStart(pos)), _) if self.area.contains(pos) => {
// Mark the starting position of this touch.
self.origin.replace(pos);
}
(Event::Touch(TouchEvent::TouchMove(pos)), Some(origin)) => {
// Consider our allowed directions and the touch distance and modify the display
// backlight accordingly.
let ofs = pos - origin;
let abs = ofs.abs();
if abs.x > abs.y && (self.allow_left || self.allow_right) {
// Horizontal direction.
if (ofs.x < 0 && self.allow_left) || (ofs.x > 0 && self.allow_right) {
self.backlight(self.ratio(abs.x));
}
} else if abs.x < abs.y && (self.allow_up || self.allow_down) {
// Vertical direction.
if (ofs.y < 0 && self.allow_up) || (ofs.y > 0 && self.allow_down) {
self.backlight(self.ratio(abs.y));
}
};
}
(Event::Touch(TouchEvent::TouchEnd(pos)), Some(origin)) => {
// Touch interaction is over, reset the position.
self.origin.take();
// Compare the touch distance with our allowed directions and determine if it
// constitutes a valid swipe.
let ofs = pos - origin;
let abs = ofs.abs();
if abs.x > abs.y && (self.allow_left || self.allow_right) {
// Horizontal direction.
if self.ratio(abs.x) >= Self::THRESHOLD {
if ofs.x < 0 && self.allow_left {
return Some(SwipeDirection::Left);
} else if ofs.x > 0 && self.allow_right {
return Some(SwipeDirection::Right);
}
}
} else if abs.x < abs.y && (self.allow_up || self.allow_down) {
// Vertical direction.
if self.ratio(abs.y) >= Self::THRESHOLD {
if ofs.y < 0 && self.allow_up {
return Some(SwipeDirection::Up);
} else if ofs.y > 0 && self.allow_down {
return Some(SwipeDirection::Down);
}
}
};
// Swipe did not happen, reset the backlight.
self.backlight(0.0);
}
_ => {
// Do nothing.
}
}
None
}
fn paint(&mut self) {}
fn render<'s>(&'s self, _target: &mut impl Renderer<'s>) {}
}

@ -0,0 +1,76 @@
use crate::ui::{
component::{Component, Event, EventCtx},
geometry::Rect,
shape::Renderer,
};
use super::{Swipe, SwipeDirection};
/// Wrapper component adding "swipe up" handling to `content`.
pub struct SwipeUpScreen<T> {
content: T,
swipe: Swipe,
}
pub enum SwipeUpScreenMsg<T> {
Swiped,
Content(T),
}
impl<T> SwipeUpScreen<T>
where
T: Component,
{
pub fn new(content: T) -> Self {
Self {
content,
swipe: Swipe::new().up(),
}
}
}
impl<T> Component for SwipeUpScreen<T>
where
T: Component,
{
type Msg = SwipeUpScreenMsg<T::Msg>;
fn place(&mut self, bounds: Rect) -> Rect {
self.swipe.place(bounds);
self.content.place(bounds);
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
if let Some(SwipeDirection::Up) = self.swipe.event(ctx, event) {
return Some(SwipeUpScreenMsg::Swiped);
}
self.content
.event(ctx, event)
.map(SwipeUpScreenMsg::Content)
}
fn paint(&mut self) {
todo!()
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
self.content.render(target);
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
self.content.bounds(sink);
}
}
#[cfg(feature = "ui_debug")]
impl<T> crate::trace::Trace for SwipeUpScreen<T>
where
T: crate::trace::Trace,
{
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("SwipeUpScreen");
t.child("content", &self.content);
}
}

@ -0,0 +1,156 @@
use heapless::Vec;
use super::theme;
use crate::{
strutil::TString,
ui::{
component::{base::Component, Event, EventCtx},
display::Icon,
geometry::Rect,
model_mercury::component::button::{Button, ButtonMsg, IconText},
shape::{Bar, Renderer},
},
};
pub enum VerticalMenuChoiceMsg {
Selected(usize),
}
/// Number of buttons.
/// Presently, VerticalMenu holds only fixed number of buttons.
/// TODO: for scrollable menu, the implementation must change.
const N_ITEMS: usize = 3;
/// Number of visual separators between buttons.
const N_SEPS: usize = N_ITEMS - 1;
/// Fixed height of each menu button.
const MENU_BUTTON_HEIGHT: i16 = 64;
/// Fixed height of a separator.
const MENU_SEP_HEIGHT: i16 = 2;
type VerticalMenuButtons = Vec<Button, N_ITEMS>;
type AreasForSeparators = Vec<Rect, N_SEPS>;
#[derive(Clone)]
pub struct VerticalMenu {
area: Rect,
/// buttons placed vertically from top to bottom
buttons: VerticalMenuButtons,
/// areas for visual separators between buttons
areas_sep: AreasForSeparators,
}
impl VerticalMenu {
fn new(buttons: VerticalMenuButtons) -> Self {
Self {
area: Rect::zero(),
buttons,
areas_sep: AreasForSeparators::new(),
}
}
pub fn select_word(words: [TString<'static>; 3]) -> Self {
let mut buttons_vec = VerticalMenuButtons::new();
for word in words {
let button = Button::with_text(word.into()).styled(theme::button_default());
unwrap!(buttons_vec.push(button));
}
Self::new(buttons_vec)
}
pub fn context_menu(options: Vec<(&'static str, Icon), N_ITEMS>) -> Self {
// FIXME: args should be TString when IconText has TString
let mut buttons_vec = VerticalMenuButtons::new();
for opt in options {
let button_theme;
match opt.1 {
// FIXME: might not be applicable everywhere
theme::ICON_CANCEL => {
button_theme = theme::button_warning_high();
}
_ => {
button_theme = theme::button_default();
}
}
unwrap!(buttons_vec.push(
Button::with_icon_and_text(IconText::new(opt.0, opt.1)).styled(button_theme)
));
}
Self::new(buttons_vec)
}
}
impl Component for VerticalMenu {
type Msg = VerticalMenuChoiceMsg;
fn place(&mut self, bounds: Rect) -> Rect {
// VerticalMenu is supposed to be used in Frame, the remaining space is just
// enought to fit 3 buttons separated by thin bars
let height_bounds_expected = 3 * MENU_BUTTON_HEIGHT + 2 * MENU_SEP_HEIGHT;
assert!(bounds.height() == height_bounds_expected);
self.area = bounds;
self.areas_sep.clear();
let mut remaining = bounds;
let n_seps = self.buttons.len() - 1;
for (i, button) in self.buttons.iter_mut().enumerate() {
let (area_button, new_remaining) = remaining.split_top(MENU_BUTTON_HEIGHT);
button.place(area_button);
remaining = new_remaining;
if i < n_seps {
let (area_sep, new_remaining) = remaining.split_top(MENU_SEP_HEIGHT);
unwrap!(self.areas_sep.push(area_sep));
remaining = new_remaining;
}
}
self.area
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
for (i, button) in self.buttons.iter_mut().enumerate() {
if let Some(ButtonMsg::Clicked) = button.event(ctx, event) {
return Some(VerticalMenuChoiceMsg::Selected(i));
}
}
None
}
fn paint(&mut self) {
// TODO remove when ui-t3t1 done
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
// render buttons separated by thin bars
for button in &self.buttons {
button.render(target);
}
for area in self.areas_sep.iter() {
Bar::new(*area)
.with_thickness(MENU_SEP_HEIGHT)
.with_fg(theme::GREY_EXTRA_DARK)
.render(target);
}
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
sink(self.area);
}
}
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for VerticalMenu {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("VerticalMenu");
t.in_list("buttons", &|button_list| {
for button in &self.buttons {
button_list.child(button);
}
});
}
}
#[cfg(feature = "micropython")]
impl crate::ui::flow::Swipable for VerticalMenu {}

@ -20,3 +20,8 @@ pub const fn screen() -> Rect {
Rect::from_top_left_and_size(Point::zero(), SIZE)
}
pub const SCREEN: Rect = screen();
/// Spacing between components (e.g. header and main content) and offsets from
/// the side of the screen. Generally applied everywhere except the top side of
/// the header. [px]
pub const SPACING: i16 = 2;

@ -0,0 +1,125 @@
use crate::{
error,
micropython::qstr::Qstr,
strutil::TString,
translations::TR,
ui::{
component::{
text::paragraphs::{Paragraph, Paragraphs},
SwipeDirection,
},
flow::{base::Decision, flow_store, FlowMsg, FlowState, FlowStore, SwipeFlow, SwipePage},
},
};
use heapless::Vec;
use super::super::{
component::{Frame, FrameMsg, PromptScreen, VerticalMenu, VerticalMenuChoiceMsg},
theme,
};
#[derive(Copy, Clone, PartialEq, Eq, ToPrimitive)]
pub enum ConfirmResetDevice {
Intro,
Menu,
Confirm,
}
impl FlowState for ConfirmResetDevice {
fn handle_swipe(&self, direction: SwipeDirection) -> Decision<Self> {
match (self, direction) {
(ConfirmResetDevice::Intro, SwipeDirection::Left) => {
Decision::Goto(ConfirmResetDevice::Menu, direction)
}
(ConfirmResetDevice::Menu, SwipeDirection::Right) => {
Decision::Goto(ConfirmResetDevice::Intro, direction)
}
(ConfirmResetDevice::Intro, SwipeDirection::Up) => {
Decision::Goto(ConfirmResetDevice::Confirm, direction)
}
(ConfirmResetDevice::Confirm, SwipeDirection::Down) => {
Decision::Goto(ConfirmResetDevice::Intro, direction)
}
_ => Decision::Nothing,
}
}
fn handle_event(&self, msg: FlowMsg) -> Decision<Self> {
match (self, msg) {
(ConfirmResetDevice::Intro, FlowMsg::Info) => {
Decision::Goto(ConfirmResetDevice::Menu, SwipeDirection::Left)
}
(ConfirmResetDevice::Menu, FlowMsg::Cancelled) => {
Decision::Goto(ConfirmResetDevice::Intro, SwipeDirection::Right)
}
(ConfirmResetDevice::Menu, FlowMsg::Choice(0)) => Decision::Return(FlowMsg::Cancelled),
(ConfirmResetDevice::Confirm, FlowMsg::Confirmed) => {
Decision::Return(FlowMsg::Confirmed)
}
_ => Decision::Nothing,
}
}
}
use crate::{
micropython::{map::Map, obj::Obj, util},
ui::layout::obj::LayoutObj,
};
pub extern "C" fn new_confirm_reset_device(
n_args: usize,
args: *const Obj,
kwargs: *mut Map,
) -> Obj {
unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, ConfirmResetDevice::new) }
}
impl ConfirmResetDevice {
fn new(_args: &[Obj], kwargs: &Map) -> Result<Obj, error::Error> {
let title: TString = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?;
let par_array: [Paragraph<'static>; 3] = [
Paragraph::new(&theme::TEXT_MAIN_GREY_LIGHT, TR::reset__by_continuing)
.with_bottom_padding(17),
Paragraph::new(&theme::TEXT_SUB_GREY, TR::reset__more_info_at),
Paragraph::new(&theme::TEXT_SUB_GREY_LIGHT, TR::reset__tos_link),
];
let paragraphs = Paragraphs::new(par_array);
let content_intro = Frame::left_aligned(title, SwipePage::vertical(paragraphs))
.with_menu_button()
.with_footer(TR::instructions__swipe_up.into(), None);
let content_menu = Frame::left_aligned(
"".into(),
VerticalMenu::context_menu(unwrap!(Vec::from_slice(&[(
"Cancel", // FIXME: use TString
theme::ICON_CANCEL
)]))),
)
.with_cancel_button();
let content_confirm = Frame::left_aligned(
TR::reset__title_create_wallet.into(),
PromptScreen::new_hold_to_confirm(),
)
.with_footer(TR::instructions__hold_to_confirm.into(), None);
let store = flow_store()
// Intro,
.add(content_intro, |msg| {
matches!(msg, FrameMsg::Button(_)).then_some(FlowMsg::Info)
})?
// Context Menu,
.add(content_menu, |msg| match msg {
FrameMsg::Content(VerticalMenuChoiceMsg::Selected(i)) => Some(FlowMsg::Choice(i)),
FrameMsg::Button(_) => Some(FlowMsg::Cancelled),
})?
// Confirm prompt
.add(content_confirm, |msg| match msg {
FrameMsg::Content(()) => Some(FlowMsg::Confirmed),
_ => Some(FlowMsg::Cancelled),
})?;
let res = SwipeFlow::new(ConfirmResetDevice::Intro, store)?;
Ok(LayoutObj::new(res)?.into())
}
}

@ -0,0 +1,147 @@
use crate::{
error,
strutil::TString,
translations::TR,
ui::{
component::{
text::paragraphs::{Paragraph, Paragraphs},
SwipeDirection,
},
flow::{base::Decision, flow_store, FlowMsg, FlowState, FlowStore, SwipeFlow, SwipePage},
},
};
use heapless::Vec;
use super::super::{
component::{
CancelInfoConfirmMsg, Frame, FrameMsg, PromptScreen, VerticalMenu, VerticalMenuChoiceMsg,
},
theme,
};
#[derive(Copy, Clone, PartialEq, Eq, ToPrimitive)]
pub enum CreateBackup {
Intro,
Menu,
SkipBackupIntro,
SkipBackupConfirm,
}
impl FlowState for CreateBackup {
fn handle_swipe(&self, direction: SwipeDirection) -> Decision<Self> {
match (self, direction) {
(CreateBackup::Intro, SwipeDirection::Left) => {
Decision::Goto(CreateBackup::Menu, direction)
}
(CreateBackup::SkipBackupIntro, SwipeDirection::Up) => {
Decision::Goto(CreateBackup::SkipBackupConfirm, direction)
}
(CreateBackup::SkipBackupConfirm, SwipeDirection::Down) => {
Decision::Goto(CreateBackup::SkipBackupIntro, direction)
}
(CreateBackup::Intro, SwipeDirection::Up) => Decision::Return(FlowMsg::Confirmed),
_ => Decision::Nothing,
}
}
fn handle_event(&self, msg: FlowMsg) -> Decision<Self> {
match (self, msg) {
(CreateBackup::Intro, FlowMsg::Info) => {
Decision::Goto(CreateBackup::Menu, SwipeDirection::Left)
}
(CreateBackup::Menu, FlowMsg::Choice(0)) => {
Decision::Goto(CreateBackup::SkipBackupIntro, SwipeDirection::Left)
}
(CreateBackup::Menu, FlowMsg::Cancelled) => {
Decision::Goto(CreateBackup::Intro, SwipeDirection::Right)
}
(CreateBackup::SkipBackupIntro, FlowMsg::Cancelled) => {
Decision::Goto(CreateBackup::Menu, SwipeDirection::Right)
}
(CreateBackup::SkipBackupConfirm, FlowMsg::Cancelled) => {
Decision::Goto(CreateBackup::SkipBackupIntro, SwipeDirection::Right)
}
(CreateBackup::SkipBackupConfirm, FlowMsg::Confirmed) => {
Decision::Return(FlowMsg::Cancelled)
}
_ => Decision::Nothing,
}
}
}
use crate::{
micropython::{map::Map, obj::Obj, util},
ui::layout::obj::LayoutObj,
};
pub extern "C" fn new_create_backup(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj {
unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, CreateBackup::new) }
}
impl CreateBackup {
fn new(_args: &[Obj], _kwargs: &Map) -> Result<Obj, error::Error> {
let title: TString = TR::backup__title_backup_wallet.into();
let par_array: [Paragraph<'static>; 1] = [Paragraph::new(
&theme::TEXT_MAIN_GREY_LIGHT,
TString::from_str("Your wallet backup contains X words in a specific order."),
)];
let paragraphs = Paragraphs::new(par_array);
let content_intro = Frame::left_aligned(title, SwipePage::vertical(paragraphs))
.with_menu_button()
.with_footer(TR::instructions__swipe_up.into(), None);
let content_menu = Frame::left_aligned(
"".into(),
VerticalMenu::context_menu(unwrap!(Vec::from_slice(&[(
"Skip backup", // FIXME: use TString
theme::ICON_CANCEL
)]))),
)
.with_cancel_button();
let par_array_skip_intro: [Paragraph<'static>; 2] = [
Paragraph::new(&theme::TEXT_WARNING, TString::from_str("Not recommended!")),
Paragraph::new(
&theme::TEXT_MAIN_GREY_LIGHT,
TString::from_str("Create a backup to avoid losing access to your funds"),
),
];
let paragraphs_skip_intro = Paragraphs::new(par_array_skip_intro);
let content_skip_intro = Frame::left_aligned(
TR::backup__title_skip.into(),
SwipePage::vertical(paragraphs_skip_intro),
)
.with_cancel_button()
.with_footer(
TR::instructions__swipe_up.into(),
Some(TR::words__continue_anyway.into()),
);
let content_skip_confirm = Frame::left_aligned(
TR::backup__title_skip.into(),
PromptScreen::new_tap_to_cancel(),
)
.with_footer(TR::instructions__tap_to_confirm.into(), None);
let store = flow_store()
.add(content_intro, |msg| {
matches!(msg, FrameMsg::Button(CancelInfoConfirmMsg::Info)).then_some(FlowMsg::Info)
})?
.add(content_menu, |msg| match msg {
FrameMsg::Content(VerticalMenuChoiceMsg::Selected(i)) => Some(FlowMsg::Choice(i)),
FrameMsg::Button(CancelInfoConfirmMsg::Cancelled) => Some(FlowMsg::Cancelled),
FrameMsg::Button(_) => None,
})?
.add(content_skip_intro, |msg| match msg {
FrameMsg::Button(CancelInfoConfirmMsg::Cancelled) => Some(FlowMsg::Cancelled),
_ => None,
})?
.add(content_skip_confirm, |msg| match msg {
FrameMsg::Content(()) => Some(FlowMsg::Confirmed),
FrameMsg::Button(CancelInfoConfirmMsg::Cancelled) => Some(FlowMsg::Cancelled),
_ => None,
})?;
let res = SwipeFlow::new(CreateBackup::Intro, store)?;
Ok(LayoutObj::new(res)?.into())
}
}

@ -0,0 +1,188 @@
use crate::{
error,
ui::{
component::{
image::BlendedImage,
text::paragraphs::{Paragraph, Paragraphs},
Qr, SwipeDirection, Timeout,
},
flow::{
base::Decision, flow_store, FlowMsg, FlowState, FlowStore, IgnoreSwipe, SwipeFlow,
SwipePage,
},
},
};
use heapless::Vec;
use super::super::{
component::{Frame, FrameMsg, IconDialog, VerticalMenu, VerticalMenuChoiceMsg},
theme,
};
const LONGSTRING: &'static str = "https://youtu.be/iFkEs4GNgfc?si=Jl4UZSIAYGVcLQKohttps://youtu.be/iFkEs4GNgfc?si=Jl4UZSIAYGVcLQKohttps://youtu.be/iFkEs4GNgfc?si=Jl4UZSIAYGVcLQKohttps://youtu.be/iFkEs4GNgfc?si=Jl4UZSIAYGVcLQKohttps://youtu.be/iFkEs4GNgfc?si=Jl4UZSIAYGVcLQKo";
#[derive(Copy, Clone, PartialEq, Eq, ToPrimitive)]
pub enum GetAddress {
Address,
Menu,
QrCode,
AccountInfo,
Cancel,
Success,
}
impl FlowState for GetAddress {
fn handle_swipe(&self, direction: SwipeDirection) -> Decision<Self> {
match (self, direction) {
(GetAddress::Address, SwipeDirection::Left) => {
Decision::Goto(GetAddress::Menu, direction)
}
(GetAddress::Address, SwipeDirection::Up) => {
Decision::Goto(GetAddress::Success, direction)
}
(GetAddress::Menu, SwipeDirection::Right) => {
Decision::Goto(GetAddress::Address, direction)
}
(GetAddress::QrCode, SwipeDirection::Right) => {
Decision::Goto(GetAddress::Menu, direction)
}
(GetAddress::AccountInfo, SwipeDirection::Right) => {
Decision::Goto(GetAddress::Menu, direction)
}
(GetAddress::Cancel, SwipeDirection::Up) => Decision::Return(FlowMsg::Cancelled),
_ => Decision::Nothing,
}
}
fn handle_event(&self, msg: FlowMsg) -> Decision<Self> {
match (self, msg) {
(GetAddress::Address, FlowMsg::Info) => {
Decision::Goto(GetAddress::Menu, SwipeDirection::Left)
}
(GetAddress::Menu, FlowMsg::Choice(0)) => {
Decision::Goto(GetAddress::QrCode, SwipeDirection::Left)
}
(GetAddress::Menu, FlowMsg::Choice(1)) => {
Decision::Goto(GetAddress::AccountInfo, SwipeDirection::Left)
}
(GetAddress::Menu, FlowMsg::Choice(2)) => {
Decision::Goto(GetAddress::Cancel, SwipeDirection::Left)
}
(GetAddress::Menu, FlowMsg::Cancelled) => {
Decision::Goto(GetAddress::Address, SwipeDirection::Right)
}
(GetAddress::QrCode, FlowMsg::Cancelled) => {
Decision::Goto(GetAddress::Menu, SwipeDirection::Right)
}
(GetAddress::AccountInfo, FlowMsg::Cancelled) => {
Decision::Goto(GetAddress::Menu, SwipeDirection::Right)
}
(GetAddress::Cancel, FlowMsg::Cancelled) => {
Decision::Goto(GetAddress::Menu, SwipeDirection::Right)
}
(GetAddress::Success, _) => Decision::Return(FlowMsg::Confirmed),
_ => Decision::Nothing,
}
}
}
use crate::{
micropython::{buffer::StrBuffer, map::Map, obj::Obj, util},
ui::layout::obj::LayoutObj,
};
pub extern "C" fn new_get_address(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj {
unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, GetAddress::new) }
}
impl GetAddress {
fn new(_args: &[Obj], _kwargs: &Map) -> Result<Obj, error::Error> {
let store = flow_store()
.add(
Frame::left_aligned(
"Receive".into(),
SwipePage::vertical(Paragraphs::new(Paragraph::new(
&theme::TEXT_MONO,
StrBuffer::from(LONGSTRING),
))),
)
.with_subtitle("address".into())
.with_menu_button(),
|msg| matches!(msg, FrameMsg::Button(_)).then_some(FlowMsg::Info),
)?
.add(
Frame::left_aligned(
"".into(),
VerticalMenu::context_menu(unwrap!(Vec::from_slice(&[
("Address QR code", theme::ICON_QR_CODE),
("Account info", theme::ICON_CHEVRON_RIGHT),
("Cancel trans.", theme::ICON_CANCEL),
]))),
)
.with_cancel_button(),
|msg| match msg {
FrameMsg::Content(VerticalMenuChoiceMsg::Selected(i)) => {
Some(FlowMsg::Choice(i))
}
FrameMsg::Button(_) => Some(FlowMsg::Cancelled),
},
)?
.add(
Frame::left_aligned(
"Receive address".into(),
IgnoreSwipe::new(Qr::new(
"https://youtu.be/iFkEs4GNgfc?si=Jl4UZSIAYGVcLQKo",
true,
)?),
)
.with_cancel_button(),
|msg| matches!(msg, FrameMsg::Button(_)).then_some(FlowMsg::Cancelled),
)?
.add(
Frame::left_aligned(
"Account info".into(),
SwipePage::vertical(Paragraphs::new(Paragraph::new(
&theme::TEXT_NORMAL,
StrBuffer::from("taproot xp"),
))),
)
.with_cancel_button(),
|msg| matches!(msg, FrameMsg::Button(_)).then_some(FlowMsg::Cancelled),
)?
.add(
Frame::left_aligned(
"Cancel receive".into(),
SwipePage::vertical(Paragraphs::new(Paragraph::new(
&theme::TEXT_NORMAL,
StrBuffer::from("O rly?"),
))),
)
.with_cancel_button(),
|msg| matches!(msg, FrameMsg::Button(_)).then_some(FlowMsg::Cancelled),
)?
.add(
IconDialog::new(
BlendedImage::new(
theme::IMAGE_BG_CIRCLE,
theme::IMAGE_FG_WARN,
theme::SUCCESS_COLOR,
theme::FG,
theme::BG,
),
StrBuffer::from("Confirmed"),
Timeout::new(100),
),
|_| Some(FlowMsg::Confirmed),
)?;
let res = SwipeFlow::new(GetAddress::Address, store)?;
Ok(LayoutObj::new(res)?.into())
}
}

@ -0,0 +1,7 @@
pub mod confirm_reset_device;
pub mod create_backup;
pub mod get_address;
pub use confirm_reset_device::ConfirmResetDevice;
pub use create_backup::CreateBackup;
pub use get_address::GetAddress;

File diff suppressed because it is too large Load Diff

@ -6,7 +6,11 @@ pub mod component;
pub mod constant;
pub mod theme;
mod screens;
#[cfg(feature = "micropython")]
pub mod flow;
#[cfg(feature = "micropython")]
pub mod layout;
pub mod screens;
pub struct ModelMercuryFeatures;

Binary file not shown.

After

Width:  |  Height:  |  Size: 542 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 433 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 B

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save