diff --git a/ci/shell.nix b/ci/shell.nix index ba4770fa9..f06a03170 100644 --- a/ci/shell.nix +++ b/ci/shell.nix @@ -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 diff --git a/core/SConscript.bootloader b/core/SConscript.bootloader index 7b54648c0..13e3b507d 100644 --- a/core/SConscript.bootloader +++ b/core/SConscript.bootloader @@ -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, diff --git a/core/SConscript.bootloader_ci b/core/SConscript.bootloader_ci index c1f540868..248402a86 100644 --- a/core/SConscript.bootloader_ci +++ b/core/SConscript.bootloader_ci @@ -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')), diff --git a/core/SConscript.bootloader_emu b/core/SConscript.bootloader_emu index a7e21ad6c..6f6901e6c 100644 --- a/core/SConscript.bootloader_emu +++ b/core/SConscript.bootloader_emu @@ -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'))) diff --git a/core/SConscript.firmware b/core/SConscript.firmware index aacafa3ab..e2054e698 100644 --- a/core/SConscript.firmware +++ b/core/SConscript.firmware @@ -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') diff --git a/core/SConscript.prodtest b/core/SConscript.prodtest index c4b6be902..de0b82580 100644 --- a/core/SConscript.prodtest +++ b/core/SConscript.prodtest @@ -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, diff --git a/core/SConscript.reflash b/core/SConscript.reflash index 9b4e2d481..8c89589ed 100644 --- a/core/SConscript.reflash +++ b/core/SConscript.reflash @@ -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, diff --git a/core/SConscript.unix b/core/SConscript.unix index 7474ebafb..0f4754065 100644 --- a/core/SConscript.unix +++ b/core/SConscript.unix @@ -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') diff --git a/core/embed/extmod/modtrezorutils/modtrezorutils.c b/core/embed/extmod/modtrezorutils/modtrezorutils.c index 2cde2041a..9c78a3e3b 100644 --- a/core/embed/extmod/modtrezorutils/modtrezorutils.c +++ b/core/embed/extmod/modtrezorutils/modtrezorutils.c @@ -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 diff --git a/core/embed/lib/fonts/fonts.c b/core/embed/lib/fonts/fonts.c index 76b528ed9..2c2d7c862 100644 --- a/core/embed/lib/fonts/fonts.c +++ b/core/embed/lib/fonts/fonts.c @@ -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; diff --git a/core/embed/lib/fonts/fonts.h b/core/embed/lib/fonts/fonts.h index 108dd9199..06e3be272 100644 --- a/core/embed/lib/fonts/fonts.h +++ b/core/embed/lib/fonts/fonts.h @@ -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); diff --git a/core/embed/lib/terminal.c b/core/embed/lib/terminal.c index 9a471aebc..55de08339 100644 --- a/core/embed/lib/terminal.c +++ b/core/embed/lib/terminal.c @@ -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++) { diff --git a/core/embed/rust/librust_qstr.h b/core/embed/rust/librust_qstr.h index bd7a90819..f8d306fb9 100644 --- a/core/embed/rust/librust_qstr.h +++ b/core/embed/rust/librust_qstr.h @@ -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; diff --git a/core/embed/rust/src/translations/generated/translated_string.rs b/core/embed/rust/src/translations/generated/translated_string.rs index 337b403c7..48172a8eb 100644 --- a/core/embed/rust/src/translations/generated/translated_string.rs +++ b/core/embed/rust/src/translations/generated/translated_string.rs @@ -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, } } diff --git a/core/embed/rust/src/ui/animation.rs b/core/embed/rust/src/ui/animation.rs index 746949139..66039faba 100644 --- a/core/embed/rust/src/ui/animation.rs +++ b/core/embed/rust/src/ui/animation.rs @@ -59,4 +59,8 @@ impl Animation { panic!("offset is too large"); } } + + pub fn finished(&self, now: Instant) -> bool { + self.elapsed(now) >= self.duration + } } diff --git a/core/embed/rust/src/ui/component/base.rs b/core/embed/rust/src/ui/component/base.rs index ab8a38fc4..10e27fd76 100644 --- a/core/embed/rust/src/ui/component/base.rs +++ b/core/embed/rust/src/ui/component/base.rs @@ -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 { component: T, marked_for_paint: bool, diff --git a/core/embed/rust/src/ui/component/image.rs b/core/embed/rust/src/ui/component/image.rs index 959ccf2c4..4a3fe34ee 100644 --- a/core/embed/rust/src/ui/component/image.rs +++ b/core/embed/rust/src/ui/component/image.rs @@ -72,6 +72,7 @@ impl crate::trace::Trace for Image { } } +#[derive(Clone)] pub struct BlendedImage { bg: Icon, fg: Icon, diff --git a/core/embed/rust/src/ui/component/label.rs b/core/embed/rust/src/ui/component/label.rs index 4618e21d3..666552a5d 100644 --- a/core/embed/rust/src/ui/component/label.rs +++ b/core/embed/rust/src/ui/component/label.rs @@ -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 } diff --git a/core/embed/rust/src/ui/component/mod.rs b/core/embed/rust/src/ui/component/mod.rs index 0859d6858..bc1e36dbb 100644 --- a/core/embed/rust/src/ui/component/mod.rs +++ b/core/embed/rust/src/ui/component/mod.rs @@ -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}, diff --git a/core/embed/rust/src/ui/component/qr_code.rs b/core/embed/rust/src/ui/component/qr_code.rs index 6b13e116d..953af10ed 100644 --- a/core/embed/rust/src/ui/component/qr_code.rs +++ b/core/embed/rust/src/ui/component/qr_code.rs @@ -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, border: i16, diff --git a/core/embed/rust/src/ui/component/swipe.rs b/core/embed/rust/src/ui/component/swipe.rs new file mode 100644 index 000000000..876d6209e --- /dev/null +++ b/core/embed/rust/src/ui/component/swipe.rs @@ -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, +} + +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 { + 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>) {} +} diff --git a/core/embed/rust/src/ui/component/text/paragraphs.rs b/core/embed/rust/src/ui/component/text/paragraphs.rs index 698bc1dfd..2ef4b1311 100644 --- a/core/embed/rust/src/ui/component/text/paragraphs.rs +++ b/core/embed/rust/src/ui/component/text/paragraphs.rs @@ -47,6 +47,7 @@ pub trait ParagraphSource<'a> { } } +#[derive(Clone)] pub struct Paragraphs { area: Rect, placement: LinearPlacement, @@ -335,6 +336,7 @@ impl<'a> Paragraph<'a> { } } +#[derive(Clone)] struct TextLayoutProxy { offset: PageOffset, bounds: Rect, diff --git a/core/embed/rust/src/ui/component/timeout.rs b/core/embed/rust/src/ui/component/timeout.rs index 6da62e725..8aac3ba8d 100644 --- a/core/embed/rust/src/ui/component/timeout.rs +++ b/core/embed/rust/src/ui/component/timeout.rs @@ -7,6 +7,7 @@ use crate::{ }, }; +#[derive(Clone)] pub struct Timeout { time_ms: u32, timer: Option, diff --git a/core/embed/rust/src/ui/display/font.rs b/core/embed/rust/src/ui/display/font.rs index a466fce3c..1f751756d 100644 --- a/core/embed/rust/src/ui/display/font.rs +++ b/core/embed/rust/src/ui/display/font.rs @@ -154,6 +154,7 @@ pub enum Font { MONO = 3, BIG = 4, DEMIBOLD = 5, + SUB = 6, } impl From 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 { diff --git a/core/embed/rust/src/ui/flow/base.rs b/core/embed/rust/src/ui/flow/base.rs new file mode 100644 index 000000000..c7c827d5d --- /dev/null +++ b/core/embed/rust/src/ui/flow/base.rs @@ -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 { + /// Do nothing, continue with processing next handler. + Nothing, + + /// Initiate transition to another state, end event processing. + /// NOTE: it might make sense to include Option here + Goto(Q, SwipeDirection), + + /// Yield a message to the caller of the flow (i.e. micropython), end event + /// processing. + Return(FlowMsg), +} + +impl Decision { + 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; + + /// What to do when the current component emits a message in response to an + /// event. + fn handle_event(&self, msg: FlowMsg) -> Decision; +} diff --git a/core/embed/rust/src/ui/flow/flow.rs b/core/embed/rust/src/ui/flow/flow.rs new file mode 100644 index 000000000..7553771b2 --- /dev/null +++ b/core/embed/rust/src/ui/flow/flow.rs @@ -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 { + /// Current state. + state: Q, + /// FlowStore with all screens/components. + store: S, + /// `Some` when state transition animation is in progress. + transition: Option>, + /// Swipe detector. + swipe: Swipe, + /// Animation parameter. + anim_offset: Offset, +} + +struct Transition { + prev_state: Q, + animation: Animation, + direction: SwipeDirection, +} + +impl SwipeFlow { + pub fn new(init: Q, store: S) -> Result { + 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, 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 { + 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 { + let msg = self.store.event(self.state.index(), ctx, event); + if let Some(msg) = msg { + self.state.handle_event(msg) + } else { + Decision::Nothing + } + } +} + +impl Component for SwipeFlow { + 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 { + // 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 crate::trace::Trace for SwipeFlow { + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + self.store.trace(self.state.index(), t) + } +} + +#[cfg(feature = "micropython")] +impl crate::ui::layout::obj::ComponentMsgObj for SwipeFlow { + fn msg_try_into_obj( + &self, + msg: Self::Msg, + ) -> Result { + 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()?) + } + } + } +} diff --git a/core/embed/rust/src/ui/flow/mod.rs b/core/embed/rust/src/ui/flow/mod.rs new file mode 100644 index 000000000..4c4ddcb5b --- /dev/null +++ b/core/embed/rust/src/ui/flow/mod.rs @@ -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}; diff --git a/core/embed/rust/src/ui/flow/page.rs b/core/embed/rust/src/ui/flow/page.rs new file mode 100644 index 000000000..8c986975b --- /dev/null +++ b/core/embed/rust/src/ui/flow/page.rs @@ -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 { + inner: T, + axis: Axis, + pages: usize, + current: usize, +} + +impl SwipePage { + pub fn vertical(inner: T) -> Self { + Self { + inner, + axis: Axis::Vertical, + pages: 1, + current: 0, + } + } +} + +impl Component for SwipePage { + 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 { + 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 Swipable for SwipePage { + 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 crate::trace::Trace for SwipePage +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); + +impl IgnoreSwipe { + pub fn new(inner: T) -> Self { + IgnoreSwipe(inner) + } +} + +impl Component for IgnoreSwipe { + 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.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 Swipable for IgnoreSwipe { + fn can_swipe(&self, _direction: SwipeDirection) -> bool { + false + } + fn swiped(&mut self, _ctx: &mut EventCtx, _direction: SwipeDirection) {} +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for IgnoreSwipe +where + T: crate::trace::Trace, +{ + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + self.0.trace(t) + } +} diff --git a/core/embed/rust/src/ui/flow/store.rs b/core/embed/rust/src/ui/flow/store.rs new file mode 100644 index 000000000..993170154 --- /dev/null +++ b/core/embed/rust/src/ui/flow/store.rs @@ -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>` 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; + + /// 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(&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) -> 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( + self, + elem: E, + func: fn(E::Msg) -> Option, + ) -> Result + 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 { + 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(&mut self, _i: usize, _func: impl FnOnce(&mut dyn Swipable) -> T) -> T { + panic!() + } + + fn clone(&mut self, _i: Option) -> Result<(), error::Error> { + Ok(()) + } + fn render_cloned<'s>(&'s self, _target: &mut impl Renderer<'s>) {} + + fn add( + self, + elem: E, + func: fn(E::Msg) -> Option, + ) -> Result + where + Self: Sized, + { + Ok(FlowComponent { + elem: Gc::new(elem)?, + func, + cloned: None, + next: Self, + }) + } +} + +struct FlowComponent { + /// Component allocated on micropython heap. + pub elem: Gc, + + /// Clone. + pub cloned: Option>, + + /// Function to convert message to `FlowMsg`. + pub func: fn(E::Msg) -> Option, + + /// Nested FlowStore. + pub next: P, +} + +impl FlowComponent { + 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 FlowStore for FlowComponent +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 { + 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(&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) -> 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( + self, + elem: F, + func: fn(F::Msg) -> Option, + ) -> Result + where + Self: Sized, + { + Ok(FlowComponent { + elem: self.elem, + func: self.func, + cloned: None, + next: self.next.add(elem, func)?, + }) + } +} diff --git a/core/embed/rust/src/ui/geometry.rs b/core/embed/rust/src/ui/geometry.rs index a5a7d8a2b..bca2e523c 100644 --- a/core/embed/rust/src/ui/geometry.rs +++ b/core/embed/rust/src/ui/geometry.rs @@ -134,6 +134,12 @@ impl From 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 { diff --git a/core/embed/rust/src/ui/mod.rs b/core/embed/rust/src/ui/mod.rs index b08f77789..75c14bbf7 100644 --- a/core/embed/rust/src/ui/mod.rs +++ b/core/embed/rust/src/ui/mod.rs @@ -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; diff --git a/core/embed/rust/src/ui/model_mercury/component/address_details.rs b/core/embed/rust/src/ui/model_mercury/component/address_details.rs new file mode 100644 index 000000000..c3e7ca7dd --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/address_details.rs @@ -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, + details: Frame>>, + xpub_view: Frame>>, + xpubs: Vec<(TString<'static>, TString<'static>), MAX_XPUBS>, + xpub_page_count: Vec, + 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>, + path: Option>, + ) -> Result { + 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 { + 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), + } + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/button.rs b/core/embed/rust/src/ui/model_mercury/component/button.rs index fa77f1909..f57414c89 100644 --- a/core/embed/rust/src/ui/model_mercury/component/button.rs +++ b/core/embed/rust/src/ui/model_mercury/component/button.rs @@ -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, @@ -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, + impl Fn(ButtonMsg) -> Option, + > { + 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>, + right: Option>, + ) -> CancelConfirm< + impl Fn(ButtonMsg) -> Option, + impl Fn(ButtonMsg) -> Option, + > { + 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, + impl Fn(ButtonMsg) -> Option, + impl Fn(ButtonMsg) -> Option, + > { + 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 = + FixedHeightBar, Split, MsgMap>>>; + +type CancelConfirm = FixedHeightBar, MsgMap>>; + #[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); } } diff --git a/core/embed/rust/src/ui/model_mercury/component/coinjoin_progress.rs b/core/embed/rust/src/ui/model_mercury/component/coinjoin_progress.rs new file mode 100644 index 000000000..eabb07403 --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/coinjoin_progress.rs @@ -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 { + value: u16, + indeterminate: bool, + content: Child>>, + // 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 CoinJoinProgress { + pub fn new( + text: TString<'static>, + indeterminate: bool, + ) -> Result + 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 CoinJoinProgress +where + U: Component, +{ + pub fn with_background( + text: TString<'static>, + inner: U, + indeterminate: bool, + ) -> Result { + 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 Component for CoinJoinProgress +where + U: Component, +{ + 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.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 crate::trace::Trace for CoinJoinProgress +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); + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/dialog.rs b/core/embed/rust/src/ui/model_mercury/component/dialog.rs new file mode 100644 index 000000000..68cee9a3a --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/dialog.rs @@ -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 { + Content(T), + Controls(U), +} + +pub struct Dialog { + content: Child, + controls: Child, +} + +impl Dialog +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 Component for Dialog +where + T: Component, + U: Component, +{ + type Msg = DialogMsg; + + 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.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 crate::trace::Trace for Dialog +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 { + image: Child, + paragraphs: Paragraphs>, + controls: Child, +} + +impl IconDialog +where + U: Component, +{ + pub fn new(icon: BlendedImage, title: impl Into>, 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>) -> Self { + self.with_paragraph(Paragraph::new(style, text).centered()) + } + + pub fn with_description(self, description: impl Into>) -> Self { + self.with_text(&theme::TEXT_NORMAL_OFF_WHITE, description) + } + + pub fn with_value(self, value: impl Into>) -> Self { + self.with_text(&theme::TEXT_MONO, value) + } + + pub fn new_shares(lines: [impl Into>; 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 Component for IconDialog +where + U: Component, +{ + type Msg = DialogMsg; + + 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.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 crate::trace::Trace for IconDialog +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 crate::ui::flow::Swipable for IconDialog {} diff --git a/core/embed/rust/src/ui/model_mercury/component/fido.rs b/core/embed/rust/src/ui/model_mercury/component/fido.rs new file mode 100644 index 000000000..93c279ec9 --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/fido.rs @@ -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 TString<'static>, U> { + page_swipe: Swipe, + app_name: Label<'static>, + account_name: Label<'static>, + icon: Child, + /// Function/closure that will return appropriate page on demand. + get_account: F, + scrollbar: ScrollBar, + fade: Cell, + controls: U, +} + +impl FidoConfirm +where + F: Fn(usize) -> TString<'static>, + U: Component, +{ + pub fn new( + app_name: TString<'static>, + get_account: F, + page_count: usize, + icon_name: Option>, + 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 Component for FidoConfirm +where + F: Fn(usize) -> TString<'static>, + U: Component, +{ + 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 { + 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 crate::trace::Trace for FidoConfirm +where + F: Fn(usize) -> TString<'static>, +{ + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.component("FidoConfirm"); + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/fido_icons.rs b/core/embed/rust/src/ui/model_mercury/component/fido_icons.rs new file mode 100644 index 000000000..730232cf4 --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/fido_icons.rs @@ -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>) -> &'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 + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/fido_icons.rs.mako b/core/embed/rust/src/ui/model_mercury/component/fido_icons.rs.mako new file mode 100644 index 000000000..5b832e9a2 --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/fido_icons.rs.mako @@ -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>) -> &'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 + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/footer.rs b/core/embed/rust/src/ui/model_mercury/component/footer.rs new file mode 100644 index 000000000..4161b2ba8 --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/footer.rs @@ -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>, + 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>>(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>>(self, description: T) -> Self { + Self { + text_description: Some(description.into()), + ..self + } + } + + pub fn update_instruction>>(&mut self, ctx: &mut EventCtx, s: T) { + self.text_instruction = s.into(); + ctx.request_paint(); + } + + pub fn update_description>>(&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 { + 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); + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/frame.rs b/core/embed/rust/src/ui/model_mercury/component/frame.rs index e5c549101..29a0f8fe6 100644 --- a/core/embed/rust/src/ui/model_mercury/component/frame.rs +++ b/core/embed/rust/src/ui/model_mercury/component/frame.rs @@ -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 { border: Insets, title: Child>, @@ -20,6 +23,7 @@ pub struct Frame { button: Option>, button_msg: CancelInfoConfirmMsg, content: Child, + footer: Option>, } pub enum FrameMsg { @@ -31,32 +35,30 @@ impl Frame 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>, + ) -> 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(&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; 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 crate::ui::flow::Swipable for Frame +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)) } } diff --git a/core/embed/rust/src/ui/model_mercury/component/homescreen/mod.rs b/core/embed/rust/src/ui/model_mercury/component/homescreen/mod.rs new file mode 100644 index 000000000..62e95cc0f --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/homescreen/mod.rs @@ -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>, + hold_to_lock: bool, + loader: Loader, + pad: Pad, + paint_notification_only: bool, + delay: Option, +} + +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 { + 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::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>, + 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 { + 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"); + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/homescreen/render.rs b/core/embed/rust/src/ui/model_mercury/component/homescreen/render.rs new file mode 100644 index 000000000..897c9cd9e --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/homescreen/render.rs @@ -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, +} + +#[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, +} + +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>, +} + +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::() }; + 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, + 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(); +} diff --git a/core/embed/rust/src/ui/model_mercury/component/keyboard/bip39.rs b/core/embed/rust/src/ui/model_mercury/component/keyboard/bip39.rs new file mode 100644 index 000000000..8697af368 --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/keyboard/bip39.rs @@ -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, + multi_tap: MultiTapKeyboard, + options_num: Option, + 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.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 { + 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 { + 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); + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/keyboard/common.rs b/core/embed/rust/src/ui/model_mercury/component/keyboard/common.rs new file mode 100644 index 000000000..9868770bc --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/keyboard/common.rs @@ -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, +} + +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 { + self.pending.as_ref().map(|p| p.key) + } + + /// Return the index of the pending key press. + pub fn pending_press(&self) -> Option { + self.pending.as_ref().map(|p| p.press) + } + + /// Return the token for the currently pending timer. + pub fn pending_timer(&self) -> Option { + 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); + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/keyboard/mnemonic.rs b/core/embed/rust/src/ui/model_mercury/component/keyboard/mnemonic.rs new file mode 100644 index 000000000..def0f6897 --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/keyboard/mnemonic.rs @@ -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 { + /// Initial prompt, displayed on empty input. + prompt: Child>>, + /// Backspace button. + back: Child>, + /// Input area, acting as the auto-complete and confirm button. + input: Child>, + /// Key buttons. + keys: [Child