diff --git a/ci/build.yml b/ci/build.yml index 1e0cdb925d..dbb533dbd1 100644 --- a/ci/build.yml +++ b/ci/build.yml @@ -149,6 +149,39 @@ core fw btconly t1 build: - trezor-fw-btconly-t1-*.*.*-$CI_COMMIT_SHORT_SHA.bin expire_in: 1 week +core fw R btconly debug build: + stage: build + <<: *gitlab_caching + needs: [] + variables: + TREZOR_MODEL: "R" + BITCOIN_ONLY: "1" + PYOPT: "0" + script: + - nix-shell --run "poetry run make -C core build_firmware" + - cp core/build/firmware/firmware.bin trezor-fw-btconly-debug-tr-$CORE_VERSION-$CI_COMMIT_SHORT_SHA.bin + artifacts: + name: "$CI_JOB_NAME-$CI_COMMIT_SHORT_SHA" + paths: + - trezor-fw-btconly-*.*.*-$CI_COMMIT_SHORT_SHA.bin + expire_in: 1 week + +core fw R btconly build: + stage: build + <<: *gitlab_caching + needs: [] + variables: + TREZOR_MODEL: "R" + BITCOIN_ONLY: "1" + script: + - nix-shell --run "poetry run make -C core build_firmware" + - cp core/build/firmware/firmware.bin trezor-fw-btconly-tr-$CORE_VERSION-$CI_COMMIT_SHORT_SHA.bin + artifacts: + name: "$CI_JOB_NAME-$CI_COMMIT_SHORT_SHA" + paths: + - trezor-fw-btconly-*.*.*-$CI_COMMIT_SHORT_SHA.bin + expire_in: 1 week + # Non-frozen emulator build. This means you still need Python files # present which get interpreted. core unix regular build: @@ -278,6 +311,40 @@ core unix frozen R debug build: artifacts: name: "$CI_JOB_NAME-$CI_COMMIT_SHORT_SHA" untracked: true + expire_in: 10 weeks + +core unix frozen R debug build arm: + image: nixos/nix + stage: build + <<: *gitlab_caching + needs: [] + variables: + PYOPT: "0" + TREZOR_MODEL: "R" + script: + - nix-shell --run "poetry run make -C core build_unix_frozen" + - mv core/build/unix/trezor-emu-core core/build/unix/trezor-emu-core-arm + artifacts: + name: "$CI_JOB_NAME-$CI_COMMIT_SHORT_SHA" + untracked: true + expire_in: 10 weeks + tags: + - docker_darwin_arm + +# Debugger build for gdb/lldb. +core unix R debugger build: + stage: build + <<: *gitlab_caching + needs: [] + variables: + TREZOR_EMULATOR_FROZEN: "1" + TREZOR_MODEL: "R" + script: + - nix-shell --run "poetry run make -C core build_unix_debug" + artifacts: + name: "$CI_JOB_NAME-$CI_COMMIT_SHORT_SHA" + paths: + - core/build/unix expire_in: 1 week core unix frozen debug asan build: diff --git a/ci/deploy.yml b/ci/deploy.yml index 3b0a9ac105..0ecc0718f4 100644 --- a/ci/deploy.yml +++ b/ci/deploy.yml @@ -265,6 +265,25 @@ ui tests ui2 fixtures deploy: tags: - deploy +ui tests R fixtures deploy: + stage: deploy + variables: + DEPLOY_PATH: "${DEPLOY_BASE_DIR}/ui_tests/" + BUCKET: "data.trezor.io" + GIT_SUBMODULE_STRATEGY: "none" + before_script: [] # no poetry + needs: + - core device R test + script: + - echo "Deploying to $DEPLOY_PATH" + - rsync --delete -va ci/ui_test_records/* "$DEPLOY_PATH" + - source ${AWS_DEPLOY_DATA} + - aws s3 sync $DEPLOY_PATH s3://$BUCKET/dev/firmware/ui_tests + # This "hack" is needed because aws does not have an easy option to generate autoindex. We fetch the one autogenerated by nginx on local server. + - wget https://firmware.corp.sldev.cz/ui_tests/ -O index.html && aws s3 cp index.html s3://$BUCKET/dev/firmware/ui_tests/ + tags: + - deploy + # sync to aws sync emulators to aws: diff --git a/ci/prepare_ui_artifacts.py b/ci/prepare_ui_artifacts.py index 3f2589ce8e..d67c1f7dc2 100644 --- a/ci/prepare_ui_artifacts.py +++ b/ci/prepare_ui_artifacts.py @@ -11,14 +11,14 @@ from tests.ui_tests import read_fixtures # isort:skip read_fixtures() from tests.ui_tests import _hash_files, FILE_HASHES, SCREENS_DIR # isort:skip -# As in CI we are running T1 and TT tests separately, there will -# always be the other model missing. +# As in CI we are running tests for more models separately, +# there will always be the other models missing. # Therefore, choosing just the cases for our model. -if len(sys.argv) > 1 and sys.argv[1].upper() == "T1": - model = "T1" +if len(sys.argv) > 1: + model = sys.argv[1].upper() else: model = "TT" -if os.getenv("UI2") == "1": +if model == "TT" and os.getenv("UI2") == "1": model += "ui2" model_file_hashes = {k: v for k, v in FILE_HASHES.items() if k.startswith(f"{model}_")} diff --git a/ci/test.yml b/ci/test.yml index 4c5a75748c..7cfaa380f3 100644 --- a/ci/test.yml +++ b/ci/test.yml @@ -152,7 +152,7 @@ core device R test: - tests/trezor.log - master_diff when: always - expire_in: 1 week + expire_in: 4 weeks reports: junit: tests/junit.xml diff --git a/common/protob/messages-debug.proto b/common/protob/messages-debug.proto index efe347b276..d64f26129b 100644 --- a/common/protob/messages-debug.proto +++ b/common/protob/messages-debug.proto @@ -28,6 +28,7 @@ message DebugLinkDecision { DOWN = 1; LEFT = 2; RIGHT = 3; + ALL_THE_WAY_UP = 4; } /** @@ -39,10 +40,21 @@ message DebugLinkDecision { INFO = 2; } + /** + * Structure representing model R button presses + */ + // TODO: probably delete the middle_btn as it is not a physical one + enum DebugPhysicalButton { + LEFT_BTN = 0; + MIDDLE_BTN = 1; + RIGHT_BTN = 2; + } + optional uint32 x = 4; // touch X coordinate optional uint32 y = 5; // touch Y coordinate optional bool wait = 6; // wait for layout change optional uint32 hold_ms = 7; // touch hold duration + optional DebugPhysicalButton physical_button = 8; // physical button press } /** diff --git a/common/protob/messages-management.proto b/common/protob/messages-management.proto index eb732fc6db..52e5243a90 100644 --- a/common/protob/messages-management.proto +++ b/common/protob/messages-management.proto @@ -306,6 +306,7 @@ message ResetDevice { optional bool skip_backup = 8; // postpone seed backup to BackupDevice workflow optional bool no_backup = 9; // indicate that no backup is going to be made optional BackupType backup_type = 10 [default=Bip39]; // type of the mnemonic backup + optional bool show_tutorial = 11 [default=true]; // whether to show device tutorial } /** @@ -348,6 +349,7 @@ message RecoveryDevice { optional RecoveryDeviceType type = 8; // supported recovery type optional uint32 u2f_counter = 9; // U2F counter optional bool dry_run = 10; // perform dry-run recovery workflow (for safe mnemonic validation) + optional bool show_tutorial = 11 [default=true]; // whether to show device tutorial /** * Type of recovery procedure. These should be used as bitmask, e.g., * `RecoveryDeviceType_ScrambledWords | RecoveryDeviceType_Matrix` diff --git a/core/.gitignore b/core/.gitignore index b48f5043bb..1c8ee092e6 100644 --- a/core/.gitignore +++ b/core/.gitignore @@ -8,3 +8,4 @@ tests/trezor_monero_tests* .coverage.* htmlcov/ mypy_report +tools/gdb_scripts/*.log diff --git a/core/Makefile b/core/Makefile index 3307c76a8f..b276239bd2 100644 --- a/core/Makefile +++ b/core/Makefile @@ -23,6 +23,7 @@ PYOPT ?= 1 BITCOIN_ONLY ?= 0 TREZOR_MODEL ?= T TREZOR_MEMPERF ?= 0 +TREZOR_EMULATOR_FROZEN ?= 0 ADDRESS_SANITIZER ?= 0 UI2 ?= 0 @@ -84,7 +85,7 @@ test: ## run unit tests cd tests ; ./run_tests.sh $(TESTOPTS) test_rust: ## run rs unit tests - cd embed/rust ; cargo test --target=$(RUST_TARGET) --no-default-features --features model_t$(shell echo $(TREZOR_MODEL) | tr "TR" "tr"),test -- --test-threads=1 + cd embed/rust ; cargo test $(TESTOPTS) --target=$(RUST_TARGET) --no-default-features --features model_t$(shell echo $(TREZOR_MODEL) | tr "TR" "tr"),test -- --test-threads=1 --nocapture test_emu: ## run selected device tests from python-trezor $(EMU_TEST) $(PYTEST) $(TESTPATH)/device_tests $(TESTOPTS) @@ -174,7 +175,7 @@ build_unix_frozen: templates build_cross ## build unix port with frozen modules $(SCONS) CFLAGS="$(CFLAGS)" $(UNIX_BUILD_DIR)/trezor-emu-core $(UNIX_PORT_OPTS) TREZOR_MODEL="$(TREZOR_MODEL)" PYOPT="$(PYOPT)" BITCOIN_ONLY="$(BITCOIN_ONLY)" TREZOR_EMULATOR_ASAN="$(ADDRESS_SANITIZER)" UI2="$(UI2)" TREZOR_MEMPERF="$(TREZOR_MEMPERF)" TREZOR_EMULATOR_FROZEN=1 build_unix_debug: templates ## build unix port - $(SCONS) --max-drift=1 CFLAGS="$(CFLAGS)" $(UNIX_BUILD_DIR)/trezor-emu-core $(UNIX_PORT_OPTS) TREZOR_MODEL="$(TREZOR_MODEL)" BITCOIN_ONLY="$(BITCOIN_ONLY)" TREZOR_EMULATOR_ASAN=1 UI2="$(UI2)" TREZOR_EMULATOR_DEBUGGABLE=1 + $(SCONS) --max-drift=1 CFLAGS="$(CFLAGS)" $(UNIX_BUILD_DIR)/trezor-emu-core $(UNIX_PORT_OPTS) TREZOR_MODEL="$(TREZOR_MODEL)" PYOPT="0" BITCOIN_ONLY="$(BITCOIN_ONLY)" TREZOR_EMULATOR_ASAN=1 UI2="$(UI2)" TREZOR_EMULATOR_DEBUGGABLE=1 TREZOR_EMULATOR_FROZEN="$(TREZOR_EMULATOR_FROZEN)" build_cross: ## build mpy-cross port $(MAKE) -C vendor/micropython/mpy-cross $(CROSS_PORT_OPTS) diff --git a/core/SConscript.firmware b/core/SConscript.firmware index 65e90232e4..9d6b796709 100644 --- a/core/SConscript.firmware +++ b/core/SConscript.firmware @@ -613,8 +613,14 @@ if FROZEN: SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'trezor/ui/constants/t1.py')) SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'trezor/ui/layouts/t1.py')) elif TREZOR_MODEL in ('R',): + SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'trezor/ui/components/tr/*.py')) SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'trezor/ui/constants/tr.py')) SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'trezor/ui/layouts/tr/__init__.py')) + SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'trezor/ui/layouts/tr/reset.py')) + SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'trezor/ui/layouts/tr/recovery.py')) + if EVERYTHING: + SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'trezor/ui/layouts/tr/webauthn.py')) + SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'trezor/ui/layouts/tr/altcoin.py')) else: raise ValueError('Unknown Trezor model') @@ -643,7 +649,20 @@ if FROZEN: SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'apps/common/*.py')) SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'apps/common/*/*.py')) SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'apps/debug/*.py')) - SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'apps/homescreen/*.py')) + SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'apps/homescreen/*.py', + # Each homescreen gets included individually below, based on model + exclude=[ + SOURCE_PY_DIR + 'apps/homescreen/tt.py', + SOURCE_PY_DIR + 'apps/homescreen/tr.py', + SOURCE_PY_DIR + 'apps/homescreen/t1.py', + ] + )) + if TREZOR_MODEL in ('T',): + SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'apps/homescreen/tt.py')) + elif TREZOR_MODEL in ('R',): + SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'apps/homescreen/tr.py')) + elif TREZOR_MODEL in ('1',): + SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'apps/homescreen/t1.py')) SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'apps/management/*.py', exclude=[ SOURCE_PY_DIR + 'apps/management/sd_protect.py', diff --git a/core/SConscript.unix b/core/SConscript.unix index 8e387a08b2..d096d08017 100644 --- a/core/SConscript.unix +++ b/core/SConscript.unix @@ -567,6 +567,7 @@ if FROZEN: SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'trezor/ui/constants/t1.py')) SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'trezor/ui/layouts/t1.py')) elif TREZOR_MODEL in ('R',): + SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'trezor/ui/components/tr/*.py')) SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'trezor/ui/constants/tr.py')) SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'trezor/ui/layouts/tr/__init__.py')) SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'trezor/ui/layouts/tr/reset.py')) @@ -602,7 +603,20 @@ if FROZEN: SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'apps/common/*.py')) SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'apps/common/*/*.py')) SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'apps/debug/*.py')) - SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'apps/homescreen/*.py')) + SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'apps/homescreen/*.py', + # Each homescreen gets included individually below, based on model + exclude=[ + SOURCE_PY_DIR + 'apps/homescreen/tt.py', + SOURCE_PY_DIR + 'apps/homescreen/tr.py', + SOURCE_PY_DIR + 'apps/homescreen/t1.py', + ] + )) + if TREZOR_MODEL in ('T',): + SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'apps/homescreen/tt.py')) + elif TREZOR_MODEL in ('R',): + SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'apps/homescreen/tr.py')) + elif TREZOR_MODEL in ('1',): + SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'apps/homescreen/t1.py')) SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'apps/management/*.py', exclude=[ SOURCE_PY_DIR + 'apps/management/sd_protect.py', diff --git a/core/assets/check_model_r.png b/core/assets/check_model_r.png new file mode 100644 index 0000000000..1f0b3b6bbe Binary files /dev/null and b/core/assets/check_model_r.png differ diff --git a/core/assets/homescreen_model_r.png b/core/assets/homescreen_model_r.png deleted file mode 100644 index 2f2cc72bdd..0000000000 Binary files a/core/assets/homescreen_model_r.png and /dev/null differ diff --git a/core/assets/model_r/amount.png b/core/assets/model_r/amount.png new file mode 100644 index 0000000000..4ffc24edb6 Binary files /dev/null and b/core/assets/model_r/amount.png differ diff --git a/core/assets/model_r/bin.png b/core/assets/model_r/bin.png new file mode 100644 index 0000000000..3dc2c3c4bd Binary files /dev/null and b/core/assets/model_r/bin.png differ diff --git a/core/assets/model_r/cancel_for_outline.png b/core/assets/model_r/cancel_for_outline.png new file mode 100644 index 0000000000..b6fff7a409 Binary files /dev/null and b/core/assets/model_r/cancel_for_outline.png differ diff --git a/core/assets/model_r/cancel_no_outline.png b/core/assets/model_r/cancel_no_outline.png new file mode 100644 index 0000000000..eb92d0e834 Binary files /dev/null and b/core/assets/model_r/cancel_no_outline.png differ diff --git a/core/assets/model_r/down_arrow.png b/core/assets/model_r/down_arrow.png new file mode 100644 index 0000000000..2533409df4 Binary files /dev/null and b/core/assets/model_r/down_arrow.png differ diff --git a/core/assets/model_r/homescreen.png b/core/assets/model_r/homescreen.png new file mode 100644 index 0000000000..29c15a1995 Binary files /dev/null and b/core/assets/model_r/homescreen.png differ diff --git a/core/assets/model_r/left_arm.png b/core/assets/model_r/left_arm.png new file mode 100644 index 0000000000..de6e65574a Binary files /dev/null and b/core/assets/model_r/left_arm.png differ diff --git a/core/assets/model_r/left_arrow.png b/core/assets/model_r/left_arrow.png new file mode 100644 index 0000000000..c2f2c85ca3 Binary files /dev/null and b/core/assets/model_r/left_arrow.png differ diff --git a/core/assets/model_r/lock.png b/core/assets/model_r/lock.png new file mode 100644 index 0000000000..1ac4f5088b Binary files /dev/null and b/core/assets/model_r/lock.png differ diff --git a/core/assets/model_r/param.png b/core/assets/model_r/param.png new file mode 100644 index 0000000000..a69ec43f1a Binary files /dev/null and b/core/assets/model_r/param.png differ diff --git a/core/assets/model_r/right_arm.png b/core/assets/model_r/right_arm.png new file mode 100644 index 0000000000..abb3117db1 Binary files /dev/null and b/core/assets/model_r/right_arm.png differ diff --git a/core/assets/model_r/right_arrow.png b/core/assets/model_r/right_arrow.png new file mode 100644 index 0000000000..24d28fd0ee Binary files /dev/null and b/core/assets/model_r/right_arrow.png differ diff --git a/core/assets/model_r/up_arrow.png b/core/assets/model_r/up_arrow.png new file mode 100644 index 0000000000..c7007d097b Binary files /dev/null and b/core/assets/model_r/up_arrow.png differ diff --git a/core/assets/model_r/user.png b/core/assets/model_r/user.png new file mode 100644 index 0000000000..fbd51f8ec5 Binary files /dev/null and b/core/assets/model_r/user.png differ diff --git a/core/assets/model_r/wallet.png b/core/assets/model_r/wallet.png new file mode 100644 index 0000000000..505515f855 Binary files /dev/null and b/core/assets/model_r/wallet.png differ diff --git a/core/assets/model_r/warning_left.png b/core/assets/model_r/warning_left.png new file mode 100644 index 0000000000..18dcfd5655 Binary files /dev/null and b/core/assets/model_r/warning_left.png differ diff --git a/core/assets/model_r/warning_right.png b/core/assets/model_r/warning_right.png new file mode 100644 index 0000000000..1705d4b3e6 Binary files /dev/null and b/core/assets/model_r/warning_right.png differ diff --git a/core/embed/extmod/modtrezorio/modtrezorio.c b/core/embed/extmod/modtrezorio/modtrezorio.c index b84a7c3cf0..237a152a7f 100644 --- a/core/embed/extmod/modtrezorio/modtrezorio.c +++ b/core/embed/extmod/modtrezorio/modtrezorio.c @@ -86,6 +86,7 @@ STATIC const mp_rom_map_elem_t mp_module_trezorio_globals_table[] = { {MP_ROM_QSTR(MP_QSTR_TOUCH_START), MP_ROM_INT((TOUCH_START >> 24) & 0xFFU)}, {MP_ROM_QSTR(MP_QSTR_TOUCH_MOVE), MP_ROM_INT((TOUCH_MOVE >> 24) & 0xFFU)}, {MP_ROM_QSTR(MP_QSTR_TOUCH_END), MP_ROM_INT((TOUCH_END >> 24) & 0xFFU)}, + #elif defined TREZOR_MODEL_1 || defined TREZOR_MODEL_R {MP_ROM_QSTR(MP_QSTR_BUTTON), MP_ROM_INT(BUTTON_IFACE)}, {MP_ROM_QSTR(MP_QSTR_BUTTON_PRESSED), @@ -94,8 +95,11 @@ STATIC const mp_rom_map_elem_t mp_module_trezorio_globals_table[] = { MP_ROM_INT((BTN_EVT_UP >> 24) & 0x3U)}, {MP_ROM_QSTR(MP_QSTR_BUTTON_LEFT), MP_ROM_INT(BTN_LEFT)}, {MP_ROM_QSTR(MP_QSTR_BUTTON_RIGHT), MP_ROM_INT(BTN_RIGHT)}, + #endif + {MP_ROM_QSTR(MP_QSTR_USB_CHECK), MP_ROM_INT(USB_DATA_IFACE)}, + {MP_ROM_QSTR(MP_QSTR_FlashOTP), MP_ROM_PTR(&mod_trezorio_FlashOTP_type)}, {MP_ROM_QSTR(MP_QSTR_USB), MP_ROM_PTR(&mod_trezorio_USB_type)}, diff --git a/core/embed/extmod/modtrezorui/font_unifont_bold_16.c b/core/embed/extmod/modtrezorui/font_unifont_bold_16.c new file mode 100644 index 0000000000..edc31eec97 --- /dev/null +++ b/core/embed/extmod/modtrezorui/font_unifont_bold_16.c @@ -0,0 +1,203 @@ +#include + +// clang-format off + +// - the first two bytes are width and height of the glyph +// - the third, fourth and fifth bytes are advance, bearingX and bearingY of the horizontal metrics of the glyph +// - the rest is packed 1-bit glyph data + +/* */ static const uint8_t Font_Unifont_Bold_16_glyph_32[] = { 0, 0, 7, 0, 0 }; // hand-changed to 7 to have 9px space between words +/* ! */ static const uint8_t Font_Unifont_Bold_16_glyph_33[] = { 2, 10, 7, 2, 10, 255, 252, 240 }; +/* " */ static const uint8_t Font_Unifont_Bold_16_glyph_34[] = { 6, 4, 7, 0, 12, 207, 60, 209, 0 }; +/* # */ static const uint8_t Font_Unifont_Bold_16_glyph_35[] = { 7, 10, 8, 0, 10, 54, 108, 223, 246, 205, 191, 236, 217, 176 }; +/* $ */ static const uint8_t Font_Unifont_Bold_16_glyph_36[] = { 7, 10, 8, 0, 10, 16, 251, 94, 183, 3, 181, 235, 124, 32 }; +/* % */ static const uint8_t Font_Unifont_Bold_16_glyph_37[] = { 7, 10, 8, 0, 10, 97, 102, 211, 65, 2, 11, 43, 150, 24 }; +/* & */ static const uint8_t Font_Unifont_Bold_16_glyph_38[] = { 8, 10, 8, 0, 10, 56, 108, 108, 104, 48, 107, 206, 204, 206, 122, 0 }; +/* ' */ static const uint8_t Font_Unifont_Bold_16_glyph_39[] = { 2, 4, 7, 2, 12, 253, 0 }; +/* ( */ static const uint8_t Font_Unifont_Bold_16_glyph_40[] = { 4, 12, 7, 2, 11, 54, 108, 204, 204, 198, 99, 0 }; +/* ) */ static const uint8_t Font_Unifont_Bold_16_glyph_41[] = { 4, 12, 7, 0, 11, 198, 99, 51, 51, 54, 108, 0 }; +/* * */ static const uint8_t Font_Unifont_Bold_16_glyph_42[] = { 7, 7, 8, 0, 8, 17, 37, 81, 197, 82, 68, 0 }; +/* + */ static const uint8_t Font_Unifont_Bold_16_glyph_43[] = { 7, 7, 8, 0, 8, 16, 32, 71, 241, 2, 4, 0 }; +/* , */ static const uint8_t Font_Unifont_Bold_16_glyph_44[] = { 3, 4, 7, 1, 2, 237, 224 }; +/* - */ static const uint8_t Font_Unifont_Bold_16_glyph_45[] = { 5, 1, 7, 0, 5, 248 }; +/* . */ static const uint8_t Font_Unifont_Bold_16_glyph_46[] = { 2, 2, 7, 2, 2, 240 }; +/* / */ static const uint8_t Font_Unifont_Bold_16_glyph_47[] = { 6, 10, 7, 0, 10, 12, 49, 132, 48, 194, 24, 195, 0 }; +/* 0 */ static const uint8_t Font_Unifont_Bold_16_glyph_48[] = { 7, 10, 8, 0, 10, 56, 219, 30, 125, 122, 249, 227, 108, 112 }; +/* 1 */ static const uint8_t Font_Unifont_Bold_16_glyph_49[] = { 6, 10, 7, 0, 10, 51, 195, 12, 48, 195, 12, 51, 240 }; +/* 2 */ static const uint8_t Font_Unifont_Bold_16_glyph_50[] = { 7, 10, 8, 0, 10, 125, 143, 24, 48, 195, 12, 112, 193, 252 }; +/* 3 */ static const uint8_t Font_Unifont_Bold_16_glyph_51[] = { 7, 10, 8, 0, 10, 125, 143, 24, 49, 192, 193, 227, 198, 248 }; +/* 4 */ static const uint8_t Font_Unifont_Bold_16_glyph_52[] = { 7, 10, 8, 0, 10, 12, 120, 179, 100, 217, 191, 134, 12, 24 }; +/* 5 */ static const uint8_t Font_Unifont_Bold_16_glyph_53[] = { 7, 10, 8, 0, 10, 255, 131, 6, 15, 193, 193, 227, 198, 248 }; +/* 6 */ static const uint8_t Font_Unifont_Bold_16_glyph_54[] = { 7, 10, 8, 0, 10, 60, 195, 6, 15, 216, 241, 227, 198, 248 }; +/* 7 */ static const uint8_t Font_Unifont_Bold_16_glyph_55[] = { 6, 10, 7, 0, 10, 252, 48, 195, 24, 99, 12, 97, 128 }; +/* 8 */ static const uint8_t Font_Unifont_Bold_16_glyph_56[] = { 7, 10, 8, 0, 10, 125, 143, 30, 55, 216, 241, 227, 198, 248 }; +/* 9 */ static const uint8_t Font_Unifont_Bold_16_glyph_57[] = { 7, 10, 8, 0, 10, 125, 143, 30, 60, 111, 193, 131, 12, 240 }; +/* : */ static const uint8_t Font_Unifont_Bold_16_glyph_58[] = { 2, 7, 7, 2, 8, 240, 60 }; +/* ; */ static const uint8_t Font_Unifont_Bold_16_glyph_59[] = { 2, 9, 7, 2, 8, 240, 61, 128 }; +/* < */ static const uint8_t Font_Unifont_Bold_16_glyph_60[] = { 6, 9, 7, 0, 9, 12, 99, 24, 193, 131, 6, 12 }; +/* = */ static const uint8_t Font_Unifont_Bold_16_glyph_61[] = { 6, 5, 7, 0, 7, 252, 0, 0, 252 }; +/* > */ static const uint8_t Font_Unifont_Bold_16_glyph_62[] = { 6, 9, 7, 0, 9, 193, 131, 6, 12, 99, 24, 192 }; +/* ? */ static const uint8_t Font_Unifont_Bold_16_glyph_63[] = { 7, 10, 8, 0, 10, 125, 143, 24, 48, 195, 6, 0, 24, 48 }; +/* @ */ static const uint8_t Font_Unifont_Bold_16_glyph_64[] = { 7, 10, 8, 0, 10, 60, 134, 109, 187, 118, 237, 205, 64, 124 }; +/* A */ static const uint8_t Font_Unifont_Bold_16_glyph_65[] = { 7, 10, 8, 0, 10, 56, 249, 182, 60, 120, 255, 227, 199, 140 }; +/* B */ static const uint8_t Font_Unifont_Bold_16_glyph_66[] = { 7, 10, 8, 0, 10, 253, 143, 30, 63, 216, 241, 227, 199, 248 }; +/* C */ static const uint8_t Font_Unifont_Bold_16_glyph_67[] = { 7, 10, 8, 0, 10, 125, 143, 30, 12, 24, 48, 99, 198, 248 }; +/* D */ static const uint8_t Font_Unifont_Bold_16_glyph_68[] = { 7, 10, 8, 0, 10, 241, 155, 30, 60, 120, 241, 227, 205, 224 }; +/* E */ static const uint8_t Font_Unifont_Bold_16_glyph_69[] = { 6, 10, 7, 0, 10, 255, 12, 48, 251, 12, 48, 195, 240 }; +/* F */ static const uint8_t Font_Unifont_Bold_16_glyph_70[] = { 6, 10, 7, 0, 10, 255, 12, 48, 195, 236, 48, 195, 0 }; +/* G */ static const uint8_t Font_Unifont_Bold_16_glyph_71[] = { 7, 10, 8, 0, 10, 125, 143, 30, 12, 27, 241, 227, 206, 236 }; +/* H */ static const uint8_t Font_Unifont_Bold_16_glyph_72[] = { 7, 10, 8, 0, 10, 199, 143, 30, 63, 248, 241, 227, 199, 140 }; +/* I */ static const uint8_t Font_Unifont_Bold_16_glyph_73[] = { 6, 10, 7, 0, 10, 252, 195, 12, 48, 195, 12, 51, 240 }; +/* J */ static const uint8_t Font_Unifont_Bold_16_glyph_74[] = { 7, 10, 8, 0, 10, 62, 24, 48, 96, 193, 131, 102, 204, 240 }; +/* K */ static const uint8_t Font_Unifont_Bold_16_glyph_75[] = { 7, 10, 8, 0, 10, 199, 143, 54, 207, 28, 62, 110, 207, 140 }; +/* L */ static const uint8_t Font_Unifont_Bold_16_glyph_76[] = { 6, 10, 7, 0, 10, 195, 12, 48, 195, 12, 48, 195, 240 }; +/* M */ static const uint8_t Font_Unifont_Bold_16_glyph_77[] = { 7, 10, 8, 0, 10, 131, 143, 31, 127, 250, 245, 227, 199, 140 }; +/* N */ static const uint8_t Font_Unifont_Bold_16_glyph_78[] = { 7, 10, 8, 0, 10, 199, 207, 158, 189, 122, 245, 231, 207, 140 }; +/* O */ static const uint8_t Font_Unifont_Bold_16_glyph_79[] = { 7, 10, 8, 0, 10, 125, 143, 30, 60, 120, 241, 227, 198, 248 }; +/* P */ static const uint8_t Font_Unifont_Bold_16_glyph_80[] = { 7, 10, 8, 0, 10, 253, 143, 30, 60, 127, 176, 96, 193, 128 }; +/* Q */ static const uint8_t Font_Unifont_Bold_16_glyph_81[] = { 7, 11, 8, 0, 10, 125, 143, 30, 60, 120, 241, 235, 238, 112, 24 }; +/* R */ static const uint8_t Font_Unifont_Bold_16_glyph_82[] = { 7, 10, 8, 0, 10, 253, 143, 30, 60, 127, 182, 102, 199, 140 }; +/* S */ static const uint8_t Font_Unifont_Bold_16_glyph_83[] = { 7, 10, 8, 0, 10, 125, 143, 31, 7, 135, 131, 227, 198, 248 }; +/* T */ static const uint8_t Font_Unifont_Bold_16_glyph_84[] = { 7, 10, 8, 0, 10, 254, 48, 96, 193, 131, 6, 12, 24, 48 }; +/* U */ static const uint8_t Font_Unifont_Bold_16_glyph_85[] = { 7, 10, 8, 0, 10, 199, 143, 30, 60, 120, 241, 227, 238, 248 }; +/* V */ static const uint8_t Font_Unifont_Bold_16_glyph_86[] = { 7, 10, 8, 0, 10, 199, 143, 26, 38, 205, 155, 20, 40, 112 }; +/* W */ static const uint8_t Font_Unifont_Bold_16_glyph_87[] = { 7, 10, 8, 0, 10, 199, 143, 30, 189, 122, 245, 255, 238, 136 }; +/* X */ static const uint8_t Font_Unifont_Bold_16_glyph_88[] = { 7, 10, 8, 0, 10, 199, 141, 179, 99, 135, 27, 54, 199, 140 }; +/* Y */ static const uint8_t Font_Unifont_Bold_16_glyph_89[] = { 6, 10, 7, 0, 10, 207, 60, 243, 73, 227, 12, 48, 192 }; +/* Z */ static const uint8_t Font_Unifont_Bold_16_glyph_90[] = { 7, 10, 8, 0, 10, 254, 12, 56, 225, 135, 28, 112, 193, 252 }; +/* [ */ static const uint8_t Font_Unifont_Bold_16_glyph_91[] = { 4, 12, 7, 2, 11, 252, 204, 204, 204, 204, 207, 0 }; +/* \ */ static const uint8_t Font_Unifont_Bold_16_glyph_92[] = { 6, 10, 7, 0, 10, 195, 6, 8, 48, 193, 6, 12, 48 }; +/* ] */ static const uint8_t Font_Unifont_Bold_16_glyph_93[] = { 4, 12, 7, 0, 11, 243, 51, 51, 51, 51, 63, 0 }; +/* ^ */ static const uint8_t Font_Unifont_Bold_16_glyph_94[] = { 7, 3, 8, 0, 12, 56, 219, 24 }; +/* _ */ static const uint8_t Font_Unifont_Bold_16_glyph_95[] = { 7, 1, 7, 0, 0, 254 }; +/* ` */ static const uint8_t Font_Unifont_Bold_16_glyph_96[] = { 4, 3, 7, 0, 13, 198, 48 }; +/* a */ static const uint8_t Font_Unifont_Bold_16_glyph_97[] = { 7, 8, 8, 0, 8, 125, 140, 27, 252, 120, 243, 187, 0 }; +/* b */ static const uint8_t Font_Unifont_Bold_16_glyph_98[] = { 7, 11, 8, 0, 11, 193, 131, 6, 238, 120, 241, 227, 199, 207, 112 }; +/* c */ static const uint8_t Font_Unifont_Bold_16_glyph_99[] = { 7, 8, 8, 0, 8, 125, 143, 30, 12, 24, 241, 190, 0 }; +/* d */ static const uint8_t Font_Unifont_Bold_16_glyph_100[] = { 7, 11, 8, 0, 11, 6, 12, 25, 188, 248, 241, 227, 199, 156, 216 }; +/* e */ static const uint8_t Font_Unifont_Bold_16_glyph_101[] = { 7, 8, 8, 0, 8, 125, 143, 31, 252, 24, 241, 190, 0 }; +/* f */ static const uint8_t Font_Unifont_Bold_16_glyph_102[] = { 7, 11, 8, 0, 11, 30, 96, 193, 143, 230, 12, 24, 48, 97, 240 }; +/* g */ static const uint8_t Font_Unifont_Bold_16_glyph_103[] = { 7, 11, 8, 0, 9, 2, 247, 54, 108, 207, 8, 62, 207, 141, 240 }; +/* h */ static const uint8_t Font_Unifont_Bold_16_glyph_104[] = { 7, 11, 8, 0, 11, 193, 131, 6, 238, 120, 241, 227, 199, 143, 24 }; +/* i */ static const uint8_t Font_Unifont_Bold_16_glyph_105[] = { 6, 11, 7, 0, 11, 48, 192, 60, 48, 195, 12, 48, 207, 192 }; +/* j */ static const uint8_t Font_Unifont_Bold_16_glyph_106[] = { 6, 13, 8, 0, 11, 24, 96, 31, 12, 48, 195, 12, 60, 246, 112 }; +/* k */ static const uint8_t Font_Unifont_Bold_16_glyph_107[] = { 7, 11, 8, 0, 11, 193, 131, 6, 60, 251, 60, 120, 217, 159, 24 }; +/* l */ static const uint8_t Font_Unifont_Bold_16_glyph_108[] = { 6, 11, 7, 0, 11, 240, 195, 12, 48, 195, 12, 48, 207, 192 }; +/* m */ static const uint8_t Font_Unifont_Bold_16_glyph_109[] = { 7, 8, 8, 0, 8, 237, 175, 94, 189, 122, 245, 235, 0 }; +/* n */ static const uint8_t Font_Unifont_Bold_16_glyph_110[] = { 7, 8, 8, 0, 8, 221, 207, 30, 60, 120, 241, 227, 0 }; +/* o */ static const uint8_t Font_Unifont_Bold_16_glyph_111[] = { 7, 8, 8, 0, 8, 125, 143, 30, 60, 120, 241, 190, 0 }; +/* p */ static const uint8_t Font_Unifont_Bold_16_glyph_112[] = { 7, 10, 8, 0, 8, 221, 207, 30, 60, 120, 249, 238, 193, 128 }; +/* q */ static const uint8_t Font_Unifont_Bold_16_glyph_113[] = { 7, 10, 8, 0, 8, 119, 159, 30, 60, 120, 243, 187, 6, 12 }; +/* r */ static const uint8_t Font_Unifont_Bold_16_glyph_114[] = { 7, 8, 8, 0, 8, 221, 207, 30, 12, 24, 48, 96, 0 }; +/* s */ static const uint8_t Font_Unifont_Bold_16_glyph_115[] = { 7, 8, 8, 0, 8, 125, 143, 27, 129, 216, 241, 190, 0 }; +/* t */ static const uint8_t Font_Unifont_Bold_16_glyph_116[] = { 7, 10, 8, 0, 10, 48, 96, 199, 243, 6, 12, 24, 48, 60 }; +/* u */ static const uint8_t Font_Unifont_Bold_16_glyph_117[] = { 7, 8, 8, 0, 8, 199, 143, 30, 60, 120, 243, 187, 0 }; +/* v */ static const uint8_t Font_Unifont_Bold_16_glyph_118[] = { 7, 8, 8, 0, 8, 199, 143, 26, 38, 205, 142, 28, 0 }; +/* w */ static const uint8_t Font_Unifont_Bold_16_glyph_119[] = { 7, 8, 8, 0, 8, 199, 175, 94, 189, 122, 245, 182, 0 }; +/* x */ static const uint8_t Font_Unifont_Bold_16_glyph_120[] = { 7, 8, 8, 0, 8, 199, 141, 177, 195, 141, 177, 227, 0 }; +/* y */ static const uint8_t Font_Unifont_Bold_16_glyph_121[] = { 7, 10, 8, 0, 8, 199, 143, 30, 60, 109, 205, 131, 6, 248 }; +/* z */ static const uint8_t Font_Unifont_Bold_16_glyph_122[] = { 7, 8, 8, 0, 8, 254, 12, 56, 227, 142, 56, 127, 0 }; +/* { */ static const uint8_t Font_Unifont_Bold_16_glyph_123[] = { 5, 13, 7, 1, 11, 59, 24, 99, 51, 12, 49, 152, 195, 128 }; +/* | */ static const uint8_t Font_Unifont_Bold_16_glyph_124[] = { 2, 14, 7, 2, 12, 255, 255, 255, 240 }; +/* } */ static const uint8_t Font_Unifont_Bold_16_glyph_125[] = { 5, 13, 7, 0, 11, 225, 140, 198, 24, 102, 99, 12, 110, 0 }; +/* ~ */ static const uint8_t Font_Unifont_Bold_16_glyph_126[] = { 7, 3, 8, 0, 11, 99, 118, 48 }; + +const uint8_t Font_Unifont_Bold_16_glyph_nonprintable[] = { 7, 10, 8, 0, 10, 130, 112, 231, 207, 60, 249, 255, 231, 207 }; + +const uint8_t * const Font_Unifont_Bold_16[126 + 1 - 32] = { + Font_Unifont_Bold_16_glyph_32, + Font_Unifont_Bold_16_glyph_33, + Font_Unifont_Bold_16_glyph_34, + Font_Unifont_Bold_16_glyph_35, + Font_Unifont_Bold_16_glyph_36, + Font_Unifont_Bold_16_glyph_37, + Font_Unifont_Bold_16_glyph_38, + Font_Unifont_Bold_16_glyph_39, + Font_Unifont_Bold_16_glyph_40, + Font_Unifont_Bold_16_glyph_41, + Font_Unifont_Bold_16_glyph_42, + Font_Unifont_Bold_16_glyph_43, + Font_Unifont_Bold_16_glyph_44, + Font_Unifont_Bold_16_glyph_45, + Font_Unifont_Bold_16_glyph_46, + Font_Unifont_Bold_16_glyph_47, + Font_Unifont_Bold_16_glyph_48, + Font_Unifont_Bold_16_glyph_49, + Font_Unifont_Bold_16_glyph_50, + Font_Unifont_Bold_16_glyph_51, + Font_Unifont_Bold_16_glyph_52, + Font_Unifont_Bold_16_glyph_53, + Font_Unifont_Bold_16_glyph_54, + Font_Unifont_Bold_16_glyph_55, + Font_Unifont_Bold_16_glyph_56, + Font_Unifont_Bold_16_glyph_57, + Font_Unifont_Bold_16_glyph_58, + Font_Unifont_Bold_16_glyph_59, + Font_Unifont_Bold_16_glyph_60, + Font_Unifont_Bold_16_glyph_61, + Font_Unifont_Bold_16_glyph_62, + Font_Unifont_Bold_16_glyph_63, + Font_Unifont_Bold_16_glyph_64, + Font_Unifont_Bold_16_glyph_65, + Font_Unifont_Bold_16_glyph_66, + Font_Unifont_Bold_16_glyph_67, + Font_Unifont_Bold_16_glyph_68, + Font_Unifont_Bold_16_glyph_69, + Font_Unifont_Bold_16_glyph_70, + Font_Unifont_Bold_16_glyph_71, + Font_Unifont_Bold_16_glyph_72, + Font_Unifont_Bold_16_glyph_73, + Font_Unifont_Bold_16_glyph_74, + Font_Unifont_Bold_16_glyph_75, + Font_Unifont_Bold_16_glyph_76, + Font_Unifont_Bold_16_glyph_77, + Font_Unifont_Bold_16_glyph_78, + Font_Unifont_Bold_16_glyph_79, + Font_Unifont_Bold_16_glyph_80, + Font_Unifont_Bold_16_glyph_81, + Font_Unifont_Bold_16_glyph_82, + Font_Unifont_Bold_16_glyph_83, + Font_Unifont_Bold_16_glyph_84, + Font_Unifont_Bold_16_glyph_85, + Font_Unifont_Bold_16_glyph_86, + Font_Unifont_Bold_16_glyph_87, + Font_Unifont_Bold_16_glyph_88, + Font_Unifont_Bold_16_glyph_89, + Font_Unifont_Bold_16_glyph_90, + Font_Unifont_Bold_16_glyph_91, + Font_Unifont_Bold_16_glyph_92, + Font_Unifont_Bold_16_glyph_93, + Font_Unifont_Bold_16_glyph_94, + Font_Unifont_Bold_16_glyph_95, + Font_Unifont_Bold_16_glyph_96, + Font_Unifont_Bold_16_glyph_97, + Font_Unifont_Bold_16_glyph_98, + Font_Unifont_Bold_16_glyph_99, + Font_Unifont_Bold_16_glyph_100, + Font_Unifont_Bold_16_glyph_101, + Font_Unifont_Bold_16_glyph_102, + Font_Unifont_Bold_16_glyph_103, + Font_Unifont_Bold_16_glyph_104, + Font_Unifont_Bold_16_glyph_105, + Font_Unifont_Bold_16_glyph_106, + Font_Unifont_Bold_16_glyph_107, + Font_Unifont_Bold_16_glyph_108, + Font_Unifont_Bold_16_glyph_109, + Font_Unifont_Bold_16_glyph_110, + Font_Unifont_Bold_16_glyph_111, + Font_Unifont_Bold_16_glyph_112, + Font_Unifont_Bold_16_glyph_113, + Font_Unifont_Bold_16_glyph_114, + Font_Unifont_Bold_16_glyph_115, + Font_Unifont_Bold_16_glyph_116, + Font_Unifont_Bold_16_glyph_117, + Font_Unifont_Bold_16_glyph_118, + Font_Unifont_Bold_16_glyph_119, + Font_Unifont_Bold_16_glyph_120, + Font_Unifont_Bold_16_glyph_121, + Font_Unifont_Bold_16_glyph_122, + Font_Unifont_Bold_16_glyph_123, + Font_Unifont_Bold_16_glyph_124, + Font_Unifont_Bold_16_glyph_125, + Font_Unifont_Bold_16_glyph_126, +}; diff --git a/core/embed/extmod/modtrezorui/font_unifont_bold_16.h b/core/embed/extmod/modtrezorui/font_unifont_bold_16.h new file mode 100644 index 0000000000..b91f2cad7d --- /dev/null +++ b/core/embed/extmod/modtrezorui/font_unifont_bold_16.h @@ -0,0 +1,7 @@ +#include + +#if TREZOR_FONT_BPP != 1 +#error Wrong TREZOR_FONT_BPP (expected 1) +#endif +extern const uint8_t* const Font_Unifont_Bold_16[126 + 1 - 32]; +extern const uint8_t Font_Unifont_Bold_16_glyph_nonprintable[]; diff --git a/core/embed/extmod/modtrezorui/font_unifont_regular_16.c b/core/embed/extmod/modtrezorui/font_unifont_regular_16.c new file mode 100644 index 0000000000..6d326b9dc6 --- /dev/null +++ b/core/embed/extmod/modtrezorui/font_unifont_regular_16.c @@ -0,0 +1,203 @@ +#include + +// clang-format off + +// - the first two bytes are width and height of the glyph +// - the third, fourth and fifth bytes are advance, bearingX and bearingY of the horizontal metrics of the glyph +// - the rest is packed 1-bit glyph data + +/* */ static const uint8_t Font_Unifont_Regular_16_glyph_32[] = { 0, 0, 8, 0, 0 }; +/* ! */ static const uint8_t Font_Unifont_Regular_16_glyph_33[] = { 1, 10, 7, 3, 10, 254, 192 }; +/* " */ static const uint8_t Font_Unifont_Regular_16_glyph_34[] = { 5, 4, 7, 1, 12, 140, 99, 16 }; +/* # */ static const uint8_t Font_Unifont_Regular_16_glyph_35[] = { 6, 10, 7, 0, 10, 36, 146, 127, 73, 47, 228, 146, 64 }; +/* $ */ static const uint8_t Font_Unifont_Regular_16_glyph_36[] = { 7, 10, 7, 0, 10, 16, 250, 76, 135, 3, 132, 201, 124, 32 }; +/* % */ static const uint8_t Font_Unifont_Regular_16_glyph_37[] = { 7, 10, 7, 0, 10, 99, 42, 83, 65, 2, 11, 41, 83, 24 }; +/* & */ static const uint8_t Font_Unifont_Regular_16_glyph_38[] = { 7, 10, 7, 0, 10, 56, 137, 17, 67, 10, 98, 194, 140, 228 }; +/* ' */ static const uint8_t Font_Unifont_Regular_16_glyph_39[] = { 1, 4, 7, 3, 12, 240 }; +/* ( */ static const uint8_t Font_Unifont_Regular_16_glyph_40[] = { 3, 12, 7, 2, 11, 41, 73, 36, 137, 16 }; +/* ) */ static const uint8_t Font_Unifont_Regular_16_glyph_41[] = { 3, 12, 7, 1, 11, 137, 18, 73, 41, 64 }; +/* * */ static const uint8_t Font_Unifont_Regular_16_glyph_42[] = { 7, 7, 7, 0, 8, 17, 37, 81, 197, 82, 68, 0 }; +/* + */ static const uint8_t Font_Unifont_Regular_16_glyph_43[] = { 7, 7, 7, 0, 8, 16, 32, 71, 241, 2, 4, 0 }; +/* , */ static const uint8_t Font_Unifont_Regular_16_glyph_44[] = { 2, 4, 7, 2, 2, 214, 0 }; +/* - */ static const uint8_t Font_Unifont_Regular_16_glyph_45[] = { 4, 1, 7, 1, 5, 240 }; +/* . */ static const uint8_t Font_Unifont_Regular_16_glyph_46[] = { 2, 2, 7, 2, 2, 240 }; +/* / */ static const uint8_t Font_Unifont_Regular_16_glyph_47[] = { 6, 10, 7, 0, 10, 4, 16, 132, 16, 130, 16, 130, 0 }; +/* 0 */ static const uint8_t Font_Unifont_Regular_16_glyph_48[] = { 6, 10, 7, 0, 10, 49, 40, 99, 150, 156, 97, 72, 192 }; +/* 1 */ static const uint8_t Font_Unifont_Regular_16_glyph_49[] = { 5, 10, 7, 1, 10, 35, 40, 66, 16, 132, 39, 192 }; +/* 2 */ static const uint8_t Font_Unifont_Regular_16_glyph_50[] = { 6, 10, 7, 0, 10, 122, 24, 65, 24, 132, 32, 131, 240 }; +/* 3 */ static const uint8_t Font_Unifont_Regular_16_glyph_51[] = { 6, 10, 7, 0, 10, 122, 24, 65, 56, 16, 97, 133, 224 }; +/* 4 */ static const uint8_t Font_Unifont_Regular_16_glyph_52[] = { 6, 10, 7, 0, 10, 8, 98, 146, 138, 47, 194, 8, 32 }; +/* 5 */ static const uint8_t Font_Unifont_Regular_16_glyph_53[] = { 6, 10, 7, 0, 10, 254, 8, 32, 248, 16, 65, 133, 224 }; +/* 6 */ static const uint8_t Font_Unifont_Regular_16_glyph_54[] = { 6, 10, 7, 0, 10, 57, 8, 32, 250, 24, 97, 133, 224 }; +/* 7 */ static const uint8_t Font_Unifont_Regular_16_glyph_55[] = { 6, 10, 7, 0, 10, 252, 16, 66, 8, 33, 4, 16, 64 }; +/* 8 */ static const uint8_t Font_Unifont_Regular_16_glyph_56[] = { 6, 10, 7, 0, 10, 122, 24, 97, 122, 24, 97, 133, 224 }; +/* 9 */ static const uint8_t Font_Unifont_Regular_16_glyph_57[] = { 6, 10, 7, 0, 10, 122, 24, 97, 124, 16, 65, 9, 192 }; +/* : */ static const uint8_t Font_Unifont_Regular_16_glyph_58[] = { 2, 7, 7, 2, 8, 240, 60 }; +/* ; */ static const uint8_t Font_Unifont_Regular_16_glyph_59[] = { 2, 9, 7, 2, 8, 240, 53, 128 }; +/* < */ static const uint8_t Font_Unifont_Regular_16_glyph_60[] = { 5, 9, 7, 1, 9, 8, 136, 136, 32, 130, 8 }; +/* = */ static const uint8_t Font_Unifont_Regular_16_glyph_61[] = { 6, 5, 7, 0, 7, 252, 0, 0, 252 }; +/* > */ static const uint8_t Font_Unifont_Regular_16_glyph_62[] = { 5, 9, 7, 0, 9, 130, 8, 32, 136, 136, 128 }; +/* ? */ static const uint8_t Font_Unifont_Regular_16_glyph_63[] = { 6, 10, 7, 0, 10, 122, 24, 65, 8, 65, 0, 16, 64 }; +/* @ */ static const uint8_t Font_Unifont_Regular_16_glyph_64[] = { 6, 10, 7, 0, 10, 57, 25, 107, 166, 154, 103, 64, 240 }; +/* A */ static const uint8_t Font_Unifont_Regular_16_glyph_65[] = { 6, 10, 7, 0, 10, 49, 36, 161, 135, 248, 97, 134, 16 }; +/* B */ static const uint8_t Font_Unifont_Regular_16_glyph_66[] = { 6, 10, 7, 0, 10, 250, 24, 97, 250, 24, 97, 135, 224 }; +/* C */ static const uint8_t Font_Unifont_Regular_16_glyph_67[] = { 6, 10, 7, 0, 10, 122, 24, 96, 130, 8, 33, 133, 224 }; +/* D */ static const uint8_t Font_Unifont_Regular_16_glyph_68[] = { 6, 10, 7, 0, 10, 242, 40, 97, 134, 24, 97, 139, 192 }; +/* E */ static const uint8_t Font_Unifont_Regular_16_glyph_69[] = { 6, 10, 7, 0, 10, 254, 8, 32, 250, 8, 32, 131, 240 }; +/* F */ static const uint8_t Font_Unifont_Regular_16_glyph_70[] = { 6, 10, 7, 0, 10, 254, 8, 32, 250, 8, 32, 130, 0 }; +/* G */ static const uint8_t Font_Unifont_Regular_16_glyph_71[] = { 6, 10, 7, 0, 10, 122, 24, 96, 130, 120, 97, 141, 208 }; +/* H */ static const uint8_t Font_Unifont_Regular_16_glyph_72[] = { 6, 10, 7, 0, 10, 134, 24, 97, 254, 24, 97, 134, 16 }; +/* I */ static const uint8_t Font_Unifont_Regular_16_glyph_73[] = { 5, 10, 7, 1, 10, 249, 8, 66, 16, 132, 39, 192 }; +/* J */ static const uint8_t Font_Unifont_Regular_16_glyph_74[] = { 7, 10, 7, 0, 10, 62, 16, 32, 64, 129, 2, 68, 136, 224 }; +/* K */ static const uint8_t Font_Unifont_Regular_16_glyph_75[] = { 6, 10, 7, 0, 10, 134, 41, 40, 195, 10, 36, 138, 16 }; +/* L */ static const uint8_t Font_Unifont_Regular_16_glyph_76[] = { 6, 10, 7, 0, 10, 130, 8, 32, 130, 8, 32, 131, 240 }; +/* M */ static const uint8_t Font_Unifont_Regular_16_glyph_77[] = { 6, 10, 7, 0, 10, 134, 28, 243, 182, 216, 97, 134, 16 }; +/* N */ static const uint8_t Font_Unifont_Regular_16_glyph_78[] = { 6, 10, 7, 0, 10, 135, 28, 105, 166, 89, 99, 142, 16 }; +/* O */ static const uint8_t Font_Unifont_Regular_16_glyph_79[] = { 6, 10, 7, 0, 10, 122, 24, 97, 134, 24, 97, 133, 224 }; +/* P */ static const uint8_t Font_Unifont_Regular_16_glyph_80[] = { 6, 10, 7, 0, 10, 250, 24, 97, 250, 8, 32, 130, 0 }; +/* Q */ static const uint8_t Font_Unifont_Regular_16_glyph_81[] = { 7, 11, 7, 0, 10, 121, 10, 20, 40, 80, 161, 90, 204, 240, 24 }; +/* R */ static const uint8_t Font_Unifont_Regular_16_glyph_82[] = { 6, 10, 7, 0, 10, 250, 24, 97, 250, 72, 162, 134, 16 }; +/* S */ static const uint8_t Font_Unifont_Regular_16_glyph_83[] = { 6, 10, 7, 0, 10, 122, 24, 96, 96, 96, 97, 133, 224 }; +/* T */ static const uint8_t Font_Unifont_Regular_16_glyph_84[] = { 7, 10, 7, 0, 10, 254, 32, 64, 129, 2, 4, 8, 16, 32 }; +/* U */ static const uint8_t Font_Unifont_Regular_16_glyph_85[] = { 6, 10, 7, 0, 10, 134, 24, 97, 134, 24, 97, 133, 224 }; +/* V */ static const uint8_t Font_Unifont_Regular_16_glyph_86[] = { 7, 10, 7, 0, 10, 131, 6, 10, 36, 72, 138, 20, 16, 32 }; +/* W */ static const uint8_t Font_Unifont_Regular_16_glyph_87[] = { 6, 10, 7, 0, 10, 134, 24, 97, 182, 220, 243, 134, 16 }; +/* X */ static const uint8_t Font_Unifont_Regular_16_glyph_88[] = { 6, 10, 7, 0, 10, 134, 20, 146, 48, 196, 146, 134, 16 }; +/* Y */ static const uint8_t Font_Unifont_Regular_16_glyph_89[] = { 7, 10, 7, 0, 10, 131, 5, 18, 34, 130, 4, 8, 16, 32 }; +/* Z */ static const uint8_t Font_Unifont_Regular_16_glyph_90[] = { 6, 10, 7, 0, 10, 252, 16, 66, 16, 132, 32, 131, 240 }; +/* [ */ static const uint8_t Font_Unifont_Regular_16_glyph_91[] = { 3, 12, 7, 3, 11, 242, 73, 36, 146, 112 }; +/* \ */ static const uint8_t Font_Unifont_Regular_16_glyph_92[] = { 6, 10, 7, 0, 10, 130, 4, 8, 32, 65, 2, 4, 16 }; +/* ] */ static const uint8_t Font_Unifont_Regular_16_glyph_93[] = { 3, 12, 7, 0, 11, 228, 146, 73, 36, 240 }; +/* ^ */ static const uint8_t Font_Unifont_Regular_16_glyph_94[] = { 6, 3, 7, 0, 12, 49, 40, 64 }; +/* _ */ static const uint8_t Font_Unifont_Regular_16_glyph_95[] = { 7, 1, 7, 0, 0, 254 }; +/* ` */ static const uint8_t Font_Unifont_Regular_16_glyph_96[] = { 3, 3, 7, 1, 13, 136, 128 }; +/* a */ static const uint8_t Font_Unifont_Regular_16_glyph_97[] = { 6, 8, 7, 0, 8, 122, 16, 95, 134, 24, 221, 0 }; +/* b */ static const uint8_t Font_Unifont_Regular_16_glyph_98[] = { 6, 11, 7, 0, 11, 130, 8, 46, 198, 24, 97, 135, 27, 128 }; +/* c */ static const uint8_t Font_Unifont_Regular_16_glyph_99[] = { 6, 8, 7, 0, 8, 122, 24, 32, 130, 8, 94, 0 }; +/* d */ static const uint8_t Font_Unifont_Regular_16_glyph_100[] = { 6, 11, 7, 0, 11, 4, 16, 93, 142, 24, 97, 134, 55, 64 }; +/* e */ static const uint8_t Font_Unifont_Regular_16_glyph_101[] = { 6, 8, 7, 0, 8, 122, 24, 127, 130, 8, 94, 0 }; +/* f */ static const uint8_t Font_Unifont_Regular_16_glyph_102[] = { 5, 11, 7, 0, 11, 25, 8, 79, 144, 132, 33, 8 }; +/* g */ static const uint8_t Font_Unifont_Regular_16_glyph_103[] = { 6, 11, 7, 0, 9, 5, 216, 162, 137, 196, 30, 134, 23, 128 }; +/* h */ static const uint8_t Font_Unifont_Regular_16_glyph_104[] = { 6, 11, 7, 0, 11, 130, 8, 46, 198, 24, 97, 134, 24, 64 }; +/* i */ static const uint8_t Font_Unifont_Regular_16_glyph_105[] = { 5, 11, 7, 1, 11, 33, 0, 194, 16, 132, 33, 62 }; +/* j */ static const uint8_t Font_Unifont_Regular_16_glyph_106[] = { 5, 13, 7, 0, 11, 8, 64, 48, 132, 33, 8, 67, 38, 0 }; +/* k */ static const uint8_t Font_Unifont_Regular_16_glyph_107[] = { 6, 11, 7, 0, 11, 130, 8, 34, 146, 140, 40, 146, 40, 64 }; +/* l */ static const uint8_t Font_Unifont_Regular_16_glyph_108[] = { 5, 11, 7, 1, 11, 97, 8, 66, 16, 132, 33, 62 }; +/* m */ static const uint8_t Font_Unifont_Regular_16_glyph_109[] = { 7, 8, 7, 0, 8, 237, 38, 76, 153, 50, 100, 201, 0 }; +/* n */ static const uint8_t Font_Unifont_Regular_16_glyph_110[] = { 6, 8, 7, 0, 8, 187, 24, 97, 134, 24, 97, 0 }; +/* o */ static const uint8_t Font_Unifont_Regular_16_glyph_111[] = { 6, 8, 7, 0, 8, 122, 24, 97, 134, 24, 94, 0 }; +/* p */ static const uint8_t Font_Unifont_Regular_16_glyph_112[] = { 6, 10, 7, 0, 8, 187, 24, 97, 134, 28, 110, 130, 0 }; +/* q */ static const uint8_t Font_Unifont_Regular_16_glyph_113[] = { 6, 10, 7, 0, 8, 118, 56, 97, 134, 24, 221, 4, 16 }; +/* r */ static const uint8_t Font_Unifont_Regular_16_glyph_114[] = { 6, 8, 7, 0, 8, 187, 24, 96, 130, 8, 32, 0 }; +/* s */ static const uint8_t Font_Unifont_Regular_16_glyph_115[] = { 6, 8, 7, 0, 8, 122, 24, 24, 24, 24, 94, 0 }; +/* t */ static const uint8_t Font_Unifont_Regular_16_glyph_116[] = { 5, 10, 7, 0, 10, 33, 9, 242, 16, 132, 32, 192 }; +/* u */ static const uint8_t Font_Unifont_Regular_16_glyph_117[] = { 6, 8, 7, 0, 8, 134, 24, 97, 134, 24, 221, 0 }; +/* v */ static const uint8_t Font_Unifont_Regular_16_glyph_118[] = { 6, 8, 7, 0, 8, 134, 24, 82, 73, 35, 12, 0 }; +/* w */ static const uint8_t Font_Unifont_Regular_16_glyph_119[] = { 7, 8, 7, 0, 8, 131, 38, 76, 153, 50, 100, 182, 0 }; +/* x */ static const uint8_t Font_Unifont_Regular_16_glyph_120[] = { 6, 8, 7, 0, 8, 134, 20, 140, 49, 40, 97, 0 }; +/* y */ static const uint8_t Font_Unifont_Regular_16_glyph_121[] = { 6, 10, 7, 0, 8, 134, 24, 97, 133, 51, 65, 5, 224 }; +/* z */ static const uint8_t Font_Unifont_Regular_16_glyph_122[] = { 6, 8, 7, 0, 8, 252, 16, 132, 33, 8, 63, 0 }; +/* { */ static const uint8_t Font_Unifont_Regular_16_glyph_123[] = { 4, 13, 7, 1, 11, 52, 66, 36, 132, 34, 68, 48 }; +/* | */ static const uint8_t Font_Unifont_Regular_16_glyph_124[] = { 1, 14, 7, 3, 12, 255, 252 }; +/* } */ static const uint8_t Font_Unifont_Regular_16_glyph_125[] = { 4, 13, 7, 1, 11, 194, 36, 66, 18, 68, 34, 192 }; +/* ~ */ static const uint8_t Font_Unifont_Regular_16_glyph_126[] = { 7, 3, 7, 0, 11, 99, 38, 48 }; + +const uint8_t Font_Unifont_Regular_16_glyph_nonprintable[] = { 6, 10, 7, 0, 10, 133, 231, 190, 247, 190, 255, 239, 191 }; + +const uint8_t * const Font_Unifont_Regular_16[126 + 1 - 32] = { + Font_Unifont_Regular_16_glyph_32, + Font_Unifont_Regular_16_glyph_33, + Font_Unifont_Regular_16_glyph_34, + Font_Unifont_Regular_16_glyph_35, + Font_Unifont_Regular_16_glyph_36, + Font_Unifont_Regular_16_glyph_37, + Font_Unifont_Regular_16_glyph_38, + Font_Unifont_Regular_16_glyph_39, + Font_Unifont_Regular_16_glyph_40, + Font_Unifont_Regular_16_glyph_41, + Font_Unifont_Regular_16_glyph_42, + Font_Unifont_Regular_16_glyph_43, + Font_Unifont_Regular_16_glyph_44, + Font_Unifont_Regular_16_glyph_45, + Font_Unifont_Regular_16_glyph_46, + Font_Unifont_Regular_16_glyph_47, + Font_Unifont_Regular_16_glyph_48, + Font_Unifont_Regular_16_glyph_49, + Font_Unifont_Regular_16_glyph_50, + Font_Unifont_Regular_16_glyph_51, + Font_Unifont_Regular_16_glyph_52, + Font_Unifont_Regular_16_glyph_53, + Font_Unifont_Regular_16_glyph_54, + Font_Unifont_Regular_16_glyph_55, + Font_Unifont_Regular_16_glyph_56, + Font_Unifont_Regular_16_glyph_57, + Font_Unifont_Regular_16_glyph_58, + Font_Unifont_Regular_16_glyph_59, + Font_Unifont_Regular_16_glyph_60, + Font_Unifont_Regular_16_glyph_61, + Font_Unifont_Regular_16_glyph_62, + Font_Unifont_Regular_16_glyph_63, + Font_Unifont_Regular_16_glyph_64, + Font_Unifont_Regular_16_glyph_65, + Font_Unifont_Regular_16_glyph_66, + Font_Unifont_Regular_16_glyph_67, + Font_Unifont_Regular_16_glyph_68, + Font_Unifont_Regular_16_glyph_69, + Font_Unifont_Regular_16_glyph_70, + Font_Unifont_Regular_16_glyph_71, + Font_Unifont_Regular_16_glyph_72, + Font_Unifont_Regular_16_glyph_73, + Font_Unifont_Regular_16_glyph_74, + Font_Unifont_Regular_16_glyph_75, + Font_Unifont_Regular_16_glyph_76, + Font_Unifont_Regular_16_glyph_77, + Font_Unifont_Regular_16_glyph_78, + Font_Unifont_Regular_16_glyph_79, + Font_Unifont_Regular_16_glyph_80, + Font_Unifont_Regular_16_glyph_81, + Font_Unifont_Regular_16_glyph_82, + Font_Unifont_Regular_16_glyph_83, + Font_Unifont_Regular_16_glyph_84, + Font_Unifont_Regular_16_glyph_85, + Font_Unifont_Regular_16_glyph_86, + Font_Unifont_Regular_16_glyph_87, + Font_Unifont_Regular_16_glyph_88, + Font_Unifont_Regular_16_glyph_89, + Font_Unifont_Regular_16_glyph_90, + Font_Unifont_Regular_16_glyph_91, + Font_Unifont_Regular_16_glyph_92, + Font_Unifont_Regular_16_glyph_93, + Font_Unifont_Regular_16_glyph_94, + Font_Unifont_Regular_16_glyph_95, + Font_Unifont_Regular_16_glyph_96, + Font_Unifont_Regular_16_glyph_97, + Font_Unifont_Regular_16_glyph_98, + Font_Unifont_Regular_16_glyph_99, + Font_Unifont_Regular_16_glyph_100, + Font_Unifont_Regular_16_glyph_101, + Font_Unifont_Regular_16_glyph_102, + Font_Unifont_Regular_16_glyph_103, + Font_Unifont_Regular_16_glyph_104, + Font_Unifont_Regular_16_glyph_105, + Font_Unifont_Regular_16_glyph_106, + Font_Unifont_Regular_16_glyph_107, + Font_Unifont_Regular_16_glyph_108, + Font_Unifont_Regular_16_glyph_109, + Font_Unifont_Regular_16_glyph_110, + Font_Unifont_Regular_16_glyph_111, + Font_Unifont_Regular_16_glyph_112, + Font_Unifont_Regular_16_glyph_113, + Font_Unifont_Regular_16_glyph_114, + Font_Unifont_Regular_16_glyph_115, + Font_Unifont_Regular_16_glyph_116, + Font_Unifont_Regular_16_glyph_117, + Font_Unifont_Regular_16_glyph_118, + Font_Unifont_Regular_16_glyph_119, + Font_Unifont_Regular_16_glyph_120, + Font_Unifont_Regular_16_glyph_121, + Font_Unifont_Regular_16_glyph_122, + Font_Unifont_Regular_16_glyph_123, + Font_Unifont_Regular_16_glyph_124, + Font_Unifont_Regular_16_glyph_125, + Font_Unifont_Regular_16_glyph_126, +}; diff --git a/core/embed/extmod/modtrezorui/font_unifont_regular_16.h b/core/embed/extmod/modtrezorui/font_unifont_regular_16.h new file mode 100644 index 0000000000..f26f3f2c1b --- /dev/null +++ b/core/embed/extmod/modtrezorui/font_unifont_regular_16.h @@ -0,0 +1,7 @@ +#include + +#if TREZOR_FONT_BPP != 1 +#error Wrong TREZOR_FONT_BPP (expected 1) +#endif +extern const uint8_t* const Font_Unifont_Regular_16[126 + 1 - 32]; +extern const uint8_t Font_Unifont_Regular_16_glyph_nonprintable[]; diff --git a/core/embed/extmod/modtrezorui/fonts/font_pixeloperatormono_regular_8.c b/core/embed/extmod/modtrezorui/fonts/font_pixeloperatormono_regular_8.c index ab72c6e2fe..bda3997e3e 100644 --- a/core/embed/extmod/modtrezorui/fonts/font_pixeloperatormono_regular_8.c +++ b/core/embed/extmod/modtrezorui/fonts/font_pixeloperatormono_regular_8.c @@ -6,7 +6,7 @@ // - the third, fourth and fifth bytes are advance, bearingX and bearingY of the horizontal metrics of the glyph // - the rest is packed 1-bit glyph data -/* */ static const uint8_t Font_PixelOperatorMono_Regular_8_glyph_32[] = { 0, 0, 8, 0, 0 }; +/* */ static const uint8_t Font_PixelOperatorMono_Regular_8_glyph_32[] = { 0, 0, 7, 0, 0 }; // hand-changed to 7 to have 9px space between words /* ! */ static const uint8_t Font_PixelOperatorMono_Regular_8_glyph_33[] = { 1, 7, 7, 2, 7, 250 }; /* " */ static const uint8_t Font_PixelOperatorMono_Regular_8_glyph_34[] = { 3, 3, 7, 1, 7, 182, 128 }; /* # */ static const uint8_t Font_PixelOperatorMono_Regular_8_glyph_35[] = { 6, 6, 7, 0, 6, 75, 244, 146, 253, 32 }; diff --git a/core/embed/rust/librust_qstr.h b/core/embed/rust/librust_qstr.h index bd46aed270..ef4397d407 100644 --- a/core/embed/rust/librust_qstr.h +++ b/core/embed/rust/librust_qstr.h @@ -16,6 +16,8 @@ static void _librust_qstrs(void) { MP_QSTR_CONFIRMED; MP_QSTR_CANCELLED; MP_QSTR_INFO; + MP_QSTR_checked_index; + MP_QSTR_choices; MP_QSTR_confirm_action; MP_QSTR_confirm_blob; MP_QSTR_confirm_properties; @@ -23,38 +25,50 @@ static void _librust_qstrs(void) { MP_QSTR_confirm_joint_total; MP_QSTR_confirm_modify_fee; MP_QSTR_confirm_modify_output; + MP_QSTR_confirm_output; + MP_QSTR_confirm_output_r; + MP_QSTR_confirm_payment_request; MP_QSTR_confirm_reset_device; + MP_QSTR_confirm_recovery; MP_QSTR_confirm_text; + MP_QSTR_confirm_total; + MP_QSTR_confirm_total_r; MP_QSTR_confirm_value; MP_QSTR_confirm_with_info; - MP_QSTR_confirm_recovery; - MP_QSTR_show_checklist; - MP_QSTR_show_error; - MP_QSTR_show_qr; - MP_QSTR_show_success; - MP_QSTR_show_warning; - MP_QSTR_show_info; - MP_QSTR_show_simple; - MP_QSTR_request_number; - MP_QSTR_request_pin; - MP_QSTR_request_passphrase; + MP_QSTR_confirm_word; + MP_QSTR_pin_confirm_action; MP_QSTR_request_bip39; + MP_QSTR_request_number; + MP_QSTR_request_passphrase; + MP_QSTR_request_pin; MP_QSTR_request_slip39; MP_QSTR_select_word; MP_QSTR_select_word_count; + MP_QSTR_share_words; + MP_QSTR_show_checklist; + MP_QSTR_show_error; MP_QSTR_show_group_share_success; + MP_QSTR_show_info; + MP_QSTR_show_qr; MP_QSTR_show_remaining_shares; + MP_QSTR_show_success; + MP_QSTR_show_simple; + MP_QSTR_show_warning; MP_QSTR_show_share_words; + MP_QSTR_request_word_count; + MP_QSTR_request_word_bip39; + MP_QSTR_tutorial; MP_QSTR_attach_timer_fn; - MP_QSTR_touch_event; - MP_QSTR_button_event; - MP_QSTR_timer; - MP_QSTR_paint; - MP_QSTR_request_complete_repaint; - MP_QSTR_trace; MP_QSTR_bounds; + MP_QSTR_button_event; MP_QSTR_page_count; + MP_QSTR_paint; + MP_QSTR_place; + MP_QSTR_request_complete_repaint; + MP_QSTR_touch_event; + MP_QSTR_timer; + MP_QSTR_trace; MP_QSTR_title; MP_QSTR_subtitle; @@ -90,4 +104,9 @@ static void _librust_qstrs(void) { MP_QSTR_active; MP_QSTR_info_button; MP_QSTR_time_ms; + MP_QSTR_fee_amount; + MP_QSTR_fee_rate_amount; + MP_QSTR_total_label; + MP_QSTR_fee_label; + MP_QSTR_truncated_address; } diff --git a/core/embed/rust/src/micropython/buffer.rs b/core/embed/rust/src/micropython/buffer.rs index aba1288f79..96f3264ebe 100644 --- a/core/embed/rust/src/micropython/buffer.rs +++ b/core/embed/rust/src/micropython/buffer.rs @@ -16,6 +16,7 @@ use super::ffi; /// /// Given the above assumptions about MicroPython strings, working with /// StrBuffers in Rust is safe. +#[derive(Debug, Clone)] pub struct StrBuffer { ptr: *const u8, len: usize, diff --git a/core/embed/rust/src/trace.rs b/core/embed/rust/src/trace.rs index 6af52b82c9..0627f0520e 100644 --- a/core/embed/rust/src/trace.rs +++ b/core/embed/rust/src/trace.rs @@ -1,3 +1,8 @@ +use heapless::String; + +#[cfg(feature = "model_tr")] +use crate::ui::model_tr::component::ButtonPos; + /// Visitor passed into `Trace` types. pub trait Tracer { fn int(&mut self, i: i64); @@ -6,12 +11,37 @@ pub trait Tracer { fn symbol(&mut self, name: &str); fn open(&mut self, name: &str); fn field(&mut self, name: &str, value: &dyn Trace); + fn title(&mut self, title: &str); + fn button(&mut self, button: &str); + fn content_flag(&mut self); + fn kw_pair(&mut self, key: &str, value: &str); fn close(&mut self); } +// Identifiers for tagging various parts of the Trace +// message - so that things like title or the main screen +// content can be read in debug mode by micropython. +pub const TITLE_TAG: &str = " **TITLE** "; +pub const BTN_TAG: &str = " **BTN** "; +pub const CONTENT_TAG: &str = " **CONTENT** "; +// For when the button is not used +pub const EMPTY_BTN: &str = "---"; + /// Value that can describe own structure and data using the `Tracer` interface. pub trait Trace { - fn trace(&self, d: &mut dyn Tracer); + fn trace(&self, t: &mut dyn Tracer); + /// Describes what happens when a certain button is triggered. + #[cfg(feature = "model_tr")] + fn get_btn_action(&self, _pos: ButtonPos) -> String<25> { + "Default".into() + } + /// Report actions for all three buttons in easy-to-parse format. + #[cfg(feature = "model_tr")] + fn report_btn_actions(&self, t: &mut dyn Tracer) { + t.kw_pair("left_action", &self.get_btn_action(ButtonPos::Left)); + t.kw_pair("middle_action", &self.get_btn_action(ButtonPos::Middle)); + t.kw_pair("right_action", &self.get_btn_action(ButtonPos::Right)); + } } impl Trace for &[u8] { @@ -26,6 +56,12 @@ impl Trace for &[u8; N] { } } +impl Trace for String { + fn trace(&self, t: &mut dyn Tracer) { + t.string(&self[..]) + } +} + impl Trace for &str { fn trace(&self, t: &mut dyn Tracer) { t.string(self); @@ -68,24 +104,54 @@ mod tests { } fn symbol(&mut self, name: &str) { - self.extend(name.as_bytes()) + self.string("<"); + self.string(name); + self.string(">"); } fn open(&mut self, name: &str) { - self.extend(b"<"); - self.extend(name.as_bytes()); - self.extend(b" "); + self.string("<"); + self.string(name); + self.string(" "); } fn field(&mut self, name: &str, value: &dyn Trace) { - self.extend(name.as_bytes()); - self.extend(b":"); + self.string(name); + self.string(":"); value.trace(self); - self.extend(b" "); + self.string(" "); + } + + /// Mark the string as a title/header. + fn title(&mut self, title: &str) { + self.string(TITLE_TAG); + self.string(title); + self.string(TITLE_TAG); + } + + /// Mark the string as a button content. + fn button(&mut self, button: &str) { + self.string(BTN_TAG); + self.string(button); + self.string(BTN_TAG); + } + + // Mark the following as content visible on the screen, + // until it is called next time. + fn content_flag(&mut self) { + self.string(CONTENT_TAG); + } + + /// Key-value pair for easy parsing + fn kw_pair(&mut self, key: &str, value: &str) { + self.string(key); + self.string("::"); + self.string(value); + self.string(","); // mostly for human readability } fn close(&mut self) { - self.extend(b">") + self.string(">") } } } diff --git a/core/embed/rust/src/trezorhal/bip39.rs b/core/embed/rust/src/trezorhal/bip39.rs index b205eef193..01957a14c3 100644 --- a/core/embed/rust/src/trezorhal/bip39.rs +++ b/core/embed/rust/src/trezorhal/bip39.rs @@ -44,6 +44,25 @@ pub fn word_completion_mask(prefix: &str) -> u32 { unsafe { ffi::mnemonic_word_completion_mask(prefix.as_ptr() as _, prefix.len() as _) } } +/// Returns all possible letters that form a valid word together with some +/// prefix. +pub fn get_available_letters(prefix: &str) -> impl Iterator { + const CHARS: [char; 26] = [ + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', + 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', + ]; + + let mask = word_completion_mask(prefix); + CHARS + .iter() + .filter(move |ch| bitmask_from_char(ch) & mask != 0) + .copied() +} + +fn bitmask_from_char(ch: &char) -> u32 { + 1 << (*ch as u8 - b'a') +} + pub struct Wordlist(&'static [*const cty::c_char]); impl Wordlist { @@ -190,4 +209,35 @@ mod tests { .collect::>(); assert_eq!(result, expected_result); } + + #[test] + fn test_word_completion_mask() { + let result = word_completion_mask("ab"); + assert_eq!(result, 0b101000100100100000001); + let result = word_completion_mask("zoo"); + assert_eq!(result, 0b0); + } + + #[test] + fn test_get_available_letters() { + let expected_result = vec!['a', 'i', 'l', 'o', 's', 'u']; + let result = get_available_letters("ab").collect::>(); + assert_eq!(result, expected_result); + + let expected_result = vec!['a', 'e', 'i', 'o', 'u']; + let result = get_available_letters("str").collect::>(); + assert_eq!(result, expected_result); + + let result = get_available_letters("zoo").collect::>(); + assert_eq!(result.len(), 0); + } + + #[test] + fn test_bitmask_from_char() { + assert_eq!(bitmask_from_char(&'a'), 0b1); + assert_eq!(bitmask_from_char(&'b'), 0b10); + assert_eq!(bitmask_from_char(&'c'), 0b100); + assert_eq!(bitmask_from_char(&'m'), 0b1000000000000); + assert_eq!(bitmask_from_char(&'z'), 0b10000000000000000000000000); + } } diff --git a/core/embed/rust/src/trezorhal/random.rs b/core/embed/rust/src/trezorhal/random.rs index 8f64b07734..f1a659c732 100644 --- a/core/embed/rust/src/trezorhal/random.rs +++ b/core/embed/rust/src/trezorhal/random.rs @@ -9,3 +9,22 @@ pub fn shuffle(slice: &mut [T]) { slice.swap(i, j); } } + +pub fn uniform_between(min: u32, max: u32) -> u32 { + assert!(max > min); + uniform(max - min + 1) + min +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn uniform_between_test() { + for _ in 0..10 { + assert!((10..=11).contains(&uniform_between(10, 11))); + assert!((10..=12).contains(&uniform_between(10, 12))); + assert!((256..=512).contains(&uniform_between(256, 512))); + } + } +} diff --git a/core/embed/rust/src/ui/component/base.rs b/core/embed/rust/src/ui/component/base.rs index f4e0c0d3ca..3d164c98c4 100644 --- a/core/embed/rust/src/ui/component/base.rs +++ b/core/embed/rust/src/ui/component/base.rs @@ -83,6 +83,10 @@ impl Child { self.component } + pub fn inner_mut(&mut self) -> &mut T { + &mut self.component + } + /// Access inner component mutably, track whether a paint call has been /// requested, and propagate the flag upwards the component tree. pub fn mutate(&mut self, ctx: &mut EventCtx, component_func: F) -> U diff --git a/core/embed/rust/src/ui/component/pad.rs b/core/embed/rust/src/ui/component/pad.rs index 70a79707c5..7642ebe359 100644 --- a/core/embed/rust/src/ui/component/pad.rs +++ b/core/embed/rust/src/ui/component/pad.rs @@ -18,6 +18,11 @@ impl Pad { } } + pub fn with_clear(mut self) -> Self { + self.clear = true; + self + } + pub fn place(&mut self, area: Rect) { self.area = area; } diff --git a/core/embed/rust/src/ui/component/paginated.rs b/core/embed/rust/src/ui/component/paginated.rs index 70f6ac17a2..1755191720 100644 --- a/core/embed/rust/src/ui/component/paginated.rs +++ b/core/embed/rust/src/ui/component/paginated.rs @@ -14,7 +14,9 @@ pub enum PageMsg { } pub trait Paginate { + /// How many pages of content are there in total? fn page_count(&mut self) -> usize; + /// Navigate to the given page. fn change_page(&mut self, active_page: usize); } @@ -27,6 +29,11 @@ where let mut page_count = 1; // There's always at least one page. let mut char_offset = 0; + // Make sure we're starting from the beginning. + self.set_char_offset(char_offset); + + // Looping through the content and counting pages + // until we finally fit. loop { let fit = self.layout_content(&mut TextNoOp); match fit { @@ -57,6 +64,8 @@ where // Make sure we're starting from the beginning. self.set_char_offset(char_offset); + // Looping through the content until we arrive at + // the wanted page. while active_page < to_page { let fit = self.layout_content(&mut TextNoOp); match fit { diff --git a/core/embed/rust/src/ui/component/text/common.rs b/core/embed/rust/src/ui/component/text/common.rs new file mode 100644 index 0000000000..4e35cdb719 --- /dev/null +++ b/core/embed/rust/src/ui/component/text/common.rs @@ -0,0 +1,105 @@ +use crate::ui::{component::EventCtx, util::ResultExt}; +use heapless::String; + +/// Reified editing operations of `TextBox`. +/// +/// Note: This does not contain all supported editing operations, only the ones +/// we currently use. +pub enum TextEdit { + ReplaceLast(char), + Append(char), +} + +/// Wraps a character buffer of maximum length `L` and provides text editing +/// operations over it. Text ops usually take a `EventCtx` to request a paint +/// pass in case of any state modification. +pub struct TextBox { + text: String, +} + +impl TextBox { + /// Create a new `TextBox` with content `text`. + pub fn new(text: String) -> Self { + Self { text } + } + + /// Create an empty `TextBox`. + pub fn empty() -> Self { + Self::new(String::new()) + } + + pub fn content(&self) -> &str { + &self.text + } + + pub fn len(&self) -> usize { + self.text.len() + } + + pub fn is_empty(&self) -> bool { + self.text.is_empty() + } + + pub fn is_full(&self) -> bool { + self.text.len() == self.text.capacity() + } + + /// Delete the last character of content, if any. + pub fn delete_last(&mut self, ctx: &mut EventCtx) { + let changed = self.text.pop().is_some(); + if changed { + ctx.request_paint(); + } + } + + /// Replaces the last character of the content with `ch`. If the content is + /// empty, `ch` is appended. + pub fn replace_last(&mut self, ctx: &mut EventCtx, ch: char) { + let previous = self.text.pop(); + self.text + .push(ch) + .assert_if_debugging_ui("TextBox has zero capacity"); + let changed = previous != Some(ch); + if changed { + ctx.request_paint(); + } + } + + /// Append `ch` at the end of the content. + pub fn append(&mut self, ctx: &mut EventCtx, ch: char) { + self.text.push(ch).assert_if_debugging_ui("TextBox is full"); + ctx.request_paint(); + } + + /// Append `slice` at the end of the content. + pub fn append_slice(&mut self, ctx: &mut EventCtx, slice: &str) { + self.text + .push_str(slice) + .assert_if_debugging_ui("TextBox is full"); + ctx.request_paint(); + } + + /// Replace the textbox content with `text`. + pub fn replace(&mut self, ctx: &mut EventCtx, text: &str) { + if self.text != text { + self.text.clear(); + self.text + .push_str(text) + .assert_if_debugging_ui("TextBox is full"); + ctx.request_paint(); + } + } + + /// Clear the textbox content. + pub fn clear(&mut self, ctx: &mut EventCtx) { + self.replace(ctx, ""); + } + + /// Apply a editing operation to the text buffer. + pub fn apply(&mut self, ctx: &mut EventCtx, edit: TextEdit) { + match edit { + TextEdit::ReplaceLast(char) => self.replace_last(ctx, char), + TextEdit::Append(char) => self.append(ctx, char), + } + } +} diff --git a/core/embed/rust/src/ui/component/text/formatted.rs b/core/embed/rust/src/ui/component/text/formatted.rs index 69017662d8..93574abd9a 100644 --- a/core/embed/rust/src/ui/component/text/formatted.rs +++ b/core/embed/rust/src/ui/component/text/formatted.rs @@ -8,23 +8,27 @@ use heapless::LinearMap; use crate::ui::{ component::{Component, Event, EventCtx, Never}, display::{Color, Font}, - geometry::Rect, + geometry::{Rect}, }; use super::layout::{ - LayoutFit, LayoutSink, LineBreaking, Op, PageBreaking, TextLayout, TextRenderer, TextStyle, + LayoutFit, LayoutSink, LineBreaking, Op, TextLayout, TextRenderer, TextStyle, }; pub const MAX_ARGUMENTS: usize = 6; +#[derive(Clone)] pub struct FormattedText { layout: TextLayout, fonts: FormattedFonts, format: F, args: LinearMap<&'static str, T, MAX_ARGUMENTS>, + /// Keeps track of "cursor" position, so that we can paginate + /// by skipping this amount of characters from the beginning. char_offset: usize, } +#[derive(Clone)] pub struct FormattedFonts { /// Font used to format `{normal}`. pub normal: Font, @@ -75,11 +79,8 @@ impl FormattedText { self } - pub fn with_page_breaking(mut self, page_breaking: PageBreaking) -> Self { - self.layout.style.page_breaking = page_breaking; - self - } - + /// Equals to changing the page so that we know what + /// content to render next. pub fn set_char_offset(&mut self, char_offset: usize) { self.char_offset = char_offset; } @@ -98,15 +99,26 @@ where F: AsRef, T: AsRef, { + /// Tokenizing `self.format` and turning it into the list of `Op`s + /// which will be sent to `LayoutSink`. + /// It equals to painting the content when `sink` is `TextRenderer`. pub fn layout_content(&self, sink: &mut dyn LayoutSink) -> LayoutFit { + // Accounting for pagination by skipping the `char_offset` characters from the + // beginning. let mut cursor = self.layout.initial_cursor(); let mut ops = Op::skip_n_text_bytes( Tokenizer::new(self.format.as_ref()).flat_map(|arg| match arg { + // Normal text encountered Token::Literal(literal) => Some(Op::Text(literal)), + // Changing currently used font Token::Argument("mono") => Some(Op::Font(self.fonts.mono)), Token::Argument("bold") => Some(Op::Font(self.fonts.bold)), Token::Argument("normal") => Some(Op::Font(self.fonts.normal)), Token::Argument("demibold") => Some(Op::Font(self.fonts.demibold)), + // Text with argument + // e.g. `{address}`, .with("address", "abcd...") + // TODO: when arg is not found, we just do not display it, + // shouldn't we trigger some exception? Token::Argument(argument) => self .args .get(argument) @@ -157,7 +169,9 @@ pub mod trace { T: AsRef, { fn trace(&self, d: &mut dyn crate::trace::Tracer) { + d.content_flag(); self.0.layout_content(&mut TraceSink(d)); + d.content_flag(); } } } diff --git a/core/embed/rust/src/ui/component/text/layout.rs b/core/embed/rust/src/ui/component/text/layout.rs index 4c90634ab7..56ef2fbd88 100644 --- a/core/embed/rust/src/ui/component/text/layout.rs +++ b/core/embed/rust/src/ui/component/text/layout.rs @@ -106,18 +106,28 @@ impl TextLayout { self } + /// Baseline `Point` where we are starting to draw the text. pub fn initial_cursor(&self) -> Point { self.bounds.top_left() + Offset::y(self.style.text_font.text_height() + self.padding_top) } + /// Trying to fit the content on the current screen. pub fn fit_text(&self, text: &str) -> LayoutFit { self.layout_text(text, &mut self.initial_cursor(), &mut TextNoOp) } + /// Draw as much text as possible on the current screen. pub fn render_text(&self, text: &str) { self.layout_text(text, &mut self.initial_cursor(), &mut TextRenderer); } + /// Y coordinate of the bottom of the available space/bounds + pub fn bottom_y(&self) -> i16 { + (self.bounds.y1 - self.padding_bottom).max(self.bounds.y0) + } + + /// Perform some operations defined on `Op` for a list of those `Op`s + /// - e.g. changing the color, changing the font or rendering the text. pub fn layout_ops<'o>( mut self, ops: &mut dyn Iterator>, @@ -129,12 +139,16 @@ impl TextLayout { for op in ops { match op { + // Changing color Op::Color(color) => { self.style.text_color = color; } + // Changing font Op::Font(font) => { self.style.text_font = font; } + // Text - try to fit it on the current page and if it doesn't + // fit, return the appropriate OutOfBounds message Op::Text(text) => match self.layout_text(text, cursor, sink) { LayoutFit::Fitting { processed_chars, .. @@ -161,6 +175,9 @@ impl TextLayout { } } + /// Loop through the `text` and try to fit it on the current screen, + /// reporting events to `sink`, which may do something with them (e.g. draw + /// on screen). pub fn layout_text( &self, text: &str, @@ -168,11 +185,10 @@ impl TextLayout { sink: &mut dyn LayoutSink, ) -> LayoutFit { let init_cursor = *cursor; - let bottom = (self.bounds.y1 - self.padding_bottom).max(self.bounds.y0); let mut remaining_text = text; // Check if bounding box is high enough for at least one line. - if cursor.y > bottom { + if cursor.y > self.bottom_y() { sink.out_of_bounds(); return LayoutFit::OutOfBounds { processed_chars: 0, @@ -196,7 +212,12 @@ impl TextLayout { }; // Report the span at the cursor position. - sink.text(*cursor, self, &remaining_text[..span.length]); + // Not doing it when the span length is 0, as that + // means we encountered a newline/line-break, which we do not draw. + // Line-breaks are reported later. + if span.length > 0 { + sink.text(*cursor, self, &remaining_text[..span.length]); + } // Continue with the rest of the remaining_text. remaining_text = &remaining_text[span.length + span.skip_next_chars..]; @@ -212,7 +233,7 @@ impl TextLayout { sink.hyphen(*cursor, self); } // Check the amount of vertical space we have left. - if cursor.y + span.advance.y > bottom { + if cursor.y + span.advance.y > self.bottom_y() { if !remaining_text.is_empty() { // Append ellipsis to indicate more content is available, but only if we // haven't already appended a hyphen. @@ -252,6 +273,7 @@ impl TextLayout { } } + /// Overall height of the content, including paddings. fn layout_height(&self, init_cursor: Point, end_cursor: Point) -> i16 { self.padding_top + self.style.text_font.text_height() @@ -270,6 +292,8 @@ impl Dimensions for TextLayout { } } +/// Whether we can fit content on the current screen. +/// Knows how many characters got processed and how high the content is. pub enum LayoutFit { /// Entire content fits. Vertical size is returned in `height`. Fitting { processed_chars: usize, height: i16 }, @@ -278,6 +302,7 @@ pub enum LayoutFit { } impl LayoutFit { + /// How high is the processed/fitted content. pub fn height(&self) -> i16 { match self { LayoutFit::Fitting { height, .. } => *height, @@ -286,19 +311,33 @@ impl LayoutFit { } } +// TODO: LayoutSink could support even things like drawing icons +// or making custom x or y offsets from any position + /// Visitor for text segment operations. +/// Defines responses for certain kind of events encountered +/// when processing the content. pub trait LayoutSink { + /// Text should be processed. fn text(&mut self, _cursor: Point, _layout: &TextLayout, _text: &str) {} + /// Hyphen at the end of line. fn hyphen(&mut self, _cursor: Point, _layout: &TextLayout) {} + /// Ellipsis at the end of the page. fn ellipsis(&mut self, _cursor: Point, _layout: &TextLayout) {} + /// Line break - a newline. fn line_break(&mut self, _cursor: Point) {} + /// Content cannot fit on the screen. fn out_of_bounds(&mut self) {} } +/// `LayoutSink` without any functionality. +/// Used to consume events when counting pages +/// or navigating to a certain page number. pub struct TextNoOp; impl LayoutSink for TextNoOp {} +/// `LayoutSink` for rendering the content. pub struct TextRenderer; impl LayoutSink for TextRenderer { @@ -339,6 +378,7 @@ pub mod trace { use super::*; + /// `LayoutSink` for debugging purposes. pub struct TraceSink<'a>(pub &'a mut dyn crate::trace::Tracer); impl<'a> LayoutSink for TraceSink<'a> { @@ -360,6 +400,7 @@ pub mod trace { } } +/// Operations that can be done on FormattedText. #[derive(Copy, Clone, PartialEq, Eq)] pub enum Op<'a> { /// Render text with current color and font. @@ -371,6 +412,10 @@ pub enum Op<'a> { } impl<'a> Op<'a> { + /// Filtering the list of `Op`s to throw away all the content + /// (text, font change or other operations) before a specific byte/character + /// threshold. Used when showing the second, third... paginated page + /// to skip the first one, two... pages. pub fn skip_n_text_bytes( ops: impl Iterator>, skip_bytes: usize, @@ -392,19 +437,21 @@ impl<'a> Op<'a> { } } +/// Carries info about the content that was processed +/// on the current line. #[derive(Debug, PartialEq, Eq)] -struct Span { +pub struct Span { /// How many characters from the input text this span is laying out. - length: usize, + pub length: usize, /// How many chars from the input text should we skip before fitting the /// next span? - skip_next_chars: usize, + pub skip_next_chars: usize, /// By how much to offset the cursor after this span. If the vertical offset /// is bigger than zero, it means we are breaking the line. - advance: Offset, + pub advance: Offset, /// If we are breaking the line, should we insert a hyphen right after this /// span to indicate a word-break? - insert_hyphen_before_line_break: bool, + pub insert_hyphen_before_line_break: bool, } impl Span { @@ -463,7 +510,7 @@ impl Span { } found_any_whitespace = true; } else if span_width + char_width > max_width { - // Return the last breakpoint. + // Cannot fit on this line. Return the last breakpoint. return line; } else { let have_space_for_break = span_width + char_width + hyphen_width <= max_width; @@ -484,7 +531,7 @@ impl Span { span_width += char_width; } - // The whole text is fitting. + // The whole text is fitting on the current line. Self { length: text.len(), advance: Offset::x(span_width), diff --git a/core/embed/rust/src/ui/component/text/mod.rs b/core/embed/rust/src/ui/component/text/mod.rs index 86b1eb53c5..1b987672d9 100644 --- a/core/embed/rust/src/ui/component/text/mod.rs +++ b/core/embed/rust/src/ui/component/text/mod.rs @@ -1,3 +1,4 @@ +pub mod common; pub mod formatted; mod iter; pub mod layout; diff --git a/core/embed/rust/src/ui/component/text/paragraphs.rs b/core/embed/rust/src/ui/component/text/paragraphs.rs index 3f50d291d7..e8f1cd243d 100644 --- a/core/embed/rust/src/ui/component/text/paragraphs.rs +++ b/core/embed/rust/src/ui/component/text/paragraphs.rs @@ -239,11 +239,13 @@ pub mod trace { impl crate::trace::Trace for Paragraphs { fn trace(&self, t: &mut dyn crate::trace::Tracer) { t.open("Paragraphs"); + t.content_flag(); for (layout, content) in Self::visible_content(&self.source, &self.visible, self.offset) { layout.layout_text(content, &mut layout.initial_cursor(), &mut TraceSink(t)); t.string("\n"); } + t.content_flag(); t.close(); } } diff --git a/core/embed/rust/src/ui/debug.rs b/core/embed/rust/src/ui/debug.rs new file mode 100644 index 0000000000..92f1d8c3e6 --- /dev/null +++ b/core/embed/rust/src/ui/debug.rs @@ -0,0 +1,258 @@ +//! Including some useful debugging features, +//! like printing of the struct details. + +use heapless::String; + +use super::{ + component::{ + pad::Pad, + text::{ + common::TextBox, + layout::{Span, TextLayout}, + }, + }, + display::{Color, Font, Icon}, + geometry::{Grid, Insets, Offset, Point, Rect}, +}; +use crate::{micropython::buffer::StrBuffer, time::Duration}; + +#[cfg(feature = "model_tr")] +use super::model_tr::component::ButtonDetails; + +// NOTE: not defining a common trait, like +// Debug {fn print(&self);}, so that the trait does +// not need to be imported when using the +// print() function. It suits the use-case of being quickly +// able to use the print() for debugging and then delete it. + +/// TODO: find out how much storage these functions take +/// and probably hide them behind debug feature + +impl StrBuffer { + pub fn print(&self) { + println!("StrBuffer:: ", self.as_ref()); + } +} + +impl Duration { + pub fn print(&self) { + println!("Duration:: ", inttostr!(self.to_millis())); + } +} + +impl Point { + pub fn print(&self) { + println!( + "Point:: ", + "x: ", + inttostr!(self.x), + ", y: ", + inttostr!(self.y) + ); + } +} + +impl Rect { + pub fn print(&self) { + print!("Rect:: "); + println!(&self.corners_points()); + } + + pub fn corners_points(&self) -> String<30> { + build_string!( + 30, + "(", + inttostr!(self.x0), + ",", + inttostr!(self.y0), + "), (", + inttostr!(self.x1), + ",", + inttostr!(self.y1), + ")" + ) + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for Rect { + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.open("Rect"); + t.string(&self.corners_points()); + t.close(); + } +} + +impl Color { + pub fn print(&self) { + println!( + "Color:: ", + "R: ", + inttostr!(self.r()), + ", G: ", + inttostr!(self.g()), + ", B: ", + inttostr!(self.b()) + ); + } +} + +impl Font { + pub fn print(&self) { + println!("Font:: ", "text_height: ", inttostr!(self.text_height())); + } +} + +#[cfg(feature = "model_tr")] +impl> ButtonDetails { + pub fn print(&self) { + let text: String<20> = if let Some(text) = self.text.clone() { + text.as_ref().into() + } else { + "None".into() + }; + let icon_text: String<20> = if let Some(icon) = &self.icon { + icon.text.into() + } else { + "None".into() + }; + let force_width: String<20> = if let Some(force_width) = self.force_width { + inttostr!(force_width).into() + } else { + "None".into() + }; + println!( + "ButtonDetails:: ", + "text: ", + text.as_ref(), + ", icon_text: ", + icon_text.as_ref(), + ", with_outline: ", + booltostr!(self.with_outline), + ", with_arms: ", + booltostr!(self.with_arms), + ", force_width: ", + force_width.as_ref() + ); + } +} + +impl Offset { + pub fn print(&self) { + println!( + "Offset:: ", + "x: ", + inttostr!(self.x), + ", y: ", + inttostr!(self.y) + ); + } +} + +impl Insets { + pub fn print(&self) { + println!( + "Insets:: ", + "top: ", + inttostr!(self.top), + ", right: ", + inttostr!(self.right), + ", bottom: ", + inttostr!(self.bottom), + ", left: ", + inttostr!(self.left) + ); + } +} + +impl Grid { + pub fn print(&self) { + print!( + "Grid:: ", + "rows: ", + inttostr!(self.rows as i32), + ", cols: ", + inttostr!(self.cols as i32), + ", spacing: ", + inttostr!(self.spacing as i32) + ); + print!(", area: "); + self.area.print(); + } +} + +impl Icon { + pub fn dimension_str(&self) -> String<10> { + build_string!( + 10, + inttostr!(self.width() as i32), + "x", + inttostr!(self.height() as i32) + ) + } + + pub fn print(&self) { + println!( + "Icon:: ", + "text: ", + self.text, + ", width: ", + inttostr!(self.width() as i32), + ", height: ", + inttostr!(self.height() as i32) + ); + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for Icon { + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.open("Icon"); + t.string(self.text); + t.string(&self.dimension_str()); + t.close(); + } +} + +impl TextLayout { + pub fn print(&self) { + print!( + "TextLayout:: ", + "padding_top: ", + inttostr!(self.padding_top as i32), + ", padding_bottom: ", + inttostr!(self.padding_bottom as i32) + ); + print!(", bounds: "); + self.bounds.print(); + } +} + +impl Span { + pub fn print(&self) { + print!( + "Span:: ", + "length: ", + inttostr!(self.length as i32), + ", skip_next_chars: ", + inttostr!(self.skip_next_chars as i32), + ", insert_hyphen_before_line_break: ", + booltostr!(self.insert_hyphen_before_line_break) + ); + print!(", advance: "); + self.advance.print(); + } +} + +impl Pad { + pub fn print(&self) { + print!("Pad:: ", "area: "); + self.area.print(); + } +} + +impl TextBox { + pub fn print(&self) { + println!("TextBox:: ", "content: ", self.content()); + } +} diff --git a/core/embed/rust/src/ui/display/color.rs b/core/embed/rust/src/ui/display/color.rs new file mode 100644 index 0000000000..f318995944 --- /dev/null +++ b/core/embed/rust/src/ui/display/color.rs @@ -0,0 +1,80 @@ +use crate::ui::lerp::Lerp; + +#[derive(Copy, Clone, PartialEq, Eq)] +pub struct Color(u16); + +impl Color { + pub const fn from_u16(val: u16) -> Self { + Self(val) + } + + pub const fn rgb(r: u8, g: u8, b: u8) -> Self { + let r = (r as u16 & 0xF8) << 8; + let g = (g as u16 & 0xFC) << 3; + let b = (b as u16 & 0xF8) >> 3; + Self(r | g | b) + } + + pub const fn luminance(self) -> u32 { + ((self.r() as u32 * 299) / 1000) + + (self.g() as u32 * 587) / 1000 + + (self.b() as u32 * 114) / 1000 + } + + pub const fn r(self) -> u8 { + (self.0 >> 8) as u8 & 0xF8 + } + + pub const fn g(self) -> u8 { + (self.0 >> 3) as u8 & 0xFC + } + + pub const fn b(self) -> u8 { + (self.0 << 3) as u8 & 0xF8 + } + + pub fn to_u16(self) -> u16 { + self.0 + } + + pub fn hi_byte(self) -> u8 { + (self.to_u16() >> 8) as u8 + } + + pub fn lo_byte(self) -> u8 { + (self.to_u16() & 0xFF) as u8 + } + + pub fn negate(self) -> Self { + Self(!self.0) + } + + pub const fn white() -> Self { + Self::rgb(255, 255, 255) + } + + pub const fn black() -> Self { + Self::rgb(0, 0, 0) + } +} + +impl Lerp for Color { + fn lerp(a: Self, b: Self, t: f32) -> Self { + let r = u8::lerp(a.r(), b.r(), t); + let g = u8::lerp(a.g(), b.g(), t); + let b = u8::lerp(a.b(), b.b(), t); + Color::rgb(r, g, b) + } +} + +impl From for Color { + fn from(val: u16) -> Self { + Self(val) + } +} + +impl From for u16 { + fn from(val: Color) -> Self { + val.to_u16() + } +} diff --git a/core/embed/rust/src/ui/display/font.rs b/core/embed/rust/src/ui/display/font.rs new file mode 100644 index 0000000000..c3ffa02454 --- /dev/null +++ b/core/embed/rust/src/ui/display/font.rs @@ -0,0 +1,176 @@ +use crate::{ui::{constant, geometry::{Point, Offset, Rect}}, trezorhal::display}; +use core::slice; + +use super::{Color, get_color_table, pixeldata, set_window, get_offset}; + + +pub struct Glyph { + pub width: i16, + pub height: i16, + pub adv: i16, + pub bearing_x: i16, + pub bearing_y: i16, + data: &'static [u8], +} + +impl Glyph { + /// Construct a `Glyph` from a raw pointer. + /// + /// # Safety + /// + /// This function is unsafe because the caller has to guarantee that `data` + /// is pointing to a memory containing a valid glyph data, that is: + /// - contains valid glyph metadata + /// - data has appropriate size + /// - data must have static lifetime + pub unsafe fn load(data: *const u8) -> Self { + unsafe { + let width = *data.offset(0) as i16; + let height = *data.offset(1) as i16; + + let data_bits = constant::FONT_BPP * width * height; + + let data_bytes = if data_bits % 8 == 0 { + data_bits / 8 + } else { + (data_bits / 8) + 1 + }; + + Glyph { + width, + height, + adv: *data.offset(2) as i16, + bearing_x: *data.offset(3) as i16, + bearing_y: *data.offset(4) as i16, + data: slice::from_raw_parts(data.offset(5), data_bytes as usize), + } + } + } + + pub fn print(&self, pos: Point, colortable: [Color; 16]) -> i16 { + let bearing = Offset::new(self.bearing_x, -self.bearing_y); + let size = Offset::new(self.width, self.height); + let pos_adj = pos + bearing; + let r = Rect::from_top_left_and_size(pos_adj, size); + + let area = r.translate(get_offset()); + let window = area.clamp(constant::screen()); + + set_window(window); + + for y in window.y0..window.y1 { + for x in window.x0..window.x1 { + let p = Point::new(x, y); + let r = p - pos_adj; + let c = self.get_pixel_data(r); + pixeldata(colortable[c as usize]); + } + } + self.adv + } + + /// Returns 0 (black) or 15 (white). + pub fn unpack_bpp1(&self, a: i16) -> u8 { + let c_data = self.data[(a / 8) as usize]; + ((c_data >> (7 - (a % 8))) & 0x01) * 15 + } + + pub fn unpack_bpp2(&self, a: i16) -> u8 { + let c_data = self.data[(a / 4) as usize]; + ((c_data >> (6 - (a % 4) * 2)) & 0x03) * 5 + } + + pub fn unpack_bpp4(&self, a: i16) -> u8 { + let c_data = self.data[(a / 2) as usize]; + (c_data >> (4 - (a % 2) * 4)) & 0x0F + } + + pub fn unpack_bpp8(&self, a: i16) -> u8 { + let c_data = self.data[a as usize]; + c_data >> 4 + } + + pub fn get_pixel_data(&self, p: Offset) -> u8 { + let a = p.x + p.y * self.width; + + match constant::FONT_BPP { + 1 => self.unpack_bpp1(a), + 2 => self.unpack_bpp2(a), + 4 => self.unpack_bpp4(a), + 8 => self.unpack_bpp8(a), + _ => 0, + } + } +} + +/// Font constants. Keep in sync with FONT_ definitions in +/// `extmod/modtrezorui/fonts/fonts.h`. +#[derive(Copy, Clone, PartialEq, Eq)] +#[repr(u8)] +pub enum Font { + NORMAL = 1, + BOLD = 2, + MONO = 3, + DEMIBOLD = 5, +} + +impl From for i32 { + fn from(font: Font) -> i32 { + -(font as i32) + } +} + +impl Font { + pub fn text_width(self, text: &str) -> i16 { + display::text_width(text, self.into()) as i16 + } + + pub fn char_width(self, ch: char) -> i16 { + display::char_width(ch, self.into()) as i16 + } + + pub fn text_height(self) -> i16 { + display::text_height(self.into()) as i16 + } + + pub fn line_height(self) -> i16 { + constant::LINE_SPACE + self.text_height() + } + + pub fn get_glyph(self, char_byte: u8) -> Option { + let gl_data = display::get_char_glyph(char_byte, self.into()); + + if gl_data.is_null() { + return None; + } + unsafe { Some(Glyph::load(gl_data)) } + } + + pub fn display_text(self, text: &str, baseline: Point, fg_color: Color, bg_color: Color) { + let colortable = get_color_table(fg_color, bg_color); + let mut adv_total = 0; + for c in text.bytes() { + let g = self.get_glyph(c); + if let Some(gly) = g { + let adv = gly.print(baseline + Offset::new(adv_total, 0), colortable); + adv_total += adv; + } + } + } + + /// Get the length of the longest suffix from a given `text` + /// that will fit into the area `width` pixels wide. + pub fn longest_suffix(self, width: i16, text: &str) -> usize { + let mut text_width = 0; + for (chars_from_right, c) in text.chars().rev().enumerate() { + let c_width = self.char_width(c); + if text_width + c_width > width { + // Another character cannot be fitted, we're done. + return chars_from_right; + } + text_width += c_width; + } + + text.len() // it fits in its entirety + } +} diff --git a/core/embed/rust/src/ui/display/icon.rs b/core/embed/rust/src/ui/display/icon.rs new file mode 100644 index 0000000000..8eb1687211 --- /dev/null +++ b/core/embed/rust/src/ui/display/icon.rs @@ -0,0 +1,97 @@ +use crate::{ + trezorhal::display::ToifFormat, + ui::geometry::{Offset, Point, Rect}, +}; + +use super::{icon_rect, toif_info_ensure, Color}; + +/// Storing the icon together with its name +/// Needs to be a tuple-struct, so it can be made `const` +#[derive(Debug, Clone, Copy)] +pub struct IconAndName(&'static [u8], &'static str); + +impl IconAndName { + pub const fn new(icon: &'static [u8], name: &'static str) -> Self { + Self(icon, name) + } +} + +/// Holding icon data and allowing it to draw itself. +/// Lots of draw methods exist so that we can easily +/// "glue" the icon together with other elements +/// (text, display boundary, etc.) according to their position. +#[derive(Debug, Clone, Copy)] +pub struct Icon { + pub data: &'static [u8], + // Text is useful for debugging purposes. + pub text: &'static str, + // TODO: could include the info about "real" icon dimensions, + // accounting for the TOIF limitations (when we sometimes + // need to have empty row or column) - it could be + // erasing those empty rows/columns when we draw the icon. +} + +// TODO: consider merging it together with ToifInfo +impl Icon { + pub fn new(icon_and_name: IconAndName) -> Self { + Icon { + data: icon_and_name.0, + text: icon_and_name.1, + } + } + + fn toif_info(&self) -> (Offset, &[u8]) { + toif_info_ensure(self.data, ToifFormat::GrayScaleEH) + } + + pub fn width(&self) -> i16 { + self.toif_info().0.x as i16 + } + + pub fn height(&self) -> i16 { + self.toif_info().0.y as i16 + } + + pub fn toif_size(&self) -> Offset { + self.toif_info().0 + } + + pub fn toif_data(&self) -> &[u8] { + self.toif_info().1 + } + + /// Display icon at a specified Rectangle. + fn draw_icon_rect(&self, r: Rect, fg_color: Color, bg_color: Color) { + icon_rect(r, self.toif_data(), fg_color, bg_color); + } + + /// Display the icon with left top baseline Point. + pub fn draw_top_left(&self, baseline: Point, fg_color: Color, bg_color: Color) { + let r = Rect::from_top_left_and_size(baseline, self.toif_size()); + self.draw_icon_rect(r, fg_color, bg_color); + } + + /// Display the icon with right top baseline Point. + pub fn draw_top_right(&self, baseline: Point, fg_color: Color, bg_color: Color) { + let r = Rect::from_top_right_and_size(baseline, self.toif_size()); + self.draw_icon_rect(r, fg_color, bg_color); + } + + /// Display the icon with right bottom baseline Point. + pub fn draw_bottom_right(&self, baseline: Point, fg_color: Color, bg_color: Color) { + let r = Rect::from_bottom_right_and_size(baseline, self.toif_size()); + self.draw_icon_rect(r, fg_color, bg_color); + } + + /// Display the icon with left bottom baseline Point. + pub fn draw_bottom_left(&self, baseline: Point, fg_color: Color, bg_color: Color) { + let r = Rect::from_bottom_left_and_size(baseline, self.toif_size()); + self.draw_icon_rect(r, fg_color, bg_color); + } + + /// Display the icon around center Point. + pub fn draw_center(&self, center: Point, fg_color: Color, bg_color: Color) { + let r = Rect::from_center_and_size(center, self.toif_size()); + self.draw_icon_rect(r, fg_color, bg_color); + } +} diff --git a/core/embed/rust/src/ui/display/mod.rs b/core/embed/rust/src/ui/display/mod.rs index a4982f4682..4f5230273b 100644 --- a/core/embed/rust/src/ui/display/mod.rs +++ b/core/embed/rust/src/ui/display/mod.rs @@ -1,5 +1,8 @@ #[cfg(any(feature = "model_tt", feature = "model_tr"))] pub mod loader; +pub mod icon; +pub mod font; +pub mod color; use super::{ constant, @@ -24,10 +27,13 @@ use crate::{ }, ui::lerp::Lerp, }; -use core::slice; +// Reexports #[cfg(any(feature = "model_tt", feature = "model_tr"))] pub use loader::{loader, loader_indeterminate, LOADER_MAX, LOADER_MIN}; +pub use icon::{Icon, IconAndName}; +pub use font::{Font, Glyph}; +pub use color::Color; pub fn backlight() -> i32 { display::backlight(-1) @@ -55,6 +61,7 @@ pub fn fade_backlight(target: i32) { } } +/// Fill a whole rectangle with a specific color. pub fn rect_fill(r: Rect, fg_color: Color) { display::bar(r.x0, r.y0, r.width(), r.height(), fg_color.into()); } @@ -66,17 +73,22 @@ pub fn rect_stroke(r: Rect, fg_color: Color) { display::bar(r.x0 + r.width() - 1, r.y0, 1, r.height(), fg_color.into()); } +/// Draw a rectangle with rounded corners. pub fn rect_fill_rounded(r: Rect, fg_color: Color, bg_color: Color, radius: u8) { - assert!([2, 4, 8, 16].iter().any(|allowed| radius == *allowed)); - display::bar_radius( - r.x0, - r.y0, - r.width(), - r.height(), - fg_color.into(), - bg_color.into(), - radius, - ); + if radius == 1 { + rect_fill_rounded1(r, fg_color, bg_color); + } else { + assert!([2, 4, 8, 16].iter().any(|allowed| radius == *allowed)); + display::bar_radius( + r.x0, + r.y0, + r.width(), + r.height(), + fg_color.into(), + bg_color.into(), + radius, + ); + } } /// NOTE: Cannot start at odd x-coordinate. In this case icon is shifted 1px @@ -94,9 +106,15 @@ pub fn icon_top_left(top_left: Point, data: &[u8], fg_color: Color, bg_color: Co ); } +/// Display icon given a center Point. pub fn icon(center: Point, data: &[u8], fg_color: Color, bg_color: Color) { let (toif_size, toif_data) = toif_info_ensure(data, ToifFormat::GrayScaleEH); let r = Rect::from_center_and_size(center, toif_size); + icon_rect(r, toif_data, fg_color, bg_color); +} + +/// Display icon at a specified Rectangle, expects already sliced data without header. +pub fn icon_rect(r: Rect, toif_data: &[u8], fg_color: Color, bg_color: Color) { display::icon( r.x0, r.y0, @@ -179,35 +197,61 @@ fn toif_info_ensure(data: &[u8], format: ToifFormat) -> (Offset, &[u8]) { (size, payload) } -// Used on T1 only. +/// Filling a rectangle with a rounding of 1 pixel - removing the corners. pub fn rect_fill_rounded1(r: Rect, fg_color: Color, bg_color: Color) { - display::bar(r.x0, r.y0, r.width(), r.height(), fg_color.into()); - let corners = [ - r.top_left(), - r.top_right() - Offset::x(1), - r.bottom_right() - Offset::uniform(1), - r.bottom_left() - Offset::y(1), - ]; - for p in corners.iter() { - display::bar(p.x, p.y, 1, 1, bg_color.into()); + rect_fill(r, fg_color); + rect_fill_corners(r, bg_color); +} + +/// Creating a rectangular outline with a given radius/rounding. +pub fn rect_outline_rounded(r: Rect, fg_color: Color, bg_color: Color, radius: u8) { + // Painting a bigger rectangle with FG and inner smaller with BG + // to create the outline. + let inner_r = r.shrink(1); + if radius == 1 { + rect_fill_rounded(r, fg_color, bg_color, 1); + rect_fill(inner_r, bg_color); + } else if radius == 2 { + rect_fill_rounded(r, fg_color, bg_color, 2); + rect_fill_rounded(inner_r, bg_color, fg_color, 1); + } else if radius == 4 { + rect_fill_rounded(r, fg_color, bg_color, 4); + rect_fill_rounded(inner_r, bg_color, fg_color, 2); + rect_fill_corners(inner_r, bg_color); + } +} + +/// Filling all four corners of a rectangle with a given color. +pub fn rect_fill_corners(r: Rect, fg_color: Color) { + for p in r.corner_points().iter() { + paint_point(p, fg_color); } } #[derive(Copy, Clone, PartialEq, Eq)] -pub struct TextOverlay<'a> { +pub struct TextOverlay { area: Rect, - text: &'a str, + text: T, font: Font, } -impl<'a> TextOverlay<'a> { - pub fn new(text: &'a str, font: Font) -> Self { +impl> TextOverlay { + pub fn new(text: T, font: Font) -> Self { let area = Rect::zero(); Self { area, text, font } } + pub fn set_text(&mut self, text: T) { + self.text = text; + } + + pub fn get_text(&self) -> &T { + &self.text + } + + // baseline relative to the underlying render area pub fn place(&mut self, baseline: Point) { - let text_width = self.font.text_width(self.text); + let text_width = self.font.text_width(self.text.as_ref()); let text_height = self.font.text_height(); let text_area_start = baseline + Offset::new(-(text_width / 2), -text_height); @@ -226,7 +270,12 @@ impl<'a> TextOverlay<'a> { let p_rel = Point::new(p.x - self.area.x0, p.y - self.area.y0); - for g in self.text.bytes().filter_map(|c| self.font.get_glyph(c)) { + for g in self + .text + .as_ref() + .bytes() + .filter_map(|c| self.font.get_glyph(c)) + { let char_area = Rect::new( Point::new(tot_adv + g.bearing_x, g.height - g.bearing_y), Point::new(tot_adv + g.bearing_x + g.width, g.bearing_y), @@ -788,9 +837,9 @@ fn rect_rounded2_get_pixel( /// Optionally draws a text inside the rectangle and adjusts its color to match /// the fill. The coordinates of the text are specified in the TextOverlay /// struct. -pub fn bar_with_text_and_fill( +pub fn bar_with_text_and_fill>( area: Rect, - overlay: Option, + overlay: Option<&TextOverlay>, fg_color: Color, bg_color: Color, fill_from: i16, @@ -833,6 +882,19 @@ pub fn dotted_line(start: Point, width: i16, color: Color) { } } +/// Draws a horizontal line of pixels with a given step of pixels. +/// Giving `step=1` draws a full line. +pub fn dotted_line_horizontal(start: Point, width: i16, color: Color, step: usize) { + for x in (start.x..width).step_by(step) { + paint_point(&Point::new(x, start.y), color); + } +} + +/// Paints a pixel with a specific color on a given point. +pub fn paint_point(point: &Point, color: Color) { + display::bar(point.x, point.y, 1, 1, color.into()); +} + pub fn qrcode(center: Point, data: &str, max_size: u32, case_sensitive: bool) -> Result<(), Error> { qr::render_qrcode(center.x, center.y, data, max_size, case_sensitive) } @@ -904,244 +966,3 @@ pub fn get_color_table(fg_color: Color, bg_color: Color) -> [Color; 16] { table } - -pub struct Glyph { - pub width: i16, - pub height: i16, - pub adv: i16, - pub bearing_x: i16, - pub bearing_y: i16, - data: &'static [u8], -} - -impl Glyph { - /// Construct a `Glyph` from a raw pointer. - /// - /// # Safety - /// - /// This function is unsafe because the caller has to guarantee that `data` - /// is pointing to a memory containing a valid glyph data, that is: - /// - contains valid glyph metadata - /// - data has appropriate size - /// - data must have static lifetime - pub unsafe fn load(data: *const u8) -> Self { - unsafe { - let width = *data.offset(0) as i16; - let height = *data.offset(1) as i16; - - let data_bits = constant::FONT_BPP * width * height; - - let data_bytes = if data_bits % 8 == 0 { - data_bits / 8 - } else { - (data_bits / 8) + 1 - }; - - Glyph { - width, - height, - adv: *data.offset(2) as i16, - bearing_x: *data.offset(3) as i16, - bearing_y: *data.offset(4) as i16, - data: slice::from_raw_parts(data.offset(5), data_bytes as usize), - } - } - } - - pub fn print(&self, pos: Point, colortable: [Color; 16]) -> i16 { - let bearing = Offset::new(self.bearing_x, -self.bearing_y); - let size = Offset::new(self.width, self.height); - let pos_adj = pos + bearing; - let r = Rect::from_top_left_and_size(pos_adj, size); - - let area = r.translate(get_offset()); - let window = area.clamp(constant::screen()); - - set_window(window); - - for y in window.y0..window.y1 { - for x in window.x0..window.x1 { - let p = Point::new(x, y); - let r = p - pos_adj; - let c = self.get_pixel_data(r); - pixeldata(colortable[c as usize]); - } - } - self.adv - } - - pub fn unpack_bpp1(&self, a: i16) -> u8 { - let c_data = self.data[(a / 8) as usize]; - ((c_data >> (7 - (a % 8))) & 0x01) * 15 - } - - pub fn unpack_bpp2(&self, a: i16) -> u8 { - let c_data = self.data[(a / 4) as usize]; - ((c_data >> (6 - (a % 4) * 2)) & 0x03) * 5 - } - - pub fn unpack_bpp4(&self, a: i16) -> u8 { - let c_data = self.data[(a / 2) as usize]; - (c_data >> (4 - (a % 2) * 4)) & 0x0F - } - - pub fn unpack_bpp8(&self, a: i16) -> u8 { - let c_data = self.data[a as usize]; - c_data >> 4 - } - - pub fn get_pixel_data(&self, p: Offset) -> u8 { - let a = p.x + p.y * self.width; - - match constant::FONT_BPP { - 1 => self.unpack_bpp1(a), - 2 => self.unpack_bpp2(a), - 4 => self.unpack_bpp4(a), - 8 => self.unpack_bpp8(a), - _ => 0, - } - } -} - -/// Font constants. Keep in sync with FONT_ definitions in -/// `extmod/modtrezorui/fonts/fonts.h`. -#[derive(Copy, Clone, PartialEq, Eq)] -#[repr(u8)] -pub enum Font { - NORMAL = 1, - BOLD = 2, - MONO = 3, - DEMIBOLD = 5, -} - -impl From for i32 { - fn from(font: Font) -> i32 { - -(font as i32) - } -} - -impl Font { - pub fn text_width(self, text: &str) -> i16 { - display::text_width(text, self.into()) as i16 - } - - pub fn char_width(self, ch: char) -> i16 { - display::char_width(ch, self.into()) as i16 - } - - pub fn text_height(self) -> i16 { - display::text_height(self.into()) as i16 - } - - pub fn line_height(self) -> i16 { - constant::LINE_SPACE + self.text_height() - } - - pub fn get_glyph(self, char_byte: u8) -> Option { - let gl_data = display::get_char_glyph(char_byte, self.into()); - - if gl_data.is_null() { - return None; - } - unsafe { Some(Glyph::load(gl_data)) } - } - - pub fn display_text(self, text: &str, baseline: Point, fg_color: Color, bg_color: Color) { - let colortable = get_color_table(fg_color, bg_color); - let mut adv_total = 0; - for c in text.bytes() { - let g = self.get_glyph(c); - if let Some(gly) = g { - let adv = gly.print(baseline + Offset::new(adv_total, 0), colortable); - adv_total += adv; - } - } - } - - /// Get the length of the longest suffix from a given `text` - /// that will fit into the area `width` pixels wide. - pub fn longest_suffix(self, width: i16, text: &str) -> usize { - let mut text_width = 0; - for (chars_from_right, c) in text.chars().rev().enumerate() { - let c_width = self.char_width(c); - if text_width + c_width > width { - // Another character cannot be fitted, we're done. - return chars_from_right; - } - text_width += c_width; - } - - text.len() // it fits in its entirety - } -} - -#[derive(Copy, Clone, PartialEq, Eq)] -pub struct Color(u16); - -impl Color { - pub const fn from_u16(val: u16) -> Self { - Self(val) - } - - pub const fn rgb(r: u8, g: u8, b: u8) -> Self { - let r = (r as u16 & 0xF8) << 8; - let g = (g as u16 & 0xFC) << 3; - let b = (b as u16 & 0xF8) >> 3; - Self(r | g | b) - } - - pub const fn luminance(self) -> u32 { - ((self.r() as u32 * 299) / 1000) - + (self.g() as u32 * 587) / 1000 - + (self.b() as u32 * 114) / 1000 - } - - pub const fn r(self) -> u8 { - (self.0 >> 8) as u8 & 0xF8 - } - - pub const fn g(self) -> u8 { - (self.0 >> 3) as u8 & 0xFC - } - - pub const fn b(self) -> u8 { - (self.0 << 3) as u8 & 0xF8 - } - - pub fn to_u16(self) -> u16 { - self.0 - } - - pub fn hi_byte(self) -> u8 { - (self.to_u16() >> 8) as u8 - } - - pub fn lo_byte(self) -> u8 { - (self.to_u16() & 0xFF) as u8 - } - - pub fn negate(self) -> Self { - Self(!self.0) - } -} - -impl Lerp for Color { - fn lerp(a: Self, b: Self, t: f32) -> Self { - let r = u8::lerp(a.r(), b.r(), t); - let g = u8::lerp(a.g(), b.g(), t); - let b = u8::lerp(a.b(), b.b(), t); - Color::rgb(r, g, b) - } -} - -impl From for Color { - fn from(val: u16) -> Self { - Self(val) - } -} - -impl From for u16 { - fn from(val: Color) -> Self { - val.to_u16() - } -} diff --git a/core/embed/rust/src/ui/event.rs b/core/embed/rust/src/ui/event.rs index 0700335a86..fbf99c40f0 100644 --- a/core/embed/rust/src/ui/event.rs +++ b/core/embed/rust/src/ui/event.rs @@ -2,15 +2,20 @@ use crate::{error, ui::geometry::Point}; use core::convert::TryInto; #[derive(Copy, Clone, PartialEq, Eq)] +// TODO: delete Both and all its usage +// ButtonPos.hit() should not be used anymore pub enum PhysicalButton { Left, Right, + Both, } #[derive(Copy, Clone, PartialEq, Eq)] pub enum ButtonEvent { ButtonPressed(PhysicalButton), ButtonReleased(PhysicalButton), + HoldStarted, + HoldEnded, } impl ButtonEvent { @@ -18,6 +23,7 @@ impl ButtonEvent { let button = match button { 0 => PhysicalButton::Left, 1 => PhysicalButton::Right, + 2 => PhysicalButton::Both, _ => return Err(error::Error::OutOfRange), }; let result = match event { diff --git a/core/embed/rust/src/ui/geometry.rs b/core/embed/rust/src/ui/geometry.rs index 09703cb76a..19cfdc8e7a 100644 --- a/core/embed/rust/src/ui/geometry.rs +++ b/core/embed/rust/src/ui/geometry.rs @@ -136,7 +136,7 @@ impl From for Offset { /// 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)] +#[derive(Copy, Clone, Debug, PartialEq, Eq)] pub struct Point { pub x: i16, pub y: i16, @@ -233,6 +233,21 @@ impl Rect { } } + pub const fn from_top_right_and_size(p0: Point, size: Offset) -> Self { + let top_left = Point::new(p0.x - size.x, p0.y); + Self::from_top_left_and_size(top_left, size) + } + + pub const fn from_bottom_left_and_size(p0: Point, size: Offset) -> Self { + let top_left = Point::new(p0.x, p0.y - size.y); + Self::from_top_left_and_size(top_left, size) + } + + pub const fn from_bottom_right_and_size(p0: Point, size: Offset) -> Self { + let top_left = Point::new(p0.x - size.x, p0.y - size.y); + Self::from_top_left_and_size(top_left, size) + } + pub const fn from_center_and_size(p: Point, size: Offset) -> Self { Self { x0: p.x - size.x / 2, @@ -294,10 +309,20 @@ impl Rect { self.bottom_left().center(self.bottom_right()) } + pub const fn left_center(&self) -> Point { + self.bottom_left().center(self.top_left()) + } + + pub const fn right_center(&self) -> Point { + self.bottom_right().center(self.top_right()) + } + + /// Whether a `Point` is inside the `Rect`. pub const fn contains(&self, point: Point) -> bool { point.x >= self.x0 && point.x < self.x1 && point.y >= self.y0 && point.y < self.y1 } + /// Create a bigger `Rect` that contains both `self` and `other`. pub const fn union(&self, other: Self) -> Self { Self { x0: min(self.x0, other.x0), @@ -307,6 +332,8 @@ impl Rect { } } + /// Create a smaller `Rect` from the bigger one by moving + /// all the four sides closer to the center. pub const fn inset(&self, insets: Insets) -> Self { Self { x0: self.x0 + insets.left, @@ -316,6 +343,12 @@ impl Rect { } } + /// Move all the sides closer to the center by the same distance. + pub const fn shrink(&self, size: i16) -> Self { + self.inset(Insets::uniform(size)) + } + + /// Leave just the left side of a certain `width`. pub const fn cut_from_left(&self, width: i16) -> Self { Self { x0: self.x0, @@ -325,6 +358,7 @@ impl Rect { } } + /// Leave just the right side of a certain `width`. pub const fn cut_from_right(&self, width: i16) -> Self { Self { x0: self.x1 - width, @@ -334,6 +368,27 @@ impl Rect { } } + /// Make the `Rect` wider to the left side. + pub const fn extend_left(&self, width: i16) -> Self { + Self { + x0: self.x0 - width, + y0: self.y0, + x1: self.x1, + y1: self.y1, + } + } + + /// Make the `Rect` wider to the right side. + pub const fn extend_right(&self, width: i16) -> Self { + Self { + x0: self.x0, + y0: self.y0, + x1: self.x1 + width, + y1: self.y1, + } + } + + /// Split `Rect` into top and bottom, given the top one's `height`. pub const fn split_top(self, height: i16) -> (Self, Self) { let height = clamp(height, 0, self.height()); @@ -348,10 +403,12 @@ impl Rect { (top, bottom) } + /// Split `Rect` into top and bottom, given the bottom one's `height`. pub const fn split_bottom(self, height: i16) -> (Self, Self) { self.split_top(self.height() - height) } + /// Split `Rect` into left and right, given the left one's `width`. pub const fn split_left(self, width: i16) -> (Self, Self) { let width = clamp(width, 0, self.width()); @@ -366,10 +423,21 @@ impl Rect { (left, right) } + /// Split `Rect` into left and right, given the right one's `width`. pub const fn split_right(self, width: i16) -> (Self, Self) { self.split_left(self.width() - width) } + /// Split `Rect` into left, center and right, given the center one's + /// `width`. Center element is symmetric, left and right have the same + /// size. + pub const fn split_center(self, width: i16) -> (Self, Self, Self) { + let left_right_width = (self.width() - width) / 2; + let (left, center_right) = self.split_left(left_right_width); + let (center, right) = center_right.split_left(width); + (left, center, right) + } + pub const fn clamp(self, limit: Rect) -> Self { Self { x0: max(self.x0, limit.x0), @@ -387,6 +455,7 @@ impl Rect { } } + /// Moving `Rect` by the given offset. pub const fn translate(&self, offset: Offset) -> Self { Self { x0: self.x0 + offset.x, @@ -395,6 +464,16 @@ impl Rect { y1: self.y1 + offset.y, } } + + /// Get all four corner points. + pub fn corner_points(&self) -> [Point; 4] { + [ + self.top_left(), + self.top_right() - Offset::x(1), + self.bottom_right() - Offset::uniform(1), + self.bottom_left() - Offset::y(1), + ] + } } #[derive(Copy, Clone, PartialEq, Eq)] @@ -509,7 +588,7 @@ impl Grid { let cell_height = (self.area.height() - spacing_height) / nrows; // Not every area can be fully covered by equal-sized cells and spaces, there - // might be serveral pixels left unused. We'll distribute them by 1px to + // might be several pixels left unused. We'll distribute them by 1px to // the leftmost cells. let leftover_width = (self.area.width() - spacing_width) % ncols; let leftover_height = (self.area.height() - spacing_height) % nrows; diff --git a/core/embed/rust/src/ui/layout/obj.rs b/core/embed/rust/src/ui/layout/obj.rs index 62ded6b0c8..c605ba36b6 100644 --- a/core/embed/rust/src/ui/layout/obj.rs +++ b/core/embed/rust/src/ui/layout/obj.rs @@ -196,6 +196,14 @@ impl LayoutObj { unsafe { Gc::as_mut(&mut inner.root) }.obj_paint(); } + /// Place but do not paint. + /// Called before getting debug information about current screen. + fn obj_place(&self) { + let mut inner = self.inner.borrow_mut(); + // SAFETY: `inner.root` is unique because of the `inner.borrow_mut()`. + unsafe { Gc::as_mut(&mut inner.root) }.obj_place(constant::screen()); + } + /// Run a tracing pass over the component tree. Passed `callback` is called /// with each piece of tracing information. Panics in case the callback /// raises an exception. @@ -219,30 +227,51 @@ impl LayoutObj { } fn symbol(&mut self, name: &str) { - self.0 - .call_with_n_args(&[ - "<".try_into().unwrap(), - name.try_into().unwrap(), - ">".try_into().unwrap(), - ]) - .unwrap(); + self.string("<"); + self.string(name); + self.string(">"); } fn open(&mut self, name: &str) { - self.0 - .call_with_n_args(&["<".try_into().unwrap(), name.try_into().unwrap()]) - .unwrap(); + self.string("<"); + self.string(name); + self.string(" "); } fn field(&mut self, name: &str, value: &dyn Trace) { - self.0 - .call_with_n_args(&[name.try_into().unwrap(), ": ".try_into().unwrap()]) - .unwrap(); + self.string(name); + self.string(":"); value.trace(self); + self.string(" "); + } + + /// Mark the string as a title/header. + fn title(&mut self, title: &str) { + self.string(crate::trace::TITLE_TAG); + self.string(title); + self.string(crate::trace::TITLE_TAG); + } + + /// Mark the string as a button content. + fn button(&mut self, button: &str) { + self.string(crate::trace::BTN_TAG); + self.string(button); + self.string(crate::trace::BTN_TAG); + } + + fn content_flag(&mut self) { + self.string(crate::trace::CONTENT_TAG); + } + + fn kw_pair(&mut self, key: &str, value: &str) { + self.string(key); + self.string("::"); + self.string(value); + self.string(","); // mostly for human readability } fn close(&mut self) { - self.0.call_with_n_args(&[">".try_into().unwrap()]).unwrap(); + self.string(">") } } @@ -283,6 +312,7 @@ impl LayoutObj { Qstr::MP_QSTR_timer => obj_fn_2!(ui_layout_timer).as_obj(), Qstr::MP_QSTR_paint => obj_fn_1!(ui_layout_paint).as_obj(), Qstr::MP_QSTR_request_complete_repaint => obj_fn_1!(ui_layout_request_complete_repaint).as_obj(), + Qstr::MP_QSTR_place => obj_fn_1!(ui_layout_place).as_obj(), Qstr::MP_QSTR_trace => obj_fn_2!(ui_layout_trace).as_obj(), Qstr::MP_QSTR_bounds => obj_fn_1!(ui_layout_bounds).as_obj(), Qstr::MP_QSTR_page_count => obj_fn_1!(ui_layout_page_count).as_obj(), @@ -438,6 +468,15 @@ extern "C" fn ui_layout_request_complete_repaint(this: Obj) -> Obj { unsafe { util::try_or_raise(block) } } +extern "C" fn ui_layout_place(this: Obj) -> Obj { + let block = || { + let this: Gc = this.try_into()?; + this.obj_place(); + Ok(Obj::const_true()) + }; + unsafe { util::try_or_raise(block) } +} + extern "C" fn ui_layout_page_count(this: Obj) -> Obj { let block = || { let this: Gc = this.try_into()?; diff --git a/core/embed/rust/src/ui/layout/util.rs b/core/embed/rust/src/ui/layout/util.rs index 94b623b8c2..f3446185bd 100644 --- a/core/embed/rust/src/ui/layout/util.rs +++ b/core/embed/rust/src/ui/layout/util.rs @@ -20,6 +20,16 @@ pub fn iter_into_objs(iterable: Obj) -> Result<[Obj; N], Error> } pub fn iter_into_array(iterable: Obj) -> Result<[T; N], Error> +where + T: TryFrom, +{ + let err = Error::ValueError(cstr!("Invalid iterable length")); + let vec: Vec = iter_into_vec(iterable)?; + // Returns error if array.len() != N + vec.into_array().map_err(|_| err) +} + +pub fn iter_into_vec(iterable: Obj) -> Result, Error> where T: TryFrom, { @@ -29,6 +39,5 @@ where for item in Iter::try_from_obj_with_buf(iterable, &mut iter_buf)? { vec.push(item.try_into()?).map_err(|_| err)?; } - // Returns error if array.len() != N - vec.into_array().map_err(|_| err) + Ok(vec) } diff --git a/core/embed/rust/src/ui/macros.rs b/core/embed/rust/src/ui/macros.rs index 8ccea9d9c8..954e2943f6 100644 --- a/core/embed/rust/src/ui/macros.rs +++ b/core/embed/rust/src/ui/macros.rs @@ -4,3 +4,35 @@ macro_rules! include_res { include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/ui/", $filename)) }; } + +#[allow(unused_macros)] // Only used in TR so far. +/// Concatenates arbitrary amount of slices into a String. +macro_rules! build_string { + ($max:expr, $($string:expr),+) => { + { + let mut new_string = String::<$max>::new(); + $(new_string.push_str($string).unwrap();)+ + new_string + } + } +} + +#[allow(unused_macros)] // Mostly for debugging purposes. +/// Transforms integer into string slice. For example for printing. +macro_rules! inttostr { + ($int:expr) => {{ + heapless::String::<10>::from($int).as_str() + }}; +} + +#[allow(unused_macros)] // Mostly for debugging purposes. +/// Transforms bool into string slice. For example for printing. +macro_rules! booltostr { + ($bool:expr) => {{ + if $bool { + "true" + } else { + "false" + } + }}; +} diff --git a/core/embed/rust/src/ui/mod.rs b/core/embed/rust/src/ui/mod.rs index 4d792aa7ba..8019603fba 100644 --- a/core/embed/rust/src/ui/mod.rs +++ b/core/embed/rust/src/ui/mod.rs @@ -4,11 +4,12 @@ pub mod macros; pub mod animation; pub mod component; pub mod constant; +pub mod debug; pub mod display; pub mod event; pub mod geometry; pub mod lerp; -mod util; +pub mod util; #[cfg(feature = "micropython")] pub mod layout; diff --git a/core/embed/rust/src/ui/model_t1/component/button.rs b/core/embed/rust/src/ui/model_t1/component/button.rs index cf9ee45d04..02d4d6ca73 100644 --- a/core/embed/rust/src/ui/model_t1/component/button.rs +++ b/core/embed/rust/src/ui/model_t1/component/button.rs @@ -132,7 +132,7 @@ where ButtonContent::Text(text) => { let background_color = style.text_color.negate(); if style.border_horiz { - display::rect_fill_rounded1(self.area, background_color, theme::BG); + display::rect_fill_rounded(self.area, background_color, theme::BG, 1); } else { display::rect_fill(self.area, background_color) } diff --git a/core/embed/rust/src/ui/model_t1/component/frame.rs b/core/embed/rust/src/ui/model_t1/component/frame.rs index 47e7a489cf..ae72d8ab23 100644 --- a/core/embed/rust/src/ui/model_t1/component/frame.rs +++ b/core/embed/rust/src/ui/model_t1/component/frame.rs @@ -59,7 +59,7 @@ where theme::FG, theme::BG, ); - display::dotted_line(self.area.bottom_left(), self.area.width(), theme::FG); + display::dotted_line_horizontal(self.area.bottom_left(), self.area.width(), theme::FG, 2); self.content.paint(); } } diff --git a/core/embed/rust/src/ui/model_t1/layout.rs b/core/embed/rust/src/ui/model_t1/layout.rs index 382270fcc5..6866a1650d 100644 --- a/core/embed/rust/src/ui/model_t1/layout.rs +++ b/core/embed/rust/src/ui/model_t1/layout.rs @@ -57,10 +57,10 @@ extern "C" fn new_confirm_action(n_args: usize, args: *const Obj, kwargs: *mut M let reverse: bool = kwargs.get(Qstr::MP_QSTR_reverse)?.try_into()?; let format = match (&action, &description, reverse) { - (Some(_), Some(_), false) => "{bold}{action}\n\r{normal}{description}", - (Some(_), Some(_), true) => "{normal}{description}\n\r{bold}{action}", - (Some(_), None, _) => "{bold}{action}", - (None, Some(_), _) => "{normal}{description}", + (Some(_), Some(_), false) => "{Font::bold}{action}\n\r{Font::normal}{description}", + (Some(_), Some(_), true) => "{Font::normal}{description}\n\r{Font::bold}{action}", + (Some(_), None, _) => "{Font::bold}{action}", + (None, Some(_), _) => "{Font::normal}{description}", _ => "", }; diff --git a/core/embed/rust/src/ui/model_t1/theme.rs b/core/embed/rust/src/ui/model_t1/theme.rs index 74b1ef6b8a..9f5bf58b3a 100644 --- a/core/embed/rust/src/ui/model_t1/theme.rs +++ b/core/embed/rust/src/ui/model_t1/theme.rs @@ -8,7 +8,6 @@ use super::component::{ButtonStyle, ButtonStyleSheet}; // Color palette. pub const WHITE: Color = Color::rgb(255, 255, 255); pub const BLACK: Color = Color::rgb(0, 0, 0); -pub const GREY_LIGHT: Color = WHITE; // Word/page break characters. pub const FG: Color = WHITE; // Default foreground (text & icon) color. pub const BG: Color = BLACK; // Default background color. diff --git a/core/embed/rust/src/ui/model_tr/component/bip39.rs b/core/embed/rust/src/ui/model_tr/component/bip39.rs new file mode 100644 index 0000000000..cf05723b87 --- /dev/null +++ b/core/embed/rust/src/ui/model_tr/component/bip39.rs @@ -0,0 +1,282 @@ +use crate::{ + trezorhal::bip39, + ui::{ + component::{text::common::TextBox, Child, Component, ComponentExt, Event, EventCtx}, + geometry::Rect, + }, +}; + +use super::{ + choice_item::BigCharacterChoiceItem, ButtonDetails, ButtonLayout, ChangingTextLine, + ChoiceFactory, ChoiceItem, ChoicePage, ChoicePageMsg, TextChoiceItem, +}; +use heapless::{String, Vec}; + +pub enum Bip39EntryMsg { + ResultWord(String<15>), +} + +const CURRENT_LETTERS_ROW: i32 = 25; + +const MAX_LENGTH: usize = 10; +const MAX_CHOICE_LENGTH: usize = 26; + +/// Offer words when there will be fewer of them than this +const OFFER_WORDS_THRESHOLD: usize = 10; + +struct ChoiceFactoryBIP39 { + // TODO: replace these Vecs by iterators somehow? + letter_choices: Option>, + word_choices: Option>, +} +impl ChoiceFactoryBIP39 { + fn new( + letter_choices: Option>, + word_choices: Option>, + ) -> Self { + Self { + letter_choices, + word_choices, + } + } + + fn letters(letter_choices: Vec) -> Self { + Self::new(Some(letter_choices), None) + } + + fn words(word_choices: Vec<&'static str, OFFER_WORDS_THRESHOLD>) -> Self { + Self::new(None, Some(word_choices)) + } + + /// Word choice items with BIN leftmost button. + fn get_word_item(&self, choice_index: u8) -> ChoiceItem { + if let Some(word_choices) = &self.word_choices { + let word = word_choices[choice_index as usize]; + let choice = TextChoiceItem::new(word, ButtonLayout::default_three_icons()); + let mut word_item = ChoiceItem::Text(choice); + + // Adding BIN leftmost button and removing the rightmost one. + if choice_index == 0 { + word_item.set_left_btn(Some(ButtonDetails::bin_icon())); + } else if choice_index as usize == word_choices.len() - 1 { + word_item.set_right_btn(None); + } + + word_item + } else { + unreachable!() + } + } + + /// Letter choice items with BIN leftmost button. Letters are BIG. + fn get_letter_item(&self, choice_index: u8) -> ChoiceItem { + // TODO: we could support carousel for letters to quicken it for users + // (but then the BIN would need to be an option on its own, not so + // user-friendly) + if let Some(letter_choices) = &self.letter_choices { + let letter = letter_choices[choice_index as usize]; + let letter_choice = + BigCharacterChoiceItem::new(letter, ButtonLayout::default_three_icons()); + let mut letter_item = ChoiceItem::BigCharacter(letter_choice); + + // Adding BIN leftmost button and removing the rightmost one. + if choice_index == 0 { + letter_item.set_left_btn(Some(ButtonDetails::bin_icon())); + } else if choice_index as usize == letter_choices.len() - 1 { + letter_item.set_right_btn(None); + } + + letter_item + } else { + unreachable!() + } + } +} +impl ChoiceFactory for ChoiceFactoryBIP39 { + fn get(&self, choice_index: u8) -> ChoiceItem { + if self.letter_choices.is_some() { + self.get_letter_item(choice_index) + } else if self.word_choices.is_some() { + self.get_word_item(choice_index) + } else { + unreachable!() + } + } + + fn count(&self) -> u8 { + if let Some(letter_choices) = &self.letter_choices { + letter_choices.len() as u8 + } else if let Some(word_choices) = &self.word_choices { + word_choices.len() as u8 + } else { + unreachable!() + } + } +} + +/// Component for entering a BIP39 mnemonic. +pub struct Bip39Entry { + choice_page: ChoicePage, + chosen_letters: Child>>, + letter_choices: Vec, + textbox: TextBox, + offer_words: bool, + words_list: bip39::Wordlist, +} + +impl Bip39Entry { + pub fn new() -> Self { + let letter_choices: Vec = + bip39::get_available_letters("").collect(); + let choices = ChoiceFactoryBIP39::letters(letter_choices.clone()); + + Self { + choice_page: ChoicePage::new(choices), + chosen_letters: Child::new(ChangingTextLine::center_mono(String::new())), + letter_choices, + textbox: TextBox::empty(), + offer_words: false, + words_list: bip39::Wordlist::all(), + } + } + + /// Gets up-to-date choices for letters or words. + fn get_current_choices(&mut self) -> ChoiceFactoryBIP39 { + // Narrowing the word list + self.words_list = self.words_list.filter_prefix(self.textbox.content()); + + // Offering words when there is only a few of them + // Otherwise getting relevant letters + if self.words_list.len() < OFFER_WORDS_THRESHOLD { + self.offer_words = true; + let word_choices = self.words_list.iter().collect(); + ChoiceFactoryBIP39::words(word_choices) + } else { + self.offer_words = false; + self.letter_choices = bip39::get_available_letters(self.textbox.content()).collect(); + ChoiceFactoryBIP39::letters(self.letter_choices.clone()) + } + } + + fn update_chosen_letters(&mut self, ctx: &mut EventCtx) { + let text = build_string!({ MAX_LENGTH + 1 }, self.textbox.content(), "_"); + self.chosen_letters.inner_mut().update_text(text); + self.chosen_letters.request_complete_repaint(ctx); + } + + fn append_letter(&mut self, ctx: &mut EventCtx, letter: char) { + self.textbox.append(ctx, letter); + } + + fn delete_last_letter(&mut self, ctx: &mut EventCtx) { + self.textbox.delete_last(ctx); + } + + fn reset_wordlist(&mut self) { + self.words_list = bip39::Wordlist::all(); + } +} + +impl Component for Bip39Entry { + type Msg = Bip39EntryMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + let letters_area_height = self.chosen_letters.inner().needed_height(); + let (letters_area, choice_area) = bounds.split_top(letters_area_height); + self.chosen_letters.place(letters_area); + self.choice_page.place(choice_area); + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + let msg = self.choice_page.event(ctx, event); + match msg { + Some(ChoicePageMsg::Choice(page_counter)) => { + // Clicked SELECT. + // When we already offer words, return the word at the given index. + // Otherwise, appending the new letter and resetting the choice page + // with up-to-date choices. + if self.offer_words { + let word = self + .words_list + .get(page_counter as usize) + .unwrap_or_default(); + return Some(Bip39EntryMsg::ResultWord(String::from(word))); + } else { + let new_letter = self.letter_choices[page_counter as usize]; + self.append_letter(ctx, new_letter); + self.update_chosen_letters(ctx); + let new_choices = self.get_current_choices(); + self.choice_page.reset(ctx, new_choices, true, false); + ctx.request_paint(); + } + } + Some(ChoicePageMsg::LeftMost) => { + // Clicked BIN. Deleting last letter, updating wordlist and updating choices + self.delete_last_letter(ctx); + self.update_chosen_letters(ctx); + self.reset_wordlist(); + let new_choices = self.get_current_choices(); + self.choice_page.reset(ctx, new_choices, true, false); + ctx.request_paint(); + } + _ => {} + } + + None + } + + fn paint(&mut self) { + self.chosen_letters.paint(); + self.choice_page.paint(); + } +} + +#[cfg(feature = "ui_debug")] +use super::{ButtonAction, ButtonPos}; +#[cfg(feature = "ui_debug")] +use crate::ui::util; + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for Bip39Entry { + fn get_btn_action(&self, pos: ButtonPos) -> String<25> { + match pos { + ButtonPos::Left => match self.choice_page.has_previous_choice() { + true => ButtonAction::PrevPage.string(), + false => ButtonAction::Action("Delete last char").string(), + }, + ButtonPos::Right => match self.choice_page.has_next_choice() { + true => ButtonAction::NextPage.string(), + false => ButtonAction::empty(), + }, + ButtonPos::Middle => { + let current_index = self.choice_page.page_index() as usize; + let choice: String<10> = if self.offer_words { + self.words_list + .get(current_index) + .unwrap_or_default() + .into() + } else { + util::char_to_string(self.letter_choices[current_index]) + }; + ButtonAction::select_item(choice) + } + } + } + + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.open("Bip39Entry"); + t.kw_pair("textbox", self.textbox.content()); + + self.report_btn_actions(t); + + t.open("letter_choices"); + for ch in &self.letter_choices { + t.string(&util::char_to_string::<1>(*ch)); + } + t.close(); + + t.field("choice_page", &self.choice_page); + t.close(); + } +} diff --git a/core/embed/rust/src/ui/model_tr/component/button.rs b/core/embed/rust/src/ui/model_tr/component/button.rs index a631e188f0..7615bd46f7 100644 --- a/core/embed/rust/src/ui/model_tr/component/button.rs +++ b/core/embed/rust/src/ui/model_tr/component/button.rs @@ -1,19 +1,30 @@ -use crate::ui::{ - component::{Component, Event, EventCtx}, - display::{self, Color, Font}, - event::{ButtonEvent, PhysicalButton}, - geometry::{Offset, Point, Rect}, +use crate::{ + time::Duration, + ui::{ + component::{Component, Event, EventCtx}, + constant, + display::{self, Color, Font, Icon}, + event::{ButtonEvent, PhysicalButton}, + geometry::{Offset, Point, Rect}, + }, }; +use heapless::String; + use super::theme; +const HALF_SCREEN_BUTTON_WIDTH: i16 = constant::WIDTH / 2 - 1; + +#[derive(Eq, PartialEq)] pub enum ButtonMsg { Clicked, + LongPressed, } #[derive(Copy, Clone)] pub enum ButtonPos { Left, + Middle, Right, } @@ -21,15 +32,16 @@ impl ButtonPos { pub fn hit(&self, b: &PhysicalButton) -> bool { matches!( (self, b), - (Self::Left, PhysicalButton::Left) | (Self::Right, PhysicalButton::Right) + (Self::Left, PhysicalButton::Left) + | (Self::Middle, PhysicalButton::Both) + | (Self::Right, PhysicalButton::Right) ) } } pub struct Button { - area: Rect, + bounds: Rect, pos: ButtonPos, - baseline: Point, content: ButtonContent, styles: ButtonStyleSheet, state: State, @@ -41,8 +53,7 @@ impl> Button { pos, content, styles, - baseline: Point::zero(), - area: Rect::zero(), + bounds: Rect::zero(), state: State::Released, } } @@ -51,7 +62,7 @@ impl> Button { Self::new(pos, ButtonContent::Text(text), styles) } - pub fn with_icon(pos: ButtonPos, image: &'static [u8], styles: ButtonStyleSheet) -> Self { + pub fn with_icon(pos: ButtonPos, image: Icon, styles: ButtonStyleSheet) -> Self { Self::new(pos, ButtonContent::Icon(image), styles) } @@ -59,13 +70,29 @@ impl> Button { &self.content } - fn style(&self) -> &ButtonStyle { + fn style(&self) -> ButtonStyle { match self.state { State::Released => self.styles.normal, State::Pressed => self.styles.active, } } + /// Changing the icon content of the button. + pub fn set_icon(&mut self, image: Icon) { + self.content = ButtonContent::Icon(image); + } + + /// Changing the text content of the button. + pub fn set_text(&mut self, text: T) { + self.content = ButtonContent::Text(text); + } + + /// Changing the style of the button. + pub fn set_style(&mut self, styles: ButtonStyleSheet) { + self.styles = styles; + } + + // Setting the visual state of the button. fn set(&mut self, ctx: &mut EventCtx, state: State) { if self.state != state { self.state = state; @@ -73,26 +100,75 @@ impl> Button { } } - fn placement( - area: Rect, - pos: ButtonPos, - content: &ButtonContent, - styles: &ButtonStyleSheet, - ) -> (Rect, Point) { - let border_width = if styles.normal.border_horiz { 2 } else { 0 }; - let content_width = match content { - ButtonContent::Text(text) => styles.normal.font.text_width(text.as_ref()) - 1, - ButtonContent::Icon(_icon) => todo!(), + // Setting the visual state of the button. + pub fn set_pressed(&mut self, ctx: &mut EventCtx, is_pressed: bool) { + let new_state = if is_pressed { + State::Pressed + } else { + State::Released }; - let button_width = content_width + 2 * border_width; - let area = match pos { - ButtonPos::Left => area.split_left(button_width).0, - ButtonPos::Right => area.split_right(button_width).1, + self.set(ctx, new_state); + } + + /// Return the full area of the button according + /// to its current style, content and position. + fn get_current_area(&self) -> Rect { + let style = self.style(); + + // Button width may be forced. Otherwise calculate it. + let button_width = if let Some(width) = style.force_width { + width + } else { + let outline = if style.with_outline { + theme::BUTTON_OUTLINE + } else { + 0 + }; + let content_width = match &self.content { + ButtonContent::Text(text) => style.font.text_width(text.as_ref()) - 1, + ButtonContent::Icon(icon) => icon.width() - 1, + }; + content_width + 2 * outline }; - let start_of_baseline = area.bottom_left() + Offset::new(border_width, -2); + // Button height may be adjusted for the icon without outline + // Done to avoid highlighting bigger area than necessary when + // drawing the icon in active (black on white) state + let button_height = match &self.content { + ButtonContent::Text(_) => theme::BUTTON_HEIGHT, + ButtonContent::Icon(icon) => { + if style.with_outline { + theme::BUTTON_HEIGHT + } else { + icon.height() + } + } + }; - (area, start_of_baseline) + let button_bounds = self.bounds.split_bottom(button_height).1; + let area = match self.pos { + ButtonPos::Left => button_bounds.split_left(button_width).0, + ButtonPos::Right => button_bounds.split_right(button_width).1, + ButtonPos::Middle => button_bounds.split_center(button_width).1, + }; + + // Allowing for possible offset of the area from current style + if let Some(offset) = style.offset { + area.translate(offset) + } else { + area + } + } + + /// Determine baseline point for the text. + fn get_text_baseline(&self, style: &ButtonStyle) -> Point { + // Arms and outline require the text to be elevated. + if style.with_arms || style.with_outline { + let offset = theme::BUTTON_OUTLINE; + self.get_current_area().bottom_left() + Offset::new(offset, -offset) + } else { + self.get_current_area().bottom_left() + } } } @@ -103,13 +179,13 @@ where type Msg = ButtonMsg; fn place(&mut self, bounds: Rect) -> Rect { - let (area, baseline) = Self::placement(bounds, self.pos, &self.content, &self.styles); - self.area = area; - self.baseline = baseline; - self.area + self.bounds = bounds; + self.get_current_area() } fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + // Everything should be handled by `ButtonController` + // TODO: could be completely deleted, but `ResultPopup` is using Button.event() match event { Event::Button(ButtonEvent::ButtonPressed(which)) if self.pos.hit(&which) => { self.set(ctx, State::Pressed); @@ -127,26 +203,74 @@ where fn paint(&mut self) { let style = self.style(); + let text_color = style.text_color; + let background_color = text_color.negate(); + let area = self.get_current_area(); + + // TODO: support another combinations of text and icons + // - text with OK icon on left + + // Optionally display "arms" at both sides of content, or create + // a nice rounded outline around it. + // By default just fill the content background. + if style.with_arms { + // Prepare space for both the arms and content with BG color. + // Arms are icons 10*6 pixels. + let area_to_fill = area.extend_left(10).extend_right(15); + display::rect_fill(area_to_fill, background_color); + + // Paint both arms. + // Baselines need to be shifted little bit right to fit properly with the text + // TODO: for "CONFIRM" there is one space at the right, but for "SELECT" there + // are two + Icon::new(theme::ICON_ARM_LEFT).draw_top_right( + area.left_center() + Offset::x(2), + text_color, + background_color, + ); + Icon::new(theme::ICON_ARM_RIGHT).draw_top_left( + area.right_center() + Offset::x(4), + text_color, + background_color, + ); + } else if style.with_outline { + display::rect_outline_rounded(area, text_color, background_color, 2); + } else { + display::rect_fill(area, background_color) + } match &self.content { ButtonContent::Text(text) => { - let background_color = style.text_color.negate(); - if style.border_horiz { - display::rect_fill_rounded1(self.area, background_color, theme::BG); - } else { - display::rect_fill(self.area, background_color) - } - display::text( - self.baseline, + self.get_text_baseline(&style), text.as_ref(), style.font, - style.text_color, + text_color, background_color, ); } - ButtonContent::Icon(_image) => { - todo!(); + ButtonContent::Icon(icon) => { + if style.with_outline { + // Accounting for the 8*8 icon with empty left column and bottom row + // (which fits the outline nicely and symmetrically) + let center = area.center() + Offset::uniform(1); + icon.draw_center(center, text_color, background_color); + } else { + // Positioning the icon in the corresponding corner/center + match self.pos { + ButtonPos::Left => { + icon.draw_bottom_left(area.bottom_left(), text_color, background_color) + } + ButtonPos::Right => icon.draw_bottom_right( + area.bottom_right(), + text_color, + background_color, + ), + ButtonPos::Middle => { + icon.draw_center(area.center(), text_color, background_color) + } + } + } } } } @@ -161,7 +285,7 @@ where t.open("Button"); match &self.content { ButtonContent::Text(text) => t.field("text", text), - ButtonContent::Icon(_) => t.symbol("icon"), + ButtonContent::Icon(icon) => t.field("icon", icon), } t.close(); } @@ -175,16 +299,486 @@ enum State { pub enum ButtonContent { Text(T), - Icon(&'static [u8]), + Icon(Icon), } pub struct ButtonStyleSheet { - pub normal: &'static ButtonStyle, - pub active: &'static ButtonStyle, + pub normal: ButtonStyle, + pub active: ButtonStyle, } +#[derive(Clone, Copy)] pub struct ButtonStyle { pub font: Font, pub text_color: Color, - pub border_horiz: bool, + pub with_outline: bool, + pub with_arms: bool, + pub force_width: Option, + pub offset: Option, +} + +// TODO: currently `button_default` and `button_cancel` +// are the same - decide whether to differentiate them. +// In Figma, they are not differentiated. + +impl ButtonStyleSheet { + pub fn new( + normal_color: Color, + active_color: Color, + with_outline: bool, + with_arms: bool, + force_width: Option, + offset: Option, + ) -> Self { + Self { + normal: ButtonStyle { + font: theme::FONT_BUTTON, + text_color: normal_color, + with_outline, + with_arms, + force_width, + offset, + }, + active: ButtonStyle { + font: theme::FONT_BUTTON, + text_color: active_color, + with_outline, + with_arms, + force_width, + offset, + }, + } + } + + // White text in normal mode. + pub fn default( + with_outline: bool, + with_arms: bool, + force_width: Option, + offset: Option, + ) -> Self { + Self::new( + theme::FG, + theme::BG, + with_outline, + with_arms, + force_width, + offset, + ) + } + + // Black text in normal mode. + pub fn cancel( + with_outline: bool, + with_arms: bool, + force_width: Option, + offset: Option, + ) -> Self { + Self::new( + theme::FG, + theme::BG, + with_outline, + with_arms, + force_width, + offset, + ) + // Self::new(theme::BG, theme::FG, with_outline, with_arms) + } +} + +/// Describing the button in the choice item. +#[derive(Clone, Copy)] +pub struct ButtonDetails { + pub text: Option, + pub icon: Option, + pub duration: Option, + pub is_cancel: bool, + pub with_outline: bool, + pub with_arms: bool, + pub force_width: Option, + pub offset: Option, +} + +impl> ButtonDetails { + /// Text button. + pub fn text(text: T) -> Self { + Self { + text: Some(text), + icon: None, + duration: None, + is_cancel: false, + with_outline: true, + with_arms: false, + force_width: None, + offset: None, + } + } + + /// Icon button. + pub fn icon(icon: Icon) -> Self { + Self { + text: None, + icon: Some(icon), + duration: None, + is_cancel: false, + with_outline: true, + with_arms: false, + force_width: None, + offset: None, + } + } + + /// Text with arms signalling double press. + pub fn armed_text(text: T) -> Self { + Self::text(text).with_arms() + } + + /// Cross-style-icon cancel button with no outline. + pub fn cancel_icon() -> Self { + Self::icon(Icon::new(theme::ICON_CANCEL)) + .with_no_outline() + .with_offset(Offset::new(2, -2)) + } + + /// Left arrow to signal going back. + pub fn left_arrow_icon() -> Self { + Self::icon(Icon::new(theme::ICON_ARROW_LEFT)).with_no_outline() + } + + /// Right arrow to signal going forward. + pub fn right_arrow_icon() -> Self { + Self::icon(Icon::new(theme::ICON_ARROW_RIGHT)).with_no_outline() + } + + /// Down arrow to signal paginating forward. Takes half the screen's width + pub fn down_arrow_icon_wide() -> Self { + Self::icon(Icon::new(theme::ICON_ARROW_DOWN)).force_width(HALF_SCREEN_BUTTON_WIDTH) + } + + /// Up arrow to signal paginating back. Takes half the screen's width + pub fn up_arrow_icon_wide() -> Self { + Self::icon(Icon::new(theme::ICON_ARROW_UP)).force_width(HALF_SCREEN_BUTTON_WIDTH) + } + + /// Icon of a bin to signal deleting. + pub fn bin_icon() -> Self { + Self::icon(Icon::new(theme::ICON_BIN)).with_no_outline() + } + + /// Cancel style button. + pub fn with_cancel(mut self) -> Self { + self.is_cancel = true; + self + } + + /// No outline around the button. + pub fn with_no_outline(mut self) -> Self { + self.with_outline = false; + self + } + + /// Positioning the icon precisely where we want. + /// Buttons are by default placed exactly in the corners (left/right) + /// or in the center in case of center button. The offset can change it. + pub fn with_offset(mut self, offset: Offset) -> Self { + self.offset = Some(offset); + self + } + + /// Left and right "arms" around the button. + /// Automatically disabling the outline. + pub fn with_arms(mut self) -> Self { + self.with_arms = true; + self.with_outline = false; + self + } + + /// Duration of the hold-to-confirm. + pub fn with_duration(mut self, duration: Duration) -> Self { + self.duration = Some(duration); + self + } + + /// Width of the button. + pub fn force_width(mut self, width: i16) -> Self { + self.force_width = Some(width); + self + } + + /// Button style that should be applied. + pub fn style(&self) -> ButtonStyleSheet { + if self.is_cancel { + ButtonStyleSheet::cancel( + self.with_outline, + self.with_arms, + self.force_width, + self.offset, + ) + } else { + ButtonStyleSheet::default( + self.with_outline, + self.with_arms, + self.force_width, + self.offset, + ) + } + } +} + +/// Holding the button details for all three possible buttons. +#[derive(Clone)] +pub struct ButtonLayout { + pub btn_left: Option>, + pub btn_middle: Option>, + pub btn_right: Option>, +} + +impl> ButtonLayout { + pub fn new( + btn_left: Option>, + btn_middle: Option>, + btn_right: Option>, + ) -> Self { + Self { + btn_left, + btn_middle, + btn_right, + } + } + + /// Empty layout for when we cannot yet tell which buttons + /// should be on the screen. + pub fn empty() -> Self { + Self::new(None, None, None) + } +} + +impl ButtonLayout<&'static str> { + /// Default button layout for all three buttons - icons. + pub fn default_three_icons() -> Self { + Self::three_icons_middle_text("SELECT") + } + + /// Special middle text for default icon layout. + pub fn three_icons_middle_text(middle_text: &'static str) -> Self { + Self::new( + Some(ButtonDetails::left_arrow_icon()), + Some(ButtonDetails::armed_text(middle_text)), + Some(ButtonDetails::right_arrow_icon()), + ) + } + + /// Left and right arrow icons for navigation. + pub fn left_right_arrows() -> Self { + Self::new( + Some(ButtonDetails::left_arrow_icon()), + None, + Some(ButtonDetails::right_arrow_icon()), + ) + } + + /// Cancel cross on left and right arrow. + pub fn cancel_and_arrow() -> Self { + Self::new( + Some(ButtonDetails::cancel_icon()), + None, + Some(ButtonDetails::right_arrow_icon()), + ) + } + + /// Cancel cross on left and text on the right. + pub fn cancel_and_text(text: &'static str) -> Self { + Self::new( + Some(ButtonDetails::cancel_icon()), + None, + Some(ButtonDetails::text(text)), + ) + } + + /// Cancel cross on left and hold-to-confirm text on the right. + pub fn cancel_and_htc_text(text: &'static str, duration: Duration) -> Self { + Self::new( + Some(ButtonDetails::cancel_icon()), + None, + Some(ButtonDetails::text(text).with_duration(duration)), + ) + } +} + +/// What happens when a button is triggered. +/// Theoretically any action can be connected +/// with any button. +#[derive(Clone, PartialEq, Eq)] +pub enum ButtonAction { + /// Go to the next page of this flow + NextPage, + /// Go to the previous page of this flow + PrevPage, + /// Go to a page of this flow specified by an index. + /// Negative numbers can be used to count from the end. + /// (0 ~ GoToFirstPage, -1 ~ GoToLastPage etc.) + GoToIndex(i16), + /// Go forwards/backwards a specified number of pages. + /// Negative numbers mean going back. + MovePageRelative(i16), + /// Cancel the whole layout - send Msg::Cancelled + Cancel, + /// Confirm the whole layout - send Msg::Confirmed + Confirm, + /// Select current choice value + Select, + /// Some custom specific action + Action(&'static str), +} + +#[cfg(feature = "ui_debug")] +impl ButtonAction { + /// Describing the action as a string. Debug-only. + pub fn string(&self) -> String<25> { + match self { + ButtonAction::NextPage => "Next".into(), + ButtonAction::PrevPage => "Prev".into(), + ButtonAction::GoToIndex(index) => { + build_string!(25, "Index(", inttostr!(*index), ")") + } + ButtonAction::MovePageRelative(index) => { + build_string!(25, "Relative(", inttostr!(*index), ")") + } + ButtonAction::Cancel => "Cancel".into(), + ButtonAction::Confirm => "Confirm".into(), + ButtonAction::Select => "Select".into(), + ButtonAction::Action(action) => (*action).into(), + } + } + + /// Adding a description to the Select action. + pub fn select_item>(item: T) -> String<25> { + build_string!(25, &Self::Select.string(), "(", item.as_ref(), ")") + } + + /// When there is no action. + pub fn empty() -> String<25> { + "None".into() + } +} + +// TODO: might consider defining ButtonAction::Empty +// and only storing ButtonAction instead of Option... + +/// Storing actions for all three possible buttons. +#[derive(Clone)] +pub struct ButtonActions { + pub left: Option, + pub middle: Option, + pub right: Option, +} + +impl ButtonActions { + pub fn new( + left: Option, + middle: Option, + right: Option, + ) -> Self { + Self { + left, + middle, + right, + } + } + + /// Going back with left, going further with right + pub fn prev_next() -> Self { + Self::new( + Some(ButtonAction::PrevPage), + None, + Some(ButtonAction::NextPage), + ) + } + + /// Going back with left, going further with middle + pub fn prev_next_with_middle() -> Self { + Self::new( + Some(ButtonAction::PrevPage), + Some(ButtonAction::NextPage), + None, + ) + } + + /// Going to last page with left, to the next page with right + pub fn last_next() -> Self { + Self::new( + Some(ButtonAction::GoToIndex(-1)), + None, + Some(ButtonAction::NextPage), + ) + } + + /// Cancelling with left, going to the next page with right + pub fn cancel_next() -> Self { + Self::new( + Some(ButtonAction::Cancel), + None, + Some(ButtonAction::NextPage), + ) + } + + /// Cancelling with left, confirming with right + pub fn cancel_confirm() -> Self { + Self::new( + Some(ButtonAction::Cancel), + None, + Some(ButtonAction::Confirm), + ) + } + + /// Going to the beginning with left, confirming with right + pub fn beginning_confirm() -> Self { + Self::new( + Some(ButtonAction::GoToIndex(0)), + None, + Some(ButtonAction::Confirm), + ) + } + + /// Going to the beginning with left, cancelling with right + pub fn beginning_cancel() -> Self { + Self::new( + Some(ButtonAction::GoToIndex(0)), + None, + Some(ButtonAction::Cancel), + ) + } + + /// Having access to appropriate action based on the `ButtonPos` + pub fn get_action(&self, pos: ButtonPos) -> Option { + match pos { + ButtonPos::Left => self.left.clone(), + ButtonPos::Middle => self.middle.clone(), + ButtonPos::Right => self.right.clone(), + } + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for ButtonDetails +where + T: AsRef, +{ + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.open("ButtonDetails"); + let mut btn_text: String<30> = String::new(); + if let Some(text) = &self.text { + btn_text.push_str(text.as_ref()).unwrap(); + } else if let Some(icon) = &self.icon { + btn_text.push_str("Icon:").unwrap(); + btn_text.push_str(icon.text.as_ref()).unwrap(); + } + if let Some(duration) = &self.duration { + btn_text.push_str(" (HTC:").unwrap(); + btn_text.push_str(inttostr!(duration.to_millis())).unwrap(); + btn_text.push_str(")").unwrap(); + } + t.button(btn_text.as_ref()); + t.close(); + } } diff --git a/core/embed/rust/src/ui/model_tr/component/button_controller.rs b/core/embed/rust/src/ui/model_tr/component/button_controller.rs new file mode 100644 index 0000000000..eebbb59eff --- /dev/null +++ b/core/embed/rust/src/ui/model_tr/component/button_controller.rs @@ -0,0 +1,449 @@ +use super::{ + theme, Button, ButtonDetails, ButtonLayout, ButtonPos, HoldToConfirm, HoldToConfirmMsg, + LoaderStyleSheet, +}; +use crate::{ + time::Duration, + ui::{ + component::{base::Event, Component, EventCtx, Pad}, + event::{ButtonEvent, PhysicalButton}, + geometry::Rect, + }, +}; + +use heapless::String; + +#[derive(Copy, Clone, PartialEq, Eq)] +enum ButtonState { + Nothing, + OneDown(PhysicalButton), + BothDown, + OneReleased(PhysicalButton), + HTCNeedsRelease(PhysicalButton), +} + +pub enum ButtonControllerMsg { + Triggered(ButtonPos), +} + +/// Defines what kind of button should be currently used. +pub enum ButtonType { + Button(Button), + HoldToConfirm(HoldToConfirm), + Nothing, +} + +impl ButtonType +where + T: AsRef, + T: Clone, +{ + pub fn from_button_details(pos: ButtonPos, btn_details: Option>) -> Self { + if let Some(btn_details) = btn_details { + if btn_details.duration.is_some() { + Self::HoldToConfirm(Self::get_hold_to_confirm(pos, btn_details)) + } else { + Self::Button(Self::get_button(pos, btn_details)) + } + } else { + Self::Nothing + } + } + + /// Create `Button` component from `btn_details`. + fn get_button(pos: ButtonPos, btn_details: ButtonDetails) -> Button { + // Deciding between text and icon + if let Some(text) = btn_details.clone().text { + Button::with_text(pos, text, btn_details.style()) + } else if let Some(icon) = btn_details.icon { + Button::with_icon(pos, icon, btn_details.style()) + } else { + panic!("ButtonContainer: no text or icon provided"); + } + } + + /// Create `HoldToConfirm` component from `btn_details`. + fn get_hold_to_confirm(pos: ButtonPos, btn_details: ButtonDetails) -> HoldToConfirm { + let duration = btn_details + .duration + .unwrap_or_else(|| Duration::from_millis(1000)); + if let Some(text) = btn_details.text { + HoldToConfirm::text(pos, text, LoaderStyleSheet::default(), duration) + } else if let Some(icon) = btn_details.icon { + HoldToConfirm::icon(pos, icon, LoaderStyleSheet::default(), duration) + } else { + panic!("ButtonContainer: no text or icon provided"); + } + } + + pub fn place(&mut self, button_area: Rect) { + match self { + Self::Button(button) => { + button.place(button_area); + } + Self::HoldToConfirm(htc) => { + htc.place(button_area); + } + Self::Nothing => {} + } + } + + pub fn paint(&mut self) { + match self { + Self::Button(button) => { + button.paint(); + } + Self::HoldToConfirm(htc) => { + htc.paint(); + } + Self::Nothing => {} + } + } +} + +/// Wrapping a button and its state, so that it can be easily +/// controlled from outside. +/// +/// Users have a choice of a normal button or Hold-to-confirm button. +/// `button_type` specified what from those two is used, if anything. +pub struct ButtonContainer { + pos: ButtonPos, + button_type: ButtonType, +} + +impl> ButtonContainer { + /// Supplying `None` as `btn_details` marks the button inactive + /// (it can be later activated in `set()`). + pub fn new(pos: ButtonPos, btn_details: Option>) -> Self { + Self { + pos, + button_type: ButtonType::from_button_details(pos, btn_details), + } + } + + /// Changing the state of the button. + /// + /// Passing `None` as `btn_details` will mark the button as inactive. + pub fn set(&mut self, btn_details: Option>, button_area: Rect) { + self.button_type = ButtonType::from_button_details(self.pos, btn_details); + self.button_type.place(button_area); + } + + /// Placing the possible component. + pub fn place(&mut self, bounds: Rect) { + self.button_type.place(bounds); + } + + /// Painting the component that should be currently visible, if any. + pub fn paint(&mut self) { + self.button_type.paint(); + } + + /// Setting the visual state of the button - released/pressed. + pub fn set_pressed(&mut self, ctx: &mut EventCtx, is_pressed: bool) { + if let ButtonType::Button(btn) = &mut self.button_type { + btn.set_pressed(ctx, is_pressed); + } + } + + /// Whether single-click should trigger action. + pub fn reacts_to_single_click(&self) -> bool { + matches!(self.button_type, ButtonType::Button(_)) + } + + /// Find out whether hold-to-confirm was triggered. + pub fn htc_got_triggered(&mut self, ctx: &mut EventCtx, event: Event) -> bool { + if let ButtonType::HoldToConfirm(htc) = &mut self.button_type { + if matches!(htc.event(ctx, event), Some(HoldToConfirmMsg::Confirmed)) { + return true; + } + } + false + } + + /// Registering hold event. + pub fn hold_started(&mut self, ctx: &mut EventCtx) { + if let ButtonType::HoldToConfirm(htc) = &mut self.button_type { + htc.event(ctx, Event::Button(ButtonEvent::HoldStarted)); + } + } + + /// Cancelling hold event. + pub fn hold_ended(&mut self, ctx: &mut EventCtx) { + if let ButtonType::HoldToConfirm(htc) = &mut self.button_type { + htc.event(ctx, Event::Button(ButtonEvent::HoldEnded)); + } + } +} + +/// Component responsible for handling buttons. +/// +/// Acts as a state-machine of `ButtonState`. +/// +/// Storing all three possible buttons - left, middle and right - +/// and handling their placement, painting and returning +/// appropriate events when they are triggered. +/// +/// Buttons can be interactively changed by clients by `set()`. +/// +/// Only "final" button events are returned in `ButtonControllerMsg::Triggered`, +/// based upon the buttons being long-press or not. +pub struct ButtonController { + pad: Pad, + left_btn: ButtonContainer, + middle_btn: ButtonContainer, + right_btn: ButtonContainer, + state: ButtonState, + // Button area is needed so the buttons + // can be "re-placed" after their text is changed + // Will be set in `place` + button_area: Rect, +} + +impl> ButtonController { + pub fn new(btn_layout: ButtonLayout) -> Self { + Self { + pad: Pad::with_background(theme::BG).with_clear(), + left_btn: ButtonContainer::new(ButtonPos::Left, btn_layout.btn_left), + middle_btn: ButtonContainer::new(ButtonPos::Middle, btn_layout.btn_middle), + right_btn: ButtonContainer::new(ButtonPos::Right, btn_layout.btn_right), + state: ButtonState::Nothing, + button_area: Rect::zero(), + } + } + + /// Updating all the three buttons to the wanted states. + pub fn set(&mut self, btn_layout: ButtonLayout) { + self.pad.clear(); + self.left_btn.set(btn_layout.btn_left, self.button_area); + self.middle_btn.set(btn_layout.btn_middle, self.button_area); + self.right_btn.set(btn_layout.btn_right, self.button_area); + } + + /// Setting the pressed state for all three buttons by boolean flags. + fn set_pressed(&mut self, ctx: &mut EventCtx, left: bool, mid: bool, right: bool) { + self.left_btn.set_pressed(ctx, left); + self.middle_btn.set_pressed(ctx, mid); + self.right_btn.set_pressed(ctx, right); + } + + /// Handle middle button hold-to-confirm start. + /// We need to cancel possible holds in both other buttons. + fn middle_hold_started(&mut self, ctx: &mut EventCtx) { + self.left_btn.hold_ended(ctx); + self.middle_btn.hold_started(ctx); + self.right_btn.hold_ended(ctx); + } + + /// Handling the expiration of HTC elements. + /// Finding out if they have been triggered and sending event + /// for the appropriate button. + /// Setting the state to wait for the appropriate release event + /// from the pressed button. Also resetting visible state. + fn handle_htc_expiration( + &mut self, + ctx: &mut EventCtx, + event: Event, + ) -> Option { + if self.left_btn.htc_got_triggered(ctx, event) { + self.state = ButtonState::HTCNeedsRelease(PhysicalButton::Left); + self.set_pressed(ctx, false, false, false); + return Some(ButtonControllerMsg::Triggered(ButtonPos::Left)); + } else if self.middle_btn.htc_got_triggered(ctx, event) { + // TODO: how to handle it here? Do we even need to? + self.state = ButtonState::Nothing; + self.set_pressed(ctx, false, false, false); + return Some(ButtonControllerMsg::Triggered(ButtonPos::Middle)); + } else if self.right_btn.htc_got_triggered(ctx, event) { + self.state = ButtonState::HTCNeedsRelease(PhysicalButton::Right); + self.set_pressed(ctx, false, false, false); + return Some(ButtonControllerMsg::Triggered(ButtonPos::Right)); + } + None + } +} + +impl> Component for ButtonController { + type Msg = ButtonControllerMsg; + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + // State machine for the ButtonController + // We are matching event with `Event::Button` for a button action + // and `Event::Timer` for getting the expiration of HTC. + match event { + Event::Button(button) => { + let (new_state, event) = match self.state { + ButtonState::Nothing => match button { + ButtonEvent::ButtonPressed(which) => { + match which { + PhysicalButton::Left => { + self.left_btn.hold_started(ctx); + } + PhysicalButton::Right => { + self.right_btn.hold_started(ctx); + } + _ => {} + } + (ButtonState::OneDown(which), None) + } + _ => (self.state, None), + }, + ButtonState::OneDown(which_down) => match button { + ButtonEvent::ButtonReleased(b) if b == which_down => match which_down { + PhysicalButton::Left => ( + ButtonState::Nothing, + if self.left_btn.reacts_to_single_click() { + Some(ButtonControllerMsg::Triggered(ButtonPos::Left)) + } else { + self.left_btn.hold_ended(ctx); + None + }, + ), + PhysicalButton::Right => ( + ButtonState::Nothing, + if self.right_btn.reacts_to_single_click() { + Some(ButtonControllerMsg::Triggered(ButtonPos::Right)) + } else { + self.right_btn.hold_ended(ctx); + None + }, + ), + _ => (ButtonState::Nothing, None), + }, + + ButtonEvent::ButtonPressed(b) if b != which_down => { + self.middle_hold_started(ctx); + (ButtonState::BothDown, None) + } + _ => (self.state, None), + }, + ButtonState::BothDown => match button { + ButtonEvent::ButtonReleased(b) => { + self.middle_btn.hold_ended(ctx); + (ButtonState::OneReleased(b), None) + } + _ => (self.state, None), + }, + ButtonState::OneReleased(which_up) => match button { + ButtonEvent::ButtonPressed(b) if b == which_up => { + self.middle_hold_started(ctx); + (ButtonState::BothDown, None) + } + ButtonEvent::ButtonReleased(b) if b != which_up => ( + ButtonState::Nothing, + if self.middle_btn.reacts_to_single_click() { + Some(ButtonControllerMsg::Triggered(ButtonPos::Middle)) + } else { + None + }, + ), + _ => (self.state, None), + }, + ButtonState::HTCNeedsRelease(needs_release) => match button { + // Only going out of this state if correct button was released + ButtonEvent::ButtonReleased(released) if needs_release == released => { + (ButtonState::Nothing, None) + } + _ => (self.state, None), + }, + }; + + // Updating the visual feedback for the buttons + match new_state { + // Not showing anything also when we wait for a release + ButtonState::Nothing | ButtonState::HTCNeedsRelease(_) => { + self.set_pressed(ctx, false, false, false); + } + ButtonState::OneDown(down_button) => match down_button { + PhysicalButton::Left => { + self.set_pressed(ctx, true, false, false); + } + PhysicalButton::Right => { + self.set_pressed(ctx, false, false, true); + } + _ => {} + }, + ButtonState::BothDown | ButtonState::OneReleased(_) => { + self.set_pressed(ctx, false, true, false); + } + }; + + self.state = new_state; + event + } + Event::Timer(_) => self.handle_htc_expiration(ctx, event), + _ => None, + } + } + + fn paint(&mut self) { + self.pad.paint(); + self.left_btn.paint(); + self.middle_btn.paint(); + self.right_btn.paint(); + } + + fn place(&mut self, bounds: Rect) -> Rect { + // Saving button area so that we can re-place the buttons + // when they get updated + self.button_area = bounds; + + self.pad.place(bounds); + self.left_btn.place(bounds); + self.middle_btn.place(bounds); + self.right_btn.place(bounds); + + bounds + } +} + +#[cfg(feature = "ui_debug")] +use super::ButtonContent; + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for ButtonContainer +where + T: AsRef, +{ + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.open("ButtonContainer"); + + // Putting together text representation of the button + let mut btn_text: String<30> = String::new(); + if let ButtonType::Button(btn) = &self.button_type { + match btn.content() { + ButtonContent::Text(text) => { + unwrap!(btn_text.push_str(text.as_ref())); + } + ButtonContent::Icon(icon) => { + unwrap!(btn_text.push_str("Icon:")); + unwrap!(btn_text.push_str(icon.text)); + } + } + } else if let ButtonType::HoldToConfirm(htc) = &self.button_type { + unwrap!(btn_text.push_str(htc.get_text().as_ref())); + unwrap!(btn_text.push_str(" (HTC:")); + unwrap!(btn_text.push_str(inttostr!(htc.get_duration().to_millis()))); + unwrap!(btn_text.push_str(")")); + } else { + unwrap!(btn_text.push_str(crate::trace::EMPTY_BTN)); + } + t.button(btn_text.as_ref()); + + t.close(); + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for ButtonController +where + T: AsRef, +{ + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.open("ButtonController"); + t.field("left_btn", &self.left_btn); + t.field("middle_btn", &self.middle_btn); + t.field("right_btn", &self.right_btn); + t.close(); + } +} diff --git a/core/embed/rust/src/ui/model_tr/component/changing_text.rs b/core/embed/rust/src/ui/model_tr/component/changing_text.rs new file mode 100644 index 0000000000..dd64bd7066 --- /dev/null +++ b/core/embed/rust/src/ui/model_tr/component/changing_text.rs @@ -0,0 +1,95 @@ +use crate::ui::{ + component::{Component, Event, EventCtx, Never, Pad}, + display::Font, + geometry::{Point, Rect}, +}; + +use super::{common, flow_pages_poc_helpers::LineAlignment, theme}; + +/// Component that allows for "allocating" a standalone line of text anywhere +/// on the screen and updating it arbitrarily - without affecting the rest +/// and without being affected by other components. +pub struct ChangingTextLine { + area: Rect, + pad: Pad, + text: T, + font: Font, + line_alignment: LineAlignment, +} + +impl ChangingTextLine +where + T: AsRef, +{ + pub fn new(text: T, font: Font, line_alignment: LineAlignment) -> Self { + Self { + area: Rect::zero(), + pad: Pad::with_background(theme::BG), + text, + font, + line_alignment, + } + } + + pub fn center_mono(text: T) -> Self { + Self::new(text, Font::MONO, LineAlignment::Center) + } + + pub fn update_text(&mut self, text: T) { + self.text = text; + self.pad.clear(); + } + + /// Gets the height that is needed for this line to fit perfectly + /// without affecting the rest of the screen. + /// (Accounting for letters that go below the baseline (y, j, ...).) + pub fn needed_height(&self) -> i16 { + self.font.line_height() + 2 + } + + /// Y coordinate of text baseline, is the same for all paints. + fn y_baseline(&self) -> i16 { + self.area.y0 + self.font.line_height() + } + + fn paint_left(&self) { + let baseline = Point::new(self.area.x0, self.y_baseline()); + common::display(baseline, &self.text, self.font) + } + + fn paint_center(&self) { + let baseline = Point::new(self.area.bottom_center().x, self.y_baseline()); + common::display_center(baseline, &self.text, self.font) + } + + fn paint_right(&self) { + let baseline = Point::new(self.area.x1, self.y_baseline()); + common::display_right(baseline, &self.text, self.font) + } +} + +impl Component for ChangingTextLine +where + T: AsRef, +{ + type Msg = Never; + + fn place(&mut self, bounds: Rect) -> Rect { + self.area = bounds; + self.pad.place(bounds); + bounds + } + + fn event(&mut self, _ctx: &mut EventCtx, _event: Event) -> Option { + None + } + + fn paint(&mut self) { + self.pad.paint(); + match self.line_alignment { + LineAlignment::Left => self.paint_left(), + LineAlignment::Center => self.paint_center(), + LineAlignment::Right => self.paint_right(), + } + } +} diff --git a/core/embed/rust/src/ui/model_tr/component/choice.rs b/core/embed/rust/src/ui/model_tr/component/choice.rs new file mode 100644 index 0000000000..48626a426f --- /dev/null +++ b/core/embed/rust/src/ui/model_tr/component/choice.rs @@ -0,0 +1,326 @@ +use crate::ui::{ + component::{Child, Component, Event, EventCtx, Pad}, + geometry::Rect, +}; + +use super::{theme, ButtonController, ButtonControllerMsg, ButtonPos, ChoiceItem, ChoiceItemAPI}; + +pub enum ChoicePageMsg { + Choice(u8), + LeftMost, + RightMost, +} + +const MIDDLE_ROW: i32 = 72; + +/// Interface for a specific component efficiently giving +/// `ChoicePage` all the information it needs to render +/// all the choice pages. +/// +/// It avoids the need to store the whole sequence of +/// `ChoiceItem`s in `heapless::Vec` (which caused StackOverflow), +/// but offers a "lazy-loading" way of requesting the +/// `ChoiceItem`s only when they are needed, one-by-one. +/// This way, no more than one `ChoiceItem` is stored in memory at any time. +pub trait ChoiceFactory { + fn get(&self, choice_index: u8) -> ChoiceItem; + fn count(&self) -> u8; +} + +/// General component displaying a set of items on the screen +/// and allowing the user to select one of them. +/// +/// To be used by other more specific components that will +/// supply a set of `ChoiceItem`s (through `ChoiceFactory`) +/// and will receive back the index of the selected choice. +/// +/// Each `ChoiceItem` is responsible for setting the screen - +/// choosing the button text, their duration, text displayed +/// on screen etc. +/// +/// `is_carousel` can be used to make the choice page "infinite" - +/// after reaching one end, users will appear at the other end. +pub struct ChoicePage +where + F: ChoiceFactory, +{ + choices: F, + pad: Pad, + buttons: Child>, + page_counter: u8, + is_carousel: bool, +} + +impl ChoicePage +where + F: ChoiceFactory, +{ + pub fn new(choices: F) -> Self { + let initial_btn_layout = choices.get(0).btn_layout(); + + Self { + choices, + pad: Pad::with_background(theme::BG), + buttons: Child::new(ButtonController::new(initial_btn_layout)), + page_counter: 0, + is_carousel: false, + } + } + + /// Set the page counter at the very beginning. + pub fn with_initial_page_counter(mut self, page_counter: u8) -> Self { + self.page_counter = page_counter; + self + } + + /// Enabling the carousel mode. + pub fn with_carousel(mut self) -> Self { + self.is_carousel = true; + self + } + + /// Resetting the component, which enables reusing the same instance + /// for multiple choice categories. + /// + /// NOTE: from the client point of view, it would also be an option to + /// always create a new instance with fresh setup, but I could not manage to + /// properly clean up the previous instance - it would still be shown on + /// screen and colliding with the new one. + pub fn reset( + &mut self, + ctx: &mut EventCtx, + new_choices: F, + reset_page_counter: bool, + is_carousel: bool, + ) { + self.choices = new_choices; + if reset_page_counter { + self.page_counter = 0; + } + self.update(ctx); + self.is_carousel = is_carousel; + } + + /// Navigating to the chosen page index. + pub fn set_page_counter(&mut self, ctx: &mut EventCtx, page_counter: u8) { + self.page_counter = page_counter; + self.update(ctx); + } + + /// Display current, previous and next choice according to + /// the current ChoiceItem. + fn paint_choices(&mut self) { + // Performing the appropriate `paint_XXX()` for the main choice + // and two adjacent choices when present + // In case of carousel mode, also showing the ones from other end. + self.show_current_choice(); + + if self.has_previous_choice() { + self.show_previous_choice(); + } else if self.is_carousel { + self.show_last_choice_on_left(); + } + + if self.has_next_choice() { + self.show_next_choice(); + } else if self.is_carousel { + self.show_first_choice_on_right(); + } + } + + /// Setting current buttons, and clearing. + fn update(&mut self, ctx: &mut EventCtx) { + self.set_buttons(ctx); + self.clear(ctx); + } + + /// Clearing the whole area and requesting repaint. + fn clear(&mut self, ctx: &mut EventCtx) { + self.pad.clear(); + ctx.request_paint(); + } + + fn last_page_index(&self) -> u8 { + self.choices.count() as u8 - 1 + } + + pub fn has_previous_choice(&self) -> bool { + self.page_counter > 0 + } + + pub fn has_next_choice(&self) -> bool { + self.page_counter < self.last_page_index() + } + + fn current_choice(&self) -> ChoiceItem { + self.get_choice(self.page_counter) + } + + fn get_choice(&self, index: u8) -> ChoiceItem { + self.choices.get(index) + } + + fn show_current_choice(&self) { + self.current_choice().paint_center(); + } + + fn show_previous_choice(&self) { + self.get_choice(self.page_counter - 1).paint_left(); + } + + fn show_next_choice(&self) { + self.get_choice(self.page_counter + 1).paint_right(); + } + + fn show_last_choice_on_left(&self) { + self.get_choice(self.last_page_index()).paint_left(); + } + + fn show_first_choice_on_right(&self) { + self.get_choice(0).paint_right(); + } + + fn decrease_page_counter(&mut self) { + self.page_counter -= 1; + } + + fn increase_page_counter(&mut self) { + self.page_counter += 1; + } + + fn page_counter_to_zero(&mut self) { + self.page_counter = 0; + } + + fn page_counter_to_max(&mut self) { + self.page_counter = self.last_page_index(); + } + + pub fn page_index(&self) -> u8 { + self.page_counter + } + + /// Updating the visual state of the buttons after each event. + /// All three buttons are handled based upon the current choice. + /// If defined in the current choice, setting their text, + /// whether they are long-pressed, and painting them. + /// + /// NOTE: ButtonController is handling the painting, and + /// it will not repaint the buttons unless some of them changed. + fn set_buttons(&mut self, ctx: &mut EventCtx) { + // TODO: offer the possibility to change the buttons from the client + // (button details could be changed in the same index) + // Use-case: BIN button in PIN is deleting last digit if the PIN is not empty, + // otherwise causing Cancel. Would be nice to allow deleting as a single click + // and Cancel as HTC. PIN client would check if the PIN is empty/not and + // adjust the HTC/not. + + let btn_layout = self.current_choice().btn_layout(); + self.buttons.mutate(ctx, |_ctx, buttons| { + buttons.set(btn_layout); + }); + } +} + +impl Component for ChoicePage +where + F: ChoiceFactory, +{ + type Msg = ChoicePageMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + let (content_area, button_area) = bounds.split_bottom(theme::BUTTON_HEIGHT); + self.pad.place(content_area); + self.buttons.place(button_area); + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + let button_event = self.buttons.event(ctx, event); + + if let Some(ButtonControllerMsg::Triggered(pos)) = button_event { + match pos { + ButtonPos::Left => { + if self.has_previous_choice() { + // Clicked BACK. Decrease the page counter. + self.decrease_page_counter(); + self.update(ctx); + } else if self.is_carousel { + // In case of carousel going to the right end. + self.page_counter_to_max(); + self.update(ctx); + } else { + // Triggered LEFTmost button. Send event + self.clear(ctx); + return Some(ChoicePageMsg::LeftMost); + } + } + ButtonPos::Right => { + if self.has_next_choice() { + // Clicked NEXT. Increase the page counter. + self.increase_page_counter(); + self.update(ctx); + } else if self.is_carousel { + // In case of carousel going to the left end. + self.page_counter_to_zero(); + self.update(ctx); + } else { + // Triggered RIGHTmost button. Send event + self.clear(ctx); + return Some(ChoicePageMsg::RightMost); + } + } + ButtonPos::Middle => { + // Clicked SELECT. Send current choice index + self.clear(ctx); + return Some(ChoicePageMsg::Choice(self.page_counter)); + } + } + }; + None + } + + fn paint(&mut self) { + self.pad.paint(); + self.buttons.paint(); + self.paint_choices(); + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for ChoicePage +where + F: ChoiceFactory, +{ + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.open("ChoicePage"); + t.kw_pair("active_page", inttostr!(self.page_counter)); + t.kw_pair("page_count", inttostr!(self.choices.count() as u8)); + t.kw_pair("is_carousel", booltostr!(self.is_carousel)); + + if self.has_previous_choice() { + t.field("prev_choice", &self.get_choice(self.page_counter - 1)); + } else if self.is_carousel { + // In case of carousel going to the left end. + t.field("prev_choice", &self.get_choice(self.last_page_index())); + } else { + t.string("prev_choice"); + t.symbol("None"); + } + + t.field("current_choice", &self.current_choice()); + + if self.has_next_choice() { + t.field("next_choice", &self.get_choice(self.page_counter + 1)); + } else if self.is_carousel { + // In case of carousel going to the very left. + t.field("next_choice", &self.get_choice(0)); + } else { + t.string("next_choice"); + t.symbol("None"); + } + + t.field("buttons", &self.buttons); + t.close(); + } +} diff --git a/core/embed/rust/src/ui/model_tr/component/choice_item.rs b/core/embed/rust/src/ui/model_tr/component/choice_item.rs new file mode 100644 index 0000000000..c192d02365 --- /dev/null +++ b/core/embed/rust/src/ui/model_tr/component/choice_item.rs @@ -0,0 +1,338 @@ +use crate::ui::{geometry::Point, display::Font, util::char_to_string}; +use heapless::String; + +use super::{ + common::{display, display_center, display_right}, + ButtonDetails, ButtonLayout, +}; + +const MIDDLE_ROW: i16 = 61; +const LEFT_COL: i16 = 1; +const MIDDLE_COL: i16 = 64; +const RIGHT_COL: i16 = 127; + +/// Helper to unite the row height. +fn row_height() -> i16 { + // It never reaches the maximum height + Font::NORMAL.line_height() - 4 +} + +/// Component that can be used as a choice item. +/// Allows to have a choice of anything that can be painted on screen. +/// +/// Controls the painting of the current, previous and next item +/// through `paint_XXX()` methods. +/// Defines the behavior of all three buttons through `btn_XXX` attributes. +/// +/// Possible implementations: +/// - [x] `TextChoiceItem` - for regular text +/// - [x] `MultilineTextChoiceItem` - for multiline text +/// - [x] `BigCharacterChoiceItem` - for one big character +/// - [ ] `IconChoiceItem` - for showing icons +/// - [ ] `JustCenterChoice` - paint_left() and paint_right() show nothing +/// - [ ] `LongStringsChoice` - paint_left() and paint_right() show ellipsis +pub trait ChoiceItemAPI { + fn paint_center(&mut self); + fn paint_left(&mut self); + fn paint_right(&mut self); + fn btn_layout(&self) -> ButtonLayout<&'static str>; +} + +// TODO: consider having +// pub trait ChoiceItemOperations {} + +// TODO: consider storing all the text components as `T: AsRef` +// Tried, but it makes the code unnecessarily messy with all the +// definitions, which needs to be added to all the components using it. + +/// Storing all the possible implementations of `ChoiceItemAPI`. +/// Done like this as we want to use multiple different choice pages +/// at the same time in `ChoicePage` - for example Multiline and BigLetters +#[derive(Clone)] +pub enum ChoiceItem { + Text(TextChoiceItem), + MultilineText(MultilineTextChoiceItem), + BigCharacter(BigCharacterChoiceItem), +} + +impl ChoiceItem { + // TODO: can we somehow avoid the repetitions here? + pub fn set_left_btn(&mut self, btn_left: Option>) { + match self { + ChoiceItem::Text(item) => item.btn_layout.btn_left = btn_left, + ChoiceItem::MultilineText(item) => item.btn_layout.btn_left = btn_left, + ChoiceItem::BigCharacter(item) => item.btn_layout.btn_left = btn_left, + } + } + + pub fn set_middle_btn(&mut self, btn_middle: Option>) { + match self { + ChoiceItem::Text(item) => item.btn_layout.btn_middle = btn_middle, + ChoiceItem::MultilineText(item) => item.btn_layout.btn_middle = btn_middle, + ChoiceItem::BigCharacter(item) => item.btn_layout.btn_middle = btn_middle, + } + } + + pub fn set_right_btn(&mut self, btn_right: Option>) { + match self { + ChoiceItem::Text(item) => item.btn_layout.btn_right = btn_right, + ChoiceItem::MultilineText(item) => item.btn_layout.btn_right = btn_right, + ChoiceItem::BigCharacter(item) => item.btn_layout.btn_right = btn_right, + } + } + + pub fn set_text(&mut self, text: String<50>) { + match self { + ChoiceItem::Text(item) => item.text = text, + ChoiceItem::MultilineText(item) => item.text = text, + ChoiceItem::BigCharacter(_) => { + panic!("No text setting for BigCharacter") + } + } + } +} + +impl ChoiceItemAPI for ChoiceItem { + fn paint_center(&mut self) { + match self { + ChoiceItem::Text(item) => item.paint_center(), + ChoiceItem::MultilineText(item) => item.paint_center(), + ChoiceItem::BigCharacter(item) => item.paint_center(), + } + } + + fn paint_left(&mut self) { + match self { + ChoiceItem::Text(item) => item.paint_left(), + ChoiceItem::MultilineText(item) => item.paint_left(), + ChoiceItem::BigCharacter(item) => item.paint_left(), + } + } + + fn paint_right(&mut self) { + match self { + ChoiceItem::Text(item) => item.paint_right(), + ChoiceItem::MultilineText(item) => item.paint_right(), + ChoiceItem::BigCharacter(item) => item.paint_right(), + } + } + + fn btn_layout(&self) -> ButtonLayout<&'static str> { + match self { + ChoiceItem::Text(item) => item.btn_layout(), + ChoiceItem::MultilineText(item) => item.btn_layout(), + ChoiceItem::BigCharacter(item) => item.btn_layout(), + } + } +} + +/// Simple string component used as a choice item. +#[derive(Clone)] +pub struct TextChoiceItem { + pub text: String<50>, + pub btn_layout: ButtonLayout<&'static str>, +} + +impl TextChoiceItem { + pub fn new(text: T, btn_layout: ButtonLayout<&'static str>) -> Self + where + T: AsRef, + { + Self { + text: String::from(text.as_ref()), + btn_layout, + } + } +} + +impl ChoiceItemAPI for TextChoiceItem { + fn paint_center(&mut self) { + // Displaying the center choice lower than the rest, + // to make it more clear this is the current choice + // (and also the left and right ones do not collide with it) + display_center( + Point::new(MIDDLE_COL, MIDDLE_ROW + row_height()), + &self.text, + Font::NORMAL, + ); + } + + fn paint_left(&mut self) { + display( + Point::new(LEFT_COL, MIDDLE_ROW), + &self.text, + Font::NORMAL, + ); + } + + fn paint_right(&mut self) { + display_right( + Point::new(RIGHT_COL, MIDDLE_ROW), + &self.text, + Font::NORMAL, + ); + } + + fn btn_layout(&self) -> ButtonLayout<&'static str> { + self.btn_layout.clone() + } +} + +/// Multiline string component used as a choice item. +/// +/// Lines are delimited by '\n' character, unless specified explicitly. +#[derive(Clone)] +pub struct MultilineTextChoiceItem { + // Arbitrary chosen. TODO: agree on this + pub text: String<50>, + delimiter: char, + pub btn_layout: ButtonLayout<&'static str>, +} + +impl MultilineTextChoiceItem { + pub fn new(text: String<50>, btn_layout: ButtonLayout<&'static str>) -> Self { + Self { + text, + delimiter: '\n', + btn_layout, + } + } + + /// Allows for changing the line delimiter to arbitrary char. + pub fn use_delimiter(mut self, delimiter: char) -> Self { + self.delimiter = delimiter; + self + } +} + +// TODO: Make all the text be centered vertically - account for amount of lines. +impl ChoiceItemAPI for MultilineTextChoiceItem { + fn paint_center(&mut self) { + // Displaying the center choice lower than the rest, + // to make it more clear this is the current choice + for (index, line) in self.text.split(self.delimiter).enumerate() { + let offset = MIDDLE_ROW + index as i16 * row_height() + row_height(); + display_center(Point::new(MIDDLE_COL, offset), &line, Font::NORMAL); + } + } + + fn paint_left(&mut self) { + for (index, line) in self.text.split(self.delimiter).enumerate() { + let offset = MIDDLE_ROW + index as i16 * row_height(); + display(Point::new(LEFT_COL, offset), &line, Font::NORMAL); + } + } + + fn paint_right(&mut self) { + for (index, line) in self.text.split(self.delimiter).enumerate() { + let offset = MIDDLE_ROW + index as i16 * row_height(); + display_right(Point::new(RIGHT_COL, offset), &line, Font::NORMAL); + } + } + + fn btn_layout(&self) -> ButtonLayout<&'static str> { + self.btn_layout.clone() + } +} + +/// Choice item displaying single characters in BIG font. +/// Middle choice is magnified 4 times, left and right 2 times. +#[derive(Clone)] +pub struct BigCharacterChoiceItem { + pub ch: char, + pub btn_layout: ButtonLayout<&'static str>, +} + +impl BigCharacterChoiceItem { + pub fn new(ch: char, btn_layout: ButtonLayout<&'static str>) -> Self { + Self { ch, btn_layout } + } + + /// Taking the first character from the `text`. + pub fn from_str(text: T, btn_layout: ButtonLayout<&'static str>) -> Self + where + T: AsRef, + { + Self { + ch: text.as_ref().chars().next().unwrap(), + btn_layout, + } + } + + fn _paint_char(&mut self, baseline: Point) { + display( + baseline, + &char_to_string::<1>(self.ch), + Font::NORMAL, + ); + } + +} + +impl ChoiceItemAPI for BigCharacterChoiceItem { + fn paint_center(&mut self) { + self._paint_char(Point::new(MIDDLE_COL - 12, MIDDLE_ROW + 9)); + } + + fn paint_left(&mut self) { + self._paint_char(Point::new(LEFT_COL, MIDDLE_ROW)); + } + + fn paint_right(&mut self) { + self._paint_char(Point::new(RIGHT_COL - 12, MIDDLE_ROW)); + } + + fn btn_layout(&self) -> ButtonLayout<&'static str> { + self.btn_layout.clone() + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for ChoiceItem { + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.open("ChoiceItem"); + match self { + ChoiceItem::Text(item) => item.trace(t), + ChoiceItem::MultilineText(item) => item.trace(t), + ChoiceItem::BigCharacter(item) => item.trace(t), + } + t.close(); + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for TextChoiceItem { + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.open("TextChoiceItem"); + t.content_flag(); + t.string(&self.text); + t.content_flag(); + t.close(); + } +} + +#[cfg(feature = "ui_debug")] +use crate::ui::util; + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for MultilineTextChoiceItem { + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.open("MultilineTextChoiceItem"); + t.content_flag(); + t.string(&self.text); + t.content_flag(); + t.field("delimiter", &(util::char_to_string::<1>(self.delimiter))); + t.close(); + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for BigCharacterChoiceItem { + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.open("BigCharacterChoiceItem"); + t.content_flag(); + t.string(&util::char_to_string::<1>(self.ch)); + t.content_flag(); + t.close(); + } +} diff --git a/core/embed/rust/src/ui/model_tr/component/common.rs b/core/embed/rust/src/ui/model_tr/component/common.rs new file mode 100644 index 0000000000..83933b9ba7 --- /dev/null +++ b/core/embed/rust/src/ui/model_tr/component/common.rs @@ -0,0 +1,120 @@ +use crate::ui::{ + display::{self, Font, Icon}, + geometry::{Offset, Point}, + model_tr::constant, +}; + +use heapless::String; + +use super::theme; + +/// Display header text. +pub fn display_header>(baseline: Point, text: T) { + // TODO: make this centered? + display::text( + baseline, + text.as_ref(), + theme::FONT_HEADER, + theme::FG, + theme::BG, + ); +} + +/// Display bold white text on black background +pub fn display>(baseline: Point, text: &T, font: Font) { + display::text(baseline, text.as_ref(), font, theme::FG, theme::BG); +} + +/// Display white text on black background, +/// centered around a baseline Point +pub fn display_center>(baseline: Point, text: &T, font: Font) { + display::text_center(baseline, text.as_ref(), font, theme::FG, theme::BG); +} + +/// Display white text on black background, +/// with right boundary at a baseline Point +pub fn display_right>(baseline: Point, text: &T, font: Font) { + display::text_right(baseline, text.as_ref(), font, theme::FG, theme::BG); +} + +const MAX_VISIBLE_CHARS: usize = 18; +const TOP_ROW_TEXT: i16 = 7; + +/// Display indication of current user input - PIN, passphrase etc. +/// Showing one asterisk for each character of the input. +pub fn display_dots_center_top(dots_amount: usize, offset_from_top: i16) { + let y_position = TOP_ROW_TEXT + offset_from_top; + let dots_visible = dots_amount.min(MAX_VISIBLE_CHARS); + + // String::repeat() is not available for heapless::String + let mut dots: String = String::new(); + for _ in 0..dots_visible { + dots.push_str("*").unwrap(); + } + + // Giving some notion of change even for longer-than-visible passphrases + // - slightly shifting the dots to the left and right after each new digit + if dots_amount > MAX_VISIBLE_CHARS && dots_amount % 2 == 0 { + display_center(Point::new(61, y_position), &dots, Font::MONO); + } else { + display_center(Point::new(64, y_position), &dots, Font::MONO); + } +} + +/// Display secret input that user is currently doing - PIN, passphrase etc. +pub fn display_secret_center_top>(secret: T, offset_from_top: i16) { + let y_position = TOP_ROW_TEXT + offset_from_top; + let char_amount = secret.as_ref().len(); + if char_amount <= MAX_VISIBLE_CHARS { + display_center(Point::new(64, y_position), &secret, Font::MONO); + } else { + // Show the last part with preceding ellipsis to show something is hidden + let ellipsis = "..."; + let offset: usize = char_amount.saturating_sub(MAX_VISIBLE_CHARS) + ellipsis.len(); + let to_show = build_string!(MAX_VISIBLE_CHARS, ellipsis, &secret.as_ref()[offset..]); + display_center(Point::new(64, y_position), &to_show, Font::MONO); + } +} + +/// Display title and possible subtitle together with a dotted line spanning +/// the entire width. +/// Returning the painted height of the whole header. +pub fn paint_header>(top_left: Point, title: T, subtitle: Option) -> i16 { + let text_heigth = theme::FONT_HEADER.text_height(); + let title_baseline = top_left + Offset::y(text_heigth); + display_header(title_baseline, title); + // Optionally painting the subtitle as well + // (and offsetting the dotted line in that case) + let mut dotted_line_offset = text_heigth + 2; + if let Some(subtitle) = subtitle { + dotted_line_offset += text_heigth; + display_header(title_baseline + Offset::y(text_heigth), subtitle); + } + let line_start = top_left + Offset::y(dotted_line_offset); + display::dotted_line_horizontal(line_start, constant::WIDTH, theme::FG, 2); + dotted_line_offset +} + +/// Draws icon and text on the same line - icon on the left. +pub fn icon_with_text>(baseline: Point, icon: Icon, text: T, font: Font) { + icon.draw_bottom_left(baseline, theme::FG, theme::BG); + let text_x_offset = icon.width() + 2; + display(baseline + Offset::x(text_x_offset), &text.as_ref(), font); +} + +/// Draw two lines - icon with label text (key) and another text (value) below. +/// Returns the height painted below the given baseline. +pub fn key_value_icon>( + baseline: Point, + icon: Icon, + label: T, + label_font: Font, + value: T, + value_font: Font, +) -> i16 { + icon_with_text(baseline, icon, label, label_font); + let line_height = value_font.line_height(); + let next_line = baseline + Offset::y(line_height); + display(next_line, &value, value_font); + line_height +} diff --git a/core/embed/rust/src/ui/model_tr/component/confirm.rs b/core/embed/rust/src/ui/model_tr/component/confirm.rs index aa29c6303b..b37c04f941 100644 --- a/core/embed/rust/src/ui/model_tr/component/confirm.rs +++ b/core/embed/rust/src/ui/model_tr/component/confirm.rs @@ -1,9 +1,10 @@ use crate::{ - time::Instant, + time::{Duration, Instant}, ui::{ component::{Component, Event, EventCtx}, + display::Icon, event::ButtonEvent, - geometry::{Point, Rect}, + geometry::Rect, model_tr::component::{loader::Loader, ButtonPos, LoaderMsg, LoaderStyleSheet}, }, }; @@ -13,36 +14,74 @@ pub enum HoldToConfirmMsg { FailedToConfirm, } -pub struct HoldToConfirm { +pub struct HoldToConfirm { area: Rect, pos: ButtonPos, - loader: Loader, - baseline: Point, + loader: Loader, text_width: i16, } -impl HoldToConfirm { - pub fn new(pos: ButtonPos, text: &'static str, styles: LoaderStyleSheet) -> Self { +// TODO: support icons +// TODO: could have some icon signaling "HOLD", so that we do not need +// to write `HOLD TO CONFIRM` (which hardly fits the screen), but just `{icon} +// CONFIRM` TODO: could unite with `Button` so we can use the same features for +// both + +impl> HoldToConfirm { + pub fn text(pos: ButtonPos, text: T, styles: LoaderStyleSheet, duration: Duration) -> Self { let text_width = styles.normal.font.text_width(text.as_ref()); Self { area: Rect::zero(), pos, - loader: Loader::new(text, styles), - baseline: Point::zero(), + loader: Loader::text(text, styles).with_growing_duration(duration), text_width, } } + pub fn icon(pos: ButtonPos, icon: Icon, styles: LoaderStyleSheet, duration: Duration) -> Self { + let text_width = icon.width() as i16; + Self { + area: Rect::zero(), + pos, + loader: Loader::icon(icon, styles).with_growing_duration(duration), + text_width, + } + } + + /// Updating the text of the component and re-placing it. + pub fn set_text(&mut self, text: T, button_area: Rect) { + self.text_width = self.loader.get_text_width(&text) as i16; + self.loader.set_text(text); + self.place(button_area); + } + + pub fn reset(&mut self) { + self.loader.reset(); + } + + pub fn set_duration(&mut self, duration: Duration) { + self.loader.set_duration(duration); + } + + pub fn get_duration(&self) -> Duration { + self.loader.get_duration() + } + + pub fn get_text(&self) -> &T { + self.loader.get_text() + } + fn placement(&mut self, area: Rect, pos: ButtonPos) -> Rect { let button_width = self.text_width + 7; match pos { ButtonPos::Left => area.split_left(button_width).0, ButtonPos::Right => area.split_right(button_width).1, + ButtonPos::Middle => area.split_center(button_width).1, } } } -impl Component for HoldToConfirm { +impl> Component for HoldToConfirm { type Msg = HoldToConfirmMsg; fn place(&mut self, bounds: Rect) -> Rect { @@ -52,10 +91,10 @@ impl Component for HoldToConfirm { fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { match event { - Event::Button(ButtonEvent::ButtonPressed(which)) if self.pos.hit(&which) => { + Event::Button(ButtonEvent::HoldStarted) => { self.loader.start_growing(ctx, Instant::now()); } - Event::Button(ButtonEvent::ButtonReleased(which)) if self.pos.hit(&which) => { + Event::Button(ButtonEvent::HoldEnded) => { if self.loader.is_animating() { self.loader.start_shrinking(ctx, Instant::now()); } @@ -81,7 +120,7 @@ impl Component for HoldToConfirm { } #[cfg(feature = "ui_debug")] -impl crate::trace::Trace for HoldToConfirm { +impl> crate::trace::Trace for HoldToConfirm { fn trace(&self, d: &mut dyn crate::trace::Tracer) { d.open("HoldToConfirm"); self.loader.trace(d); diff --git a/core/embed/rust/src/ui/model_tr/component/dialog.rs b/core/embed/rust/src/ui/model_tr/component/dialog.rs index 39820b3b36..630466b3fe 100644 --- a/core/embed/rust/src/ui/model_tr/component/dialog.rs +++ b/core/embed/rust/src/ui/model_tr/component/dialog.rs @@ -1,8 +1,7 @@ use super::button::{Button, ButtonMsg::Clicked}; use crate::ui::{ component::{Child, Component, Event, EventCtx}, - display::Font, - geometry::Rect, + geometry::Rect, model_tr::theme, }; pub enum DialogMsg { @@ -43,7 +42,7 @@ where type Msg = DialogMsg; fn place(&mut self, bounds: Rect) -> Rect { - let button_height = Font::BOLD.line_height() + 2; + let button_height = theme::FONT_BUTTON.line_height() + 2; let (content_area, button_area) = bounds.split_bottom(button_height); self.content.place(content_area); self.left_btn.as_mut().map(|b| b.place(button_area)); diff --git a/core/embed/rust/src/ui/model_tr/component/flow.rs b/core/embed/rust/src/ui/model_tr/component/flow.rs new file mode 100644 index 0000000000..014a15f023 --- /dev/null +++ b/core/embed/rust/src/ui/model_tr/component/flow.rs @@ -0,0 +1,255 @@ +use crate::{ + micropython::buffer::StrBuffer, + ui::{ + component::{Child, Component, Event, EventCtx, Pad}, + geometry::{Point, Rect}, + }, +}; + +use super::{ + common, theme, ButtonAction, ButtonController, ButtonControllerMsg, ButtonLayout, ButtonPos, + FlowPages, Page, +}; + +/// To be returned directly from Flow. +pub enum FlowMsg { + Confirmed, + Cancelled, +} + +// TODO: consider each FlowPage having the ability +// to handle custom actions triggered by some btn. + +pub struct Flow { + pages: FlowPages, + current_page: Page, + common_title: Option, + content_area: Rect, + pad: Pad, + buttons: Child>, + page_counter: u8, +} + +impl Flow +where + F: Fn(u8) -> Page, +{ + pub fn new(pages: FlowPages) -> Self { + let current_page = pages.get(0); + Self { + pages, + current_page, + common_title: None, + content_area: Rect::zero(), + pad: Pad::with_background(theme::BG), + // Setting empty layout for now, we do not yet know how many sub-pages the first page + // has. Initial button layout will be set in `place()` after we can call + // `content.page_count()`. + buttons: Child::new(ButtonController::new(ButtonLayout::empty())), + page_counter: 0, + } + } + + /// Adding a common title to all pages. The title will not be colliding + /// with the page content, as the content will be offset. + pub fn with_common_title(mut self, title: StrBuffer) -> Self { + self.common_title = Some(title); + self + } + + /// Placing current page, setting current buttons and clearing. + fn update(&mut self, ctx: &mut EventCtx, get_new_page: bool) { + if get_new_page { + self.current_page = self.pages.get(self.page_counter); + } + let content_area = self.content_area; + self.current_page.place(content_area); + self.set_buttons(ctx); + self.clear(ctx); + } + + /// Clearing the whole area and requesting repaint. + fn clear(&mut self, ctx: &mut EventCtx) { + self.pad.clear(); + ctx.request_paint(); + } + + /// Going to the previous page. + fn go_to_prev_page(&mut self, ctx: &mut EventCtx) { + self.page_counter -= 1; + self.update(ctx, true); + } + + /// Going to the next page. + fn go_to_next_page(&mut self, ctx: &mut EventCtx) { + self.page_counter += 1; + self.update(ctx, true); + } + + /// Going to page by its absolute index. + /// Negative index means counting from the end. + fn go_to_page_absolute(&mut self, index: i16, ctx: &mut EventCtx) { + if index < 0 { + self.page_counter = (self.pages.count() as i16 + index) as u8; + } else { + self.page_counter = index as u8; + } + self.update(ctx, true); + } + + /// Jumping to another page relative to the current one. + fn go_to_page_relative(&mut self, jump: i16, ctx: &mut EventCtx) { + self.page_counter = (self.page_counter as i16 + jump) as u8; + self.update(ctx, true); + } + + /// Updating the visual state of the buttons after each event. + /// All three buttons are handled based upon the current choice. + /// If defined in the current choice, setting their text, + /// whether they are long-pressed, and painting them. + /// + /// NOTE: ButtonController is handling the painting, and + /// it will not repaint the buttons unless some of them changed. + fn set_buttons(&mut self, ctx: &mut EventCtx) { + let btn_layout = self.current_page.btn_layout(); + self.buttons.mutate(ctx, |_ctx, buttons| { + buttons.set(btn_layout); + }); + } + + /// When current choice contains paginated content, it may use the button + /// event to just paginate itself. + fn event_consumed_by_current_choice(&mut self, ctx: &mut EventCtx, pos: ButtonPos) -> bool { + if matches!(pos, ButtonPos::Left) && self.current_page.has_prev_page() { + self.current_page.go_to_prev_page(); + self.update(ctx, false); + true + } else if matches!(pos, ButtonPos::Right) && self.current_page.has_next_page() { + self.current_page.go_to_next_page(); + self.update(ctx, false); + true + } else { + false + } + } +} + +impl Component for Flow +where + F: Fn(u8) -> Page, +{ + type Msg = FlowMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + let (title_content_area, button_area) = bounds.split_bottom(theme::BUTTON_HEIGHT); + // Accounting for possible title + let content_area = if self.common_title.is_some() { + title_content_area.split_top(10).1 + } else { + title_content_area + }; + self.content_area = content_area; + + // We finally found how long is the first page, and can set its button layout. + self.current_page.place(content_area); + self.buttons = Child::new(ButtonController::new(self.current_page.btn_layout())); + + self.pad.place(title_content_area); + self.buttons.place(button_area); + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + let button_event = self.buttons.event(ctx, event); + + // Do something when a button was triggered + // and we have some action connected with it + if let Some(ButtonControllerMsg::Triggered(pos)) = button_event { + // When there is a previous or next screen in the current flow, + // handle that first and in case it triggers, then do not continue + if self.event_consumed_by_current_choice(ctx, pos) { + return None; + } + + let actions = self.current_page.btn_actions(); + let action = actions.get_action(pos); + if let Some(action) = action { + match action { + ButtonAction::PrevPage => { + self.go_to_prev_page(ctx); + return None; + } + ButtonAction::NextPage => { + self.go_to_next_page(ctx); + return None; + } + ButtonAction::GoToIndex(index) => { + self.go_to_page_absolute(index, ctx); + return None; + } + ButtonAction::MovePageRelative(jump) => { + self.go_to_page_relative(jump, ctx); + return None; + } + ButtonAction::Cancel => return Some(FlowMsg::Cancelled), + ButtonAction::Confirm => return Some(FlowMsg::Confirmed), + ButtonAction::Select => {} + ButtonAction::Action(_) => {} + } + } + }; + None + } + + fn paint(&mut self) { + // TODO: might put horizontal scrollbar at the top right + self.pad.paint(); + self.buttons.paint(); + if let Some(title) = &self.common_title { + common::paint_header(Point::zero(), title, None); + } + self.current_page.paint(); + } +} + +#[cfg(feature = "ui_debug")] +use heapless::String; + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for Flow +where + F: Fn(u8) -> Page, +{ + /// Accounting for the possibility that button is connected with the + /// currently paginated flow_page (only Prev or Next in that case). + fn get_btn_action(&self, pos: ButtonPos) -> String<25> { + if matches!(pos, ButtonPos::Left) && self.current_page.has_prev_page() { + ButtonAction::PrevPage.string() + } else if matches!(pos, ButtonPos::Right) && self.current_page.has_next_page() { + ButtonAction::NextPage.string() + } else { + let btn_actions = self.current_page.btn_actions(); + + match btn_actions.get_action(pos) { + Some(action) => action.string(), + None => ButtonAction::empty(), + } + } + } + + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.open("Flow"); + t.kw_pair("flow_page", inttostr!(self.page_counter)); + t.kw_pair("flow_page_count", inttostr!(self.pages.count())); + + self.report_btn_actions(t); + + if let Some(title) = &self.common_title { + t.title(title.as_ref()); + } + t.field("content_area", &self.content_area); + t.field("buttons", &self.buttons); + t.field("flow_page", &self.current_page); + t.close() + } +} diff --git a/core/embed/rust/src/ui/model_tr/component/flow_pages.rs b/core/embed/rust/src/ui/model_tr/component/flow_pages.rs new file mode 100644 index 0000000000..a39579a8b6 --- /dev/null +++ b/core/embed/rust/src/ui/model_tr/component/flow_pages.rs @@ -0,0 +1,309 @@ +use crate::{ + micropython::{buffer::StrBuffer}, + ui::{ + component::Paginate, + display::{Font, Icon, IconAndName}, + geometry::{Offset, Rect}, + model_tr::theme, + util::ResultExt + }, +}; + +use heapless::Vec; + +use super::{ + flow_pages_poc_helpers::{ + LayoutFit, LayoutSink, LineAlignment, Op, TextLayout, TextNoOp, TextRenderer, TextStyle, + ToDisplay, + }, + ButtonActions, ButtonDetails, ButtonLayout, +}; + +/// Holding specific workflows that are created in `layout.rs`. +/// Is returning a `Page` (page/screen) on demand +/// based on the current page in `Flow`. +/// Before, when `layout.rs` was defining a `heapless::Vec` of `Page`s, +/// it was a very stack-expensive operation and StackOverflow was encountered. +/// With this "lazy-loading" approach (creating each page on demand) we can +/// have theoretically unlimited number of pages without triggering SO. +/// (Currently only the current page is stored on stack - in +/// `Flow::current_page`.) +pub struct FlowPages { + /// Function/closure that will return appropriate page on demand. + get_page: F, + /// Number of pages in the flow. + page_count: u8, +} + +impl FlowPages +where + F: Fn(u8) -> Page, +{ + pub fn new(get_page: F, page_count: u8) -> Self { + Self { + get_page, + page_count, + } + } + + pub fn get(&self, page_index: u8) -> Page { + (self.get_page)(page_index) + } + + pub fn count(&self) -> u8 { + self.page_count + } +} + +#[derive(Clone)] +pub struct Page { + ops: Vec, + layout: TextLayout, + btn_layout: ButtonLayout<&'static str>, + btn_actions: ButtonActions, + current_page: usize, + page_count: usize, + char_offset: usize, +} + +// For `layout.rs` +impl Page { + pub fn new(btn_layout: ButtonLayout<&'static str>, btn_actions: ButtonActions) -> Self { + let style = TextStyle::new( + Font::NORMAL, + theme::FG, + theme::BG, + theme::FG, + theme::FG, + ); + Self { + ops: Vec::new(), + layout: TextLayout::new(style), + btn_layout, + btn_actions, + current_page: 0, + page_count: 1, + char_offset: 0, + } + } +} + +// For `flow.rs` +impl Page { + pub fn paint(&mut self) { + self.change_page(self.current_page); + self.layout_content(&mut TextRenderer); + } + + pub fn btn_layout(&self) -> ButtonLayout<&'static str> { + // When we are in pagination inside this flow, + // show the up and down arrows on appropriate sides + let current = self.btn_layout.clone(); + + let btn_left = if self.has_prev_page() { + Some(ButtonDetails::up_arrow_icon_wide()) + } else { + current.btn_left + }; + let btn_right = if self.has_next_page() { + Some(ButtonDetails::down_arrow_icon_wide()) + } else { + current.btn_right + }; + + ButtonLayout::new(btn_left, current.btn_middle, btn_right) + } + + pub fn place(&mut self, bounds: Rect) -> Rect { + self.layout.bounds = bounds; + self.page_count = self.page_count(); + bounds + } + + pub fn btn_actions(&self) -> ButtonActions { + self.btn_actions.clone() + } + + pub fn has_prev_page(&self) -> bool { + self.current_page > 0 + } + + pub fn has_next_page(&self) -> bool { + self.current_page < self.page_count - 1 + } + + pub fn go_to_prev_page(&mut self) { + self.current_page -= 1; + } + + pub fn go_to_next_page(&mut self) { + self.current_page += 1; + } +} + +// For `layout.rs` - single operations +impl Page { + pub fn with_new_item(mut self, item: Op) -> Self { + self.ops + .push(item) + .assert_if_debugging_ui("Could not push to self.ops"); + self + } + + pub fn text(self, text: StrBuffer) -> Self { + self.with_new_item(Op::Text(ToDisplay::new(text))) + } + + pub fn newline(self) -> Self { + self.with_new_item(Op::Text(ToDisplay::new("\n".into()))) + } + + pub fn newline_half(self) -> Self { + self.with_new_item(Op::Text(ToDisplay::new("\r".into()))) + } + + pub fn next_page(self) -> Self { + self.with_new_item(Op::NextPage) + } + + pub fn icon(self, icon: IconAndName) -> Self { + self.with_new_item(Op::Icon(Icon::new(icon))) + } + + pub fn font(self, font: Font) -> Self { + self.with_new_item(Op::Font(font)) + } + + pub fn offset(self, offset: Offset) -> Self { + self.with_new_item(Op::CursorOffset(offset)) + } + + pub fn alignment(self, alignment: LineAlignment) -> Self { + self.with_new_item(Op::LineAlignment(alignment)) + } +} + +// For `layout.rs` - aggregating operations +impl Page { + pub fn icon_label_text(self, icon: IconAndName, label: StrBuffer, text: StrBuffer) -> Self { + self.icon_with_offset(icon, 3) + .text_normal(label) + .newline() + .text_bold(text) + } + + pub fn icon_with_offset(self, icon: IconAndName, x_offset: i16) -> Self { + self.icon(icon).offset(Offset::x(x_offset)) + } + + pub fn text_normal(self, text: StrBuffer) -> Self { + self.font(Font::NORMAL).text(text) + } + + pub fn text_bold(self, text: StrBuffer) -> Self { + self.font(Font::BOLD).text(text) + } +} + +// For painting and pagination +impl Page { + pub fn set_char_offset(&mut self, char_offset: usize) { + self.char_offset = char_offset; + } + + pub fn layout_content(&self, sink: &mut dyn LayoutSink) -> LayoutFit { + let mut cursor = self.layout.initial_cursor(); + self.layout + .layout_ops(self.ops.clone(), &mut cursor, self.char_offset, sink) + } +} + +// Pagination +impl Paginate for Page { + fn page_count(&mut self) -> usize { + let mut page_count = 1; // There's always at least one page. + let mut char_offset = 0; + + // Make sure we're starting from the beginning. + self.set_char_offset(char_offset); + + // Looping through the content and counting pages + // until we finally fit. + loop { + let fit = self.layout_content(&mut TextNoOp); + match fit { + LayoutFit::Fitting { .. } => { + break; // TODO: We should consider if there's more content + // to render. + } + LayoutFit::OutOfBounds { + processed_chars, .. + } => { + page_count += 1; + char_offset += processed_chars; + self.set_char_offset(char_offset); + } + } + } + + // Reset the char offset back to the beginning. + self.set_char_offset(0); + + page_count + } + + fn change_page(&mut self, to_page: usize) { + let mut active_page = 0; + let mut char_offset = 0; + + // Make sure we're starting from the beginning. + self.set_char_offset(char_offset); + + // Looping through the content until we arrive at + // the wanted page. + while active_page < to_page { + let fit = self.layout_content(&mut TextNoOp); + match fit { + LayoutFit::Fitting { .. } => { + break; // TODO: We should consider if there's more content + // to render. + } + LayoutFit::OutOfBounds { + processed_chars, .. + } => { + active_page += 1; + char_offset += processed_chars; + self.set_char_offset(char_offset); + } + } + } + } +} + +#[cfg(feature = "ui_debug")] +pub mod trace { + use crate::ui::model_tr::component::flow_pages_poc_helpers::TraceSink; + + use super::*; + + pub struct TraceText<'a, const M: usize>(pub &'a Page); + + impl<'a, const M: usize> crate::trace::Trace for TraceText<'a, M> { + fn trace(&self, d: &mut dyn crate::trace::Tracer) { + d.content_flag(); + self.0.layout_content(&mut TraceSink(d)); + d.content_flag(); + } + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for Page { + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.open("Page"); + t.kw_pair("active_page", inttostr!(self.current_page as u8)); + t.kw_pair("page_count", inttostr!(self.page_count as u8)); + t.field("content", &trace::TraceText(self)); + t.close(); + } +} diff --git a/core/embed/rust/src/ui/model_tr/component/flow_pages_poc_helpers.rs b/core/embed/rust/src/ui/model_tr/component/flow_pages_poc_helpers.rs new file mode 100644 index 0000000000..20e52e15c1 --- /dev/null +++ b/core/embed/rust/src/ui/model_tr/component/flow_pages_poc_helpers.rs @@ -0,0 +1,716 @@ +//! Mostly copy-pasted stuff from ui/component/text, +//! but with small modifications. +//! It is really mostly changing Op::Text(&'a str) to Op::Text(String<100>), +//! having self.ops as Vec and changes revolving around it. +//! Even if some stuff could be reused now, I copy-pasted it anyway, as this +//! extension for Icons, Offsets, etc. should no longer live in +//! ui/component/text, and so they can be freely removed (as they are here as +//! well). + +use crate::{ + micropython::buffer::StrBuffer, + ui::{ + display::{self, Color, Font, Icon}, + geometry::{Offset, Point, Rect}, + }, +}; + +use heapless::Vec; + +// TODO: consider moving into T: AsRef instead if StrBuffer? +#[derive(Clone)] +pub struct ToDisplay { + pub text: StrBuffer, + pub length: usize, +} + +impl ToDisplay { + pub fn new(text: StrBuffer) -> Self { + Self { + text: text.clone(), + length: text.len(), + } + } +} + +/// Operations that can be done on FormattedText. +#[derive(Clone)] +pub enum Op { + /// Render text with current color and font. + Text(ToDisplay), + /// Render icon. + Icon(Icon), + /// Set current text color. + Color(Color), + /// Set currently used font. + Font(Font), + /// Set currently used line alignment. + LineAlignment(LineAlignment), + /// Move the current cursor by specified Offset. + CursorOffset(Offset), + /// Force continuing on the next page. + NextPage, +} + +#[derive(Copy, Clone)] +pub enum LineBreaking { + /// Break line only at whitespace, if possible. If we don't find any + /// whitespace, break words. + BreakAtWhitespace, + /// Break words, adding a hyphen before the line-break. Does not use any + /// smart algorithm, just char-by-char. + BreakWordsAndInsertHyphen, +} + +#[derive(Copy, Clone)] +pub enum PageBreaking { + /// Stop after hitting the bottom-right edge of the bounds. + Cut, + /// Before stopping at the bottom-right edge, insert ellipsis to signify + /// more content is available, but only if no hyphen has been inserted yet. + CutAndInsertEllipsis, +} + +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum LineAlignment { + Left, + Center, + Right, +} + +/// Visual instructions for laying out a formatted block of text. +#[derive(Copy, Clone)] +pub struct TextLayout { + /// Bounding box restricting the layout dimensions. + pub bounds: Rect, + + /// Additional space before beginning of text, can be negative to shift text + /// upwards. + pub padding_top: i16, + /// Additional space between end of text and bottom of bounding box, can be + /// negative. + pub padding_bottom: i16, + + /// Fonts, colors, line/page breaking behavior. + pub style: TextStyle, +} + +#[derive(Copy, Clone)] +pub struct TextStyle { + /// Text font ID. Can be overridden by `Op::Font`. + pub text_font: Font, + /// Text color. Can be overridden by `Op::Color`. + pub text_color: Color, + /// Background color. + pub background_color: Color, + + /// Foreground color used for drawing the hyphen. + pub hyphen_color: Color, + /// Foreground color used for drawing the ellipsis. + pub ellipsis_color: Color, + + /// Specifies which line-breaking strategy to use. + pub line_breaking: LineBreaking, + /// Specifies what to do at the end of the page. + pub page_breaking: PageBreaking, + + /// Specifies how to align text on the line. + pub line_alignment: LineAlignment, +} + +impl TextStyle { + pub const fn new( + text_font: Font, + text_color: Color, + background_color: Color, + hyphen_color: Color, + ellipsis_color: Color, + ) -> Self { + TextStyle { + text_font, + text_color, + background_color, + hyphen_color, + ellipsis_color, + line_breaking: LineBreaking::BreakAtWhitespace, + page_breaking: PageBreaking::CutAndInsertEllipsis, + line_alignment: LineAlignment::Left, + } + } +} + +impl TextLayout { + /// Create a new text layout, with empty size and default text parameters + /// filled from `T`. + pub fn new(style: TextStyle) -> Self { + Self { + bounds: Rect::zero(), + padding_top: 0, + padding_bottom: 0, + style, + } + } + + pub fn with_bounds(mut self, bounds: Rect) -> Self { + self.bounds = bounds; + self + } + + /// Baseline `Point` where we are starting to draw the text. + pub fn initial_cursor(&self) -> Point { + self.bounds.top_left() + Offset::y(self.style.text_font.text_height() + self.padding_top) + } + + /// Trying to fit the content on the current screen. + pub fn fit_text(&self, text: &str) -> LayoutFit { + self.layout_text(text, &mut self.initial_cursor(), &mut TextNoOp) + } + + /// Draw as much text as possible on the current screen. + pub fn render_text(&self, text: &str) { + self.layout_text(text, &mut self.initial_cursor(), &mut TextRenderer); + } + + /// Y coordinate of the bottom of the available space/bounds + pub fn bottom_y(&self) -> i16 { + (self.bounds.y1 - self.padding_bottom).max(self.bounds.y0) + } + + /// X coordinate of the right of the available space/bounds + pub fn right_x(&self) -> i16 { + self.bounds.x1 + } + + /// Perform some operations defined on `Op` for a list of those `Op`s + /// - e.g. changing the color, changing the font or rendering the text. + pub fn layout_ops( + mut self, + ops: Vec, + cursor: &mut Point, + skip_bytes: usize, + sink: &mut dyn LayoutSink, + ) -> LayoutFit { + let init_cursor = *cursor; + let mut total_processed_chars = 0; + + let mut skipped = 0; + for op in ops { + let real_op = { + match op { + Op::Text(to_display) if skipped < skip_bytes => { + skipped = skipped.saturating_add(to_display.length); + if skipped > skip_bytes { + let leave_bytes = skipped - skip_bytes; + let new_display = ToDisplay { + text: to_display.text, + length: leave_bytes, + }; + Some(Op::Text(new_display)) + } else { + None + } + } + Op::Icon(_) if skipped < skip_bytes => { + // Assume the icon accounts for one character + skipped = skipped.saturating_add(1); + None + } + Op::NextPage if skipped < skip_bytes => { + // Skip the next page and consider it one character + skipped = skipped.saturating_add(1); + None + } + Op::CursorOffset(_) if skipped < skip_bytes => { + // Skip any offsets + None + } + op_to_pass_through => Some(op_to_pass_through.clone()), + } + }; + + if let Some(op) = real_op { + match op { + // Changing color + Op::Color(color) => { + self.style.text_color = color; + } + // Changing font + Op::Font(font) => { + self.style.text_font = font; + } + // Changing line/text alignment + Op::LineAlignment(line_alignment) => { + self.style.line_alignment = line_alignment; + } + // Moving the cursor + Op::CursorOffset(offset) => { + cursor.x += offset.x; + cursor.y += offset.y; + } + // Moving to the next page + Op::NextPage => { + // Pretending that nothing more fits on current page to force + // continuing on the next one + // Making that to account for one character for pagination purposes + total_processed_chars += 1; + return LayoutFit::OutOfBounds { + processed_chars: total_processed_chars, + height: self.layout_height(init_cursor, *cursor), + }; + } + // Drawing text or icon + Op::Text(_) | Op::Icon(_) => { + // Text and Icon behave similarly - we try to fit them + // on the current page and if they do not fit, + // return the appropriate OutOfBounds message + let fit = if let Op::Text(to_display) = op { + let text = to_display.text.as_ref(); + let text_len = to_display.length; + let start = text.len() - text_len; + let to_really_display = &text[start..]; + // let to_really_display = text.text[text.start..text.end].to_string(); + self.layout_text(to_really_display, cursor, sink) + } else if let Op::Icon(icon) = op { + self.layout_icon(icon, cursor, sink) + } else { + panic!("unexpected op type"); + }; + + match fit { + LayoutFit::Fitting { + processed_chars, .. + } => { + total_processed_chars += processed_chars; + } + LayoutFit::OutOfBounds { + processed_chars, .. + } => { + total_processed_chars += processed_chars; + + return LayoutFit::OutOfBounds { + processed_chars: total_processed_chars, + height: self.layout_height(init_cursor, *cursor), + }; + } + } + } + } + } + } + + LayoutFit::Fitting { + processed_chars: total_processed_chars, + height: self.layout_height(init_cursor, *cursor), + } + } + + /// Loop through the `text` and try to fit it on the current screen, + /// reporting events to `sink`, which may do something with them (e.g. draw + /// on screen). + pub fn layout_text( + &self, + text: &str, + cursor: &mut Point, + sink: &mut dyn LayoutSink, + ) -> LayoutFit { + let init_cursor = *cursor; + let mut remaining_text = text; + + // Check if bounding box is high enough for at least one line. + if cursor.y > self.bottom_y() { + sink.out_of_bounds(); + return LayoutFit::OutOfBounds { + processed_chars: 0, + height: 0, + }; + } + + while !remaining_text.is_empty() { + let span = Span::fit_horizontally( + remaining_text, + self.bounds.x1 - cursor.x, + self.style.text_font, + self.style.line_breaking, + ); + + // Report the span at the cursor position. + // Not doing it when the span length is 0, as that + // means we encountered a newline/line-break, which we do not draw. + // Line-breaks are reported later. + if span.length > 0 { + sink.text(*cursor, self, &remaining_text[..span.length]); + } + + // Continue with the rest of the remaining_text. + remaining_text = &remaining_text[span.length + span.skip_next_chars..]; + + // Advance the cursor horizontally. + cursor.x += span.advance.x; + + if span.advance.y > 0 { + // We're advancing to the next line. + + // Check if we should be appending a hyphen at this point. + if span.insert_hyphen_before_line_break { + sink.hyphen(*cursor, self); + } + // Check the amount of vertical space we have left. + if cursor.y + span.advance.y > self.bottom_y() { + if !remaining_text.is_empty() { + // Append ellipsis to indicate more content is available, but only if we + // haven't already appended a hyphen. + let should_append_ellipsis = + matches!(self.style.page_breaking, PageBreaking::CutAndInsertEllipsis) + && !span.insert_hyphen_before_line_break; + if should_append_ellipsis { + sink.ellipsis(*cursor, self); + } + // TODO: This does not work in case we are the last + // fitting text token on the line, with more text tokens + // following and `text.is_empty() == true`. + } + + // Report we are out of bounds and quit. + sink.out_of_bounds(); + + return LayoutFit::OutOfBounds { + processed_chars: text.len() - remaining_text.len(), + height: self.layout_height(init_cursor, *cursor), + }; + } else { + // Advance the cursor to the beginning of the next line. + cursor.x = self.bounds.x0; + cursor.y += span.advance.y; + + // Report a line break. While rendering works using the cursor coordinates, we + // use explicit line-break reporting in the `Trace` impl. + sink.line_break(*cursor); + } + } + } + + LayoutFit::Fitting { + processed_chars: text.len(), + height: self.layout_height(init_cursor, *cursor), + } + } + + /// Try to fit the `icon` on the current screen + pub fn layout_icon( + &self, + icon: Icon, + cursor: &mut Point, + sink: &mut dyn LayoutSink, + ) -> LayoutFit { + // Check if bounding box is high enough for at least one line. + if cursor.y > self.bottom_y() { + sink.out_of_bounds(); + return LayoutFit::OutOfBounds { + processed_chars: 0, + height: 0, + }; + } + + // Icon is too wide to fit on current line. + // Trying to accommodate it on the next line, when it exists on this page. + if cursor.x + icon.width() > self.right_x() { + cursor.x = self.bounds.x0; + cursor.y += self.style.text_font.line_height(); + if cursor.y > self.bottom_y() { + sink.out_of_bounds(); + return LayoutFit::OutOfBounds { + processed_chars: 0, + height: self.style.text_font.line_height(), + }; + } + } + + sink.icon(*cursor, self, icon); + + // TODO: currently we are using just small icons - that fit nicely to one line - + // but in case we would do bigger ones, we would need some anti-collision + // mechanism. + + cursor.x += icon.width() as i16; + LayoutFit::Fitting { + // TODO: how to handle this? It could collide with "skip_first_n_bytes" + processed_chars: 1, + height: 0, // it should just draw on one line + } + } + + /// Overall height of the content, including paddings. + fn layout_height(&self, init_cursor: Point, end_cursor: Point) -> i16 { + self.padding_top + + self.style.text_font.text_height() + + (end_cursor.y - init_cursor.y) + + self.padding_bottom + } +} + +/// Whether we can fit content on the current screen. +/// Knows how many characters got processed and how high the content is. +pub enum LayoutFit { + /// Entire content fits. Vertical size is returned in `height`. + Fitting { processed_chars: usize, height: i16 }, + /// Content fits partially or not at all. + OutOfBounds { processed_chars: usize, height: i16 }, +} + +impl LayoutFit { + /// How high is the processed/fitted content. + pub fn height(&self) -> i16 { + match self { + LayoutFit::Fitting { height, .. } => *height, + LayoutFit::OutOfBounds { height, .. } => *height, + } + } +} + +// TODO: LayoutSink could support even things like drawing icons +// or making custom x or y offsets from any position + +/// Visitor for text segment operations. +/// Defines responses for certain kind of events encountered +/// when processing the content. +pub trait LayoutSink { + /// Text should be processed. + fn text(&mut self, _cursor: Point, _layout: &TextLayout, _text: &str) {} + /// Text should be processed. + fn icon(&mut self, _cursor: Point, _layout: &TextLayout, _icon: Icon) {} + /// Hyphen at the end of line. + fn hyphen(&mut self, _cursor: Point, _layout: &TextLayout) {} + /// Ellipsis at the end of the page. + fn ellipsis(&mut self, _cursor: Point, _layout: &TextLayout) {} + /// Line break - a newline. + fn line_break(&mut self, _cursor: Point) {} + /// Content cannot fit on the screen. + fn out_of_bounds(&mut self) {} +} + +/// `LayoutSink` without any functionality. +/// Used to consume events when counting pages +/// or navigating to a certain page number. +pub struct TextNoOp; + +impl LayoutSink for TextNoOp {} + +/// `LayoutSink` for rendering the content. +pub struct TextRenderer; + +impl LayoutSink for TextRenderer { + fn text(&mut self, cursor: Point, layout: &TextLayout, text: &str) { + // Accounting for the line-alignment - left, right or center. + // Assume the current line can be drawn on from the cursor + // to the right side of the screen. + + match layout.style.line_alignment { + LineAlignment::Left => { + display::text( + cursor, + text, + layout.style.text_font, + layout.style.text_color, + layout.style.background_color, + ); + } + LineAlignment::Center => { + let center = Point::new(cursor.x + (layout.bounds.x1 - cursor.x) / 2, cursor.y); + display::text_center( + center, + text, + layout.style.text_font, + layout.style.text_color, + layout.style.background_color, + ); + } + LineAlignment::Right => { + let right = Point::new(layout.bounds.x1, cursor.y); + display::text_right( + right, + text, + layout.style.text_font, + layout.style.text_color, + layout.style.background_color, + ); + } + } + } + + fn hyphen(&mut self, cursor: Point, layout: &TextLayout) { + display::text( + cursor, + "-", + layout.style.text_font, + layout.style.hyphen_color, + layout.style.background_color, + ); + } + + fn ellipsis(&mut self, cursor: Point, layout: &TextLayout) { + display::text( + cursor, + "...", + layout.style.text_font, + layout.style.ellipsis_color, + layout.style.background_color, + ); + } + + fn icon(&mut self, cursor: Point, layout: &TextLayout, icon: Icon) { + icon.draw_bottom_left( + cursor, + layout.style.text_color, + layout.style.background_color, + ); + } +} + +/// `LayoutSink` for debugging purposes. +#[cfg(feature = "ui_debug")] +pub struct TraceSink<'a>(pub &'a mut dyn crate::trace::Tracer); + +#[cfg(feature = "ui_debug")] +use crate::trace::Trace; + +#[cfg(feature = "ui_debug")] +impl<'a> LayoutSink for TraceSink<'a> { + fn text(&mut self, _cursor: Point, _layout: &TextLayout, text: &str) { + self.0.string(text); + } + + fn icon(&mut self, _cursor: Point, _layout: &TextLayout, icon: Icon) { + icon.trace(self.0); + } + + fn hyphen(&mut self, _cursor: Point, _layout: &TextLayout) { + self.0.string("-"); + } + + fn ellipsis(&mut self, _cursor: Point, _layout: &TextLayout) { + self.0.string("..."); + } + + fn line_break(&mut self, _cursor: Point) { + self.0.string("\n"); + } +} + +pub trait GlyphMetrics { + fn char_width(&self, ch: char) -> i16; + fn line_height(&self) -> i16; +} + +impl GlyphMetrics for Font { + fn char_width(&self, ch: char) -> i16 { + Font::char_width(*self, ch) + } + + fn line_height(&self) -> i16 { + Font::line_height(*self) + } +} + +/// Carries info about the content that was processed +/// on the current line. +#[derive(Debug, PartialEq, Eq)] +pub struct Span { + /// How many characters from the input text this span is laying out. + pub length: usize, + /// How many chars from the input text should we skip before fitting the + /// next span? + pub skip_next_chars: usize, + /// By how much to offset the cursor after this span. If the vertical offset + /// is bigger than zero, it means we are breaking the line. + pub advance: Offset, + /// If we are breaking the line, should we insert a hyphen right after this + /// span to indicate a word-break? + pub insert_hyphen_before_line_break: bool, +} + +impl Span { + fn fit_horizontally( + text: &str, + max_width: i16, + text_font: impl GlyphMetrics, + breaking: LineBreaking, + ) -> Self { + const ASCII_LF: char = '\n'; + const ASCII_CR: char = '\r'; + const ASCII_SPACE: char = ' '; + const ASCII_HYPHEN: char = '-'; + + fn is_whitespace(ch: char) -> bool { + ch == ASCII_SPACE || ch == ASCII_LF || ch == ASCII_CR + } + + let hyphen_width = text_font.char_width(ASCII_HYPHEN); + + // The span we return in case the line has to break. We mutate it in the + // possible break points, and its initial value is returned in case no text + // at all is fitting the constraints: zero length, zero width, full line + // break. + let mut line = Self { + length: 0, + advance: Offset::y(text_font.line_height()), + insert_hyphen_before_line_break: false, + skip_next_chars: 0, + }; + + let mut span_width = 0; + let mut found_any_whitespace = false; + + let mut char_indices_iter = text.char_indices().peekable(); + // Iterating manually because we need a reference to the iterator inside the + // loop. + while let Some((i, ch)) = char_indices_iter.next() { + let char_width = text_font.char_width(ch); + + // Consider if we could be breaking the line at this position. + if is_whitespace(ch) { + // Break before the whitespace, without hyphen. + line.length = i; + line.advance.x = span_width; + line.insert_hyphen_before_line_break = false; + line.skip_next_chars = 1; + if ch == ASCII_CR { + // We'll be breaking the line, but advancing the cursor only by a half of the + // regular line height. + line.advance.y = text_font.line_height() / 2; + } + if ch == ASCII_LF || ch == ASCII_CR { + // End of line, break immediately. + return line; + } + found_any_whitespace = true; + } else if span_width + char_width > max_width { + // Cannot fit on this line. Return the last breakpoint. + return line; + } else { + let have_space_for_break = span_width + char_width + hyphen_width <= max_width; + let can_break_word = matches!(breaking, LineBreaking::BreakWordsAndInsertHyphen) + || !found_any_whitespace; + if have_space_for_break && can_break_word { + // Break after this character, append hyphen. + line.length = match char_indices_iter.peek() { + Some((idx, _)) => *idx, + None => text.len(), + }; + line.advance.x = span_width + char_width; + line.insert_hyphen_before_line_break = true; + line.skip_next_chars = 0; + } + } + + span_width += char_width; + } + + // The whole text is fitting on the current line. + Self { + length: text.len(), + advance: Offset::x(span_width), + insert_hyphen_before_line_break: false, + skip_next_chars: 0, + } + } +} diff --git a/core/embed/rust/src/ui/model_tr/component/frame.rs b/core/embed/rust/src/ui/model_tr/component/frame.rs index 47e7a489cf..0570dff620 100644 --- a/core/embed/rust/src/ui/model_tr/component/frame.rs +++ b/core/embed/rust/src/ui/model_tr/component/frame.rs @@ -1,13 +1,15 @@ -use super::theme; +use super::{common, theme}; use crate::ui::{ component::{Child, Component, Event, EventCtx}, - display::{self, Font}, - geometry::{Insets, Offset, Rect}, + geometry::{Insets, Rect}, }; +/// Component for holding another component and displaying +/// a title and optionally a subtitle describing that child component. pub struct Frame { area: Rect, title: U, + subtitle: Option, content: Child, } @@ -16,9 +18,10 @@ where T: Component, U: AsRef, { - pub fn new(title: U, content: T) -> Self { + pub fn new(title: U, subtitle: Option, content: T) -> Self { Self { title, + subtitle, area: Rect::zero(), content: Child::new(content), } @@ -37,10 +40,11 @@ where type Msg = T::Msg; fn place(&mut self, bounds: Rect) -> Rect { - const TITLE_SPACE: i16 = 4; + // Depending on whether there is subtitle or not + let title_space = if self.subtitle.is_some() { 12 } else { 4 }; - let (title_area, content_area) = bounds.split_top(Font::BOLD.line_height()); - let content_area = content_area.inset(Insets::top(TITLE_SPACE)); + let (title_area, content_area) = bounds.split_top(theme::FONT_HEADER.line_height()); + let content_area = content_area.inset(Insets::top(title_space)); self.area = title_area; self.content.place(content_area); @@ -52,14 +56,7 @@ where } fn paint(&mut self) { - display::text( - self.area.bottom_left() - Offset::y(2), - self.title.as_ref(), - Font::BOLD, - theme::FG, - theme::BG, - ); - display::dotted_line(self.area.bottom_left(), self.area.width(), theme::FG); + common::paint_header(self.area.top_left(), &self.title, self.subtitle.as_ref()); self.content.paint(); } } @@ -72,7 +69,10 @@ where { fn trace(&self, t: &mut dyn crate::trace::Tracer) { t.open("Frame"); - t.field("title", &self.title); + t.title(self.title.as_ref()); + if let Some(ref subtitle) = self.subtitle { + t.title(subtitle.as_ref()); + } t.field("content", &self.content); t.close(); } diff --git a/core/embed/rust/src/ui/model_tr/component/loader.rs b/core/embed/rust/src/ui/model_tr/component/loader.rs index c334aa94d0..1fd629c4c9 100644 --- a/core/embed/rust/src/ui/model_tr/component/loader.rs +++ b/core/embed/rust/src/ui/model_tr/component/loader.rs @@ -3,8 +3,9 @@ use crate::{ ui::{ animation::Animation, component::{Component, Event, EventCtx}, - display::{self, Color, Font}, + display::{self, Color, Font, Icon}, geometry::{Offset, Rect}, + model_tr::theme, }, }; @@ -20,31 +21,82 @@ enum State { Grown, } -pub struct Loader { +pub struct Loader { area: Rect, state: State, growing_duration: Duration, shrinking_duration: Duration, - text: display::TextOverlay<'static>, + text_overlay: Option>, styles: LoaderStyleSheet, } -impl Loader { +impl> Loader { pub const SIZE: Offset = Offset::new(120, 120); - pub fn new(text: &'static str, styles: LoaderStyleSheet) -> Self { - let overlay = display::TextOverlay::new(text, styles.normal.font); - + pub fn new(text_overlay: Option>, styles: LoaderStyleSheet) -> Self { Self { area: Rect::zero(), state: State::Initial, growing_duration: Duration::from_millis(1000), shrinking_duration: Duration::from_millis(500), - text: overlay, + text_overlay, styles, } } + pub fn text(text: T, styles: LoaderStyleSheet) -> Self { + let text_overlay = display::TextOverlay::new(text, styles.normal.font); + + Self::new(Some(text_overlay), styles) + } + + // TODO: support the icon drawing + pub fn icon(_icon: Icon, styles: LoaderStyleSheet) -> Self { + Self::new(None, styles) + } + + pub fn with_growing_duration(mut self, growing_duration: Duration) -> Self { + self.growing_duration = growing_duration; + self + } + + /// Change the duration of the loader. + pub fn set_duration(&mut self, growing_duration: Duration) { + self.growing_duration = growing_duration; + } + + pub fn get_duration(&self) -> Duration { + self.growing_duration + } + + pub fn get_text(&self) -> &T { + self.text_overlay + .as_ref() + .expect("Loader does not have text") + .get_text() + } + + /// Change the text of the loader. + /// When the text_overlay does not exist (as it was created by icon), + /// create it and place it + pub fn set_text(&mut self, text: T) { + if let Some(text_overlay) = &mut self.text_overlay { + text_overlay.set_text(text); + } else { + let text = display::TextOverlay::new(text, self.styles.normal.font); + self.text_overlay = Some(text); + if let Some(text_overlay) = &mut self.text_overlay { + let baseline = self.area.bottom_center() + Offset::new(1, -1); + text_overlay.place(baseline); + } + } + } + + /// Return width of given text according to current style. + pub fn get_text_width(&self, text: &T) -> i16 { + self.styles.normal.font.text_width(text.as_ref()) + } + pub fn start_growing(&mut self, ctx: &mut EventCtx, now: Instant) { let mut anim = Animation::new( display::LOADER_MIN, @@ -111,27 +163,35 @@ impl Loader { matches!(self.progress(now), Some(display::LOADER_MIN)) } - pub fn paint_loader(&mut self, style: &LoaderStyle, done: i16) { - let invert_from = ((self.area.width() + 1) * done) / (display::LOADER_MAX as i16); + pub fn paint_loader(&mut self, style: &LoaderStyle, done: i32) { + // TODO: support painting icons + if let Some(text_overlay) = &mut self.text_overlay { + // NOTE: need to calculate this in `i32`, it would overflow using `i16` + let invert_from = ((self.area.width() as i32 + 1) * done) / (display::LOADER_MAX as i32); - display::bar_with_text_and_fill( - self.area, - Some(self.text), - style.fg_color, - style.bg_color, - -1, - invert_from, - ); + // TODO: the text should be moved one pixel to the top so it is centered in the + // loader + display::bar_with_text_and_fill( + self.area, + Some(text_overlay), + style.fg_color, + style.bg_color, + -1, + invert_from as i16, + ); + } } } -impl Component for Loader { +impl> Component for Loader { type Msg = LoaderMsg; fn place(&mut self, bounds: Rect) -> Rect { self.area = bounds; - let baseline = bounds.bottom_center() + Offset::new(1, -1); - self.text.place(baseline); + if let Some(text_overlay) = &mut self.text_overlay { + let baseline = bounds.bottom_center() + Offset::new(1, -1); + text_overlay.place(baseline); + } self.area } @@ -140,18 +200,19 @@ impl Component for Loader { if let Event::Timer(EventCtx::ANIM_FRAME_TIMER) = event { if self.is_animating() { - // We have something to paint, so request to be painted in the next pass. - ctx.request_paint(); - if self.is_completely_grown(now) { self.state = State::Grown; + ctx.request_paint(); return Some(LoaderMsg::GrownCompletely); } else if self.is_completely_shrunk(now) { self.state = State::Initial; + ctx.request_paint(); return Some(LoaderMsg::ShrunkCompletely); } else { // There is further progress in the animation, request an animation frame event. ctx.request_anim_frame(); + // We have something to paint, so request to be painted in the next pass. + ctx.request_paint(); } } } @@ -169,11 +230,11 @@ impl Component for Loader { if let State::Initial = self.state { self.paint_loader(self.styles.normal, 0); } else if let State::Grown = self.state { - self.paint_loader(self.styles.normal, display::LOADER_MAX as i16); + self.paint_loader(self.styles.normal, display::LOADER_MAX as i32); } else { let progress = self.progress(now); if let Some(done) = progress { - self.paint_loader(self.styles.normal, done as i16); + self.paint_loader(self.styles.normal, done as i32); } else { self.paint_loader(self.styles.normal, 0); } @@ -191,8 +252,20 @@ pub struct LoaderStyle { pub bg_color: Color, } +impl LoaderStyleSheet { + pub fn default() -> Self { + Self { + normal: &LoaderStyle { + font: theme::FONT_BUTTON, + fg_color: theme::FG, + bg_color: theme::BG, + }, + } + } +} + #[cfg(feature = "ui_debug")] -impl crate::trace::Trace for Loader { +impl> crate::trace::Trace for Loader { fn trace(&self, d: &mut dyn crate::trace::Tracer) { d.open("Loader"); d.close(); diff --git a/core/embed/rust/src/ui/model_tr/component/mod.rs b/core/embed/rust/src/ui/model_tr/component/mod.rs index 8a29709ad9..9e0a2b6929 100644 --- a/core/embed/rust/src/ui/model_tr/component/mod.rs +++ b/core/embed/rust/src/ui/model_tr/component/mod.rs @@ -1,19 +1,47 @@ +mod bip39; mod button; +mod button_controller; +mod changing_text; +mod choice; +mod choice_item; +mod common; mod confirm; mod dialog; +mod flow; +mod flow_pages; +mod flow_pages_poc_helpers; mod frame; mod loader; mod page; +mod passphrase; +mod pin; mod result_anim; mod result_popup; +mod scrollbar; +mod simple_choice; use super::theme; -pub use button::{Button, ButtonContent, ButtonMsg, ButtonPos, ButtonStyle, ButtonStyleSheet}; +pub use bip39::{Bip39Entry, Bip39EntryMsg}; +pub use button::{ + Button, ButtonAction, ButtonActions, ButtonContent, ButtonDetails, ButtonLayout, ButtonMsg, + ButtonPos, ButtonStyle, ButtonStyleSheet, +}; pub use confirm::{HoldToConfirm, HoldToConfirmMsg}; + +pub use button_controller::{ButtonController, ButtonControllerMsg}; +pub use changing_text::ChangingTextLine; +pub use choice::{ChoiceFactory, ChoicePage, ChoicePageMsg}; +pub use choice_item::{ChoiceItem, ChoiceItemAPI, MultilineTextChoiceItem, TextChoiceItem}; pub use dialog::{Dialog, DialogMsg}; +pub use flow::{Flow, FlowMsg}; +pub use flow_pages::{FlowPages, Page}; pub use frame::Frame; pub use loader::{Loader, LoaderMsg, LoaderStyle, LoaderStyleSheet}; pub use page::ButtonPage; +pub use passphrase::{PassphraseEntry, PassphraseEntryMsg}; +pub use pin::{PinEntry, PinEntryMsg}; pub use result_anim::{ResultAnim, ResultAnimMsg}; pub use result_popup::{ResultPopup, ResultPopupMsg}; +pub use scrollbar::ScrollBar; +pub use simple_choice::{SimpleChoice, SimpleChoiceMsg}; diff --git a/core/embed/rust/src/ui/model_tr/component/page.rs b/core/embed/rust/src/ui/model_tr/component/page.rs index dadb410937..87a5c16c6e 100644 --- a/core/embed/rust/src/ui/model_tr/component/page.rs +++ b/core/embed/rust/src/ui/model_tr/component/page.rs @@ -1,93 +1,209 @@ -use crate::ui::{ - component::{Component, ComponentExt, Event, EventCtx, Never, Pad, PageMsg, Paginate}, - display::{self, Color, Font}, - geometry::{Insets, Offset, Point, Rect}, +use crate::{ + micropython::buffer::StrBuffer, + ui::{ + component::{Child, Component, ComponentExt, Event, EventCtx, Pad, PageMsg, Paginate}, + display::Color, + geometry::{Insets, Rect}, + }, }; -use super::{theme, Button, ButtonMsg, ButtonPos}; +use super::{ + theme, ButtonController, ButtonControllerMsg, ButtonDetails, ButtonLayout, ButtonPos, ScrollBar, +}; -pub struct ButtonPage { - content: T, - scrollbar: ScrollBar, +pub struct ButtonPage { + content: Child, + scrollbar: Child, pad: Pad, - prev: Button<&'static str>, - next: Button<&'static str>, - cancel: Button<&'static str>, - confirm: Button<&'static str>, + cancel_btn_details: Option>, + confirm_btn_details: Option>, + back_btn_details: Option>, + next_btn_details: Option>, + buttons: Child>, } -impl ButtonPage +impl ButtonPage<&'static str, T> where T: Paginate, T: Component, { - pub fn new(content: T, background: Color) -> Self { + /// Constructor for `&'static str` button-text type. + pub fn new_str(content: T, background: Color) -> Self { Self { - content, - scrollbar: ScrollBar::vertical(), + content: Child::new(content), + scrollbar: Child::new(ScrollBar::vertical_to_be_filled_later()), pad: Pad::with_background(background), - prev: Button::with_text(ButtonPos::Left, "BACK", theme::button_cancel()), - next: Button::with_text(ButtonPos::Right, "NEXT", theme::button_default()), - cancel: Button::with_text(ButtonPos::Left, "CANCEL", theme::button_cancel()), - confirm: Button::with_text(ButtonPos::Right, "CONFIRM", theme::button_default()), + cancel_btn_details: Some(ButtonDetails::cancel_icon()), + confirm_btn_details: Some(ButtonDetails::text("CONFIRM")), + back_btn_details: Some(ButtonDetails::up_arrow_icon_wide()), + next_btn_details: Some(ButtonDetails::down_arrow_icon_wide()), + // Setting empty layout for now, we do not yet know the page count. + // Initial button layout will be set in `place()` after we can call + // `content.page_count()`. + buttons: Child::new(ButtonController::new(ButtonLayout::empty())), } } - - fn change_page(&mut self, ctx: &mut EventCtx, page: usize) { - // Change the page in the content, clear the background under it and make sure - // it gets completely repainted. - self.content.change_page(page); - self.content.request_complete_repaint(ctx); - self.pad.clear(); - } } -impl Component for ButtonPage +impl ButtonPage where + T: Paginate, + T: Component, +{ + /// Constructor for `StrBuffer` button-text type. + pub fn new_str_buf(content: T, background: Color) -> Self { + Self { + content: Child::new(content), + scrollbar: Child::new(ScrollBar::vertical_to_be_filled_later()), + pad: Pad::with_background(background), + cancel_btn_details: Some(ButtonDetails::cancel_icon()), + confirm_btn_details: Some(ButtonDetails::text("CONFIRM".into())), + back_btn_details: Some(ButtonDetails::up_arrow_icon_wide()), + next_btn_details: Some(ButtonDetails::down_arrow_icon_wide()), + // Setting empty layout for now, we do not yet know the page count. + // Initial button layout will be set in `place()` after we can call + // `content.page_count()`. + buttons: Child::new(ButtonController::new(ButtonLayout::empty())), + } + } +} + +impl ButtonPage +where + T: Paginate, + T: Component, + S: AsRef, + S: Clone, +{ + pub fn with_cancel_btn(mut self, btn_details: Option>) -> Self { + self.cancel_btn_details = btn_details; + self + } + + pub fn with_confirm_btn(mut self, btn_details: Option>) -> Self { + self.confirm_btn_details = btn_details; + self + } + + pub fn with_back_btn(mut self, btn_details: Option>) -> Self { + self.back_btn_details = btn_details; + self + } + + pub fn with_next_btn(mut self, btn_details: Option>) -> Self { + self.next_btn_details = btn_details; + self + } + + /// Basically just determining whether the right button for + /// initial page should be "NEXT" or "CONFIRM". + /// Can only be called when we know the final page_count. + fn set_buttons_for_initial_page(&mut self, page_count: usize) { + let btn_layout = self.get_button_layout(false, page_count > 1); + self.buttons = Child::new(ButtonController::new(btn_layout)); + } + + /// Called when user pressed "BACK" or "NEXT". + /// Change the page in the content, clear the background under it and make + /// sure it gets completely repainted. Also updating the buttons. + fn change_page(&mut self, ctx: &mut EventCtx) { + let active_page = self.scrollbar.inner().active_page; + self.content.inner_mut().change_page(active_page); + self.content.request_complete_repaint(ctx); + self.scrollbar.request_complete_repaint(ctx); + self.update_buttons(ctx); + self.pad.clear(); + } + + /// Reflecting the current page in the buttons. + fn update_buttons(&mut self, ctx: &mut EventCtx) { + let btn_layout = self.get_button_layout( + self.scrollbar.inner().has_previous_page(), + self.scrollbar.inner().has_next_page(), + ); + self.buttons.mutate(ctx, |_ctx, buttons| { + buttons.set(btn_layout); + }); + } + + fn get_button_layout(&self, has_prev: bool, has_next: bool) -> ButtonLayout { + let btn_left = self.get_left_button_details(has_prev); + let btn_right = self.get_right_button_details(has_next); + ButtonLayout::new(btn_left, None, btn_right) + } + + fn get_left_button_details(&self, has_prev_page: bool) -> Option> { + if has_prev_page { + self.back_btn_details.clone() + } else { + self.cancel_btn_details.clone() + } + } + + fn get_right_button_details(&self, has_next_page: bool) -> Option> { + if has_next_page { + self.next_btn_details.clone() + } else { + self.confirm_btn_details.clone() + } + } +} + +impl Component for ButtonPage +where + S: Clone, + S: AsRef, T: Component, T: Paginate, { type Msg = PageMsg; fn place(&mut self, bounds: Rect) -> Rect { - let button_height = Font::BOLD.line_height() + 2; - let (content_area, button_area) = bounds.split_bottom(button_height); - let (content_area, scrollbar_area) = content_area.split_right(ScrollBar::WIDTH); + let (content_and_scrollbar_area, button_area) = bounds.split_bottom(theme::BUTTON_HEIGHT); + let (content_area, scrollbar_area) = + content_and_scrollbar_area.split_right(ScrollBar::WIDTH); let content_area = content_area.inset(Insets::top(1)); - self.pad.place(bounds); + // Do not pad the button area nor the scrollbar, leave it to them + self.pad.place(content_area); self.content.place(content_area); - let page_count = self.content.page_count(); - self.scrollbar.set_count_and_active_page(page_count, 0); + // Need to be called here, only after content is placed + // and we can calculate the page count + let page_count = self.content.inner_mut().page_count(); + self.scrollbar.inner_mut().set_page_count(page_count); self.scrollbar.place(scrollbar_area); - self.prev.place(button_area); - self.next.place(button_area); - self.cancel.place(button_area); - self.confirm.place(button_area); + self.set_buttons_for_initial_page(page_count); + self.buttons.place(button_area); bounds } fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { - ctx.set_page_count(self.scrollbar.page_count); - if self.scrollbar.has_previous_page() { - if let Some(ButtonMsg::Clicked) = self.prev.event(ctx, event) { - // Scroll up. - self.scrollbar.go_to_previous_page(); - self.change_page(ctx, self.scrollbar.active_page); - return None; - } - } else if let Some(ButtonMsg::Clicked) = self.cancel.event(ctx, event) { - return Some(PageMsg::Controls(false)); - } + ctx.set_page_count(self.scrollbar.inner().page_count); + let button_event = self.buttons.event(ctx, event); - if self.scrollbar.has_next_page() { - if let Some(ButtonMsg::Clicked) = self.next.event(ctx, event) { - // Scroll down. - self.scrollbar.go_to_next_page(); - self.change_page(ctx, self.scrollbar.active_page); - return None; + if let Some(ButtonControllerMsg::Triggered(pos)) = button_event { + match pos { + ButtonPos::Left => { + if self.scrollbar.inner().has_previous_page() { + // Clicked BACK. Scroll up. + self.scrollbar.inner_mut().go_to_previous_page(); + self.change_page(ctx); + } else { + // Clicked CANCEL. Send result. + return Some(PageMsg::Controls(false)); + } + } + ButtonPos::Right => { + if self.scrollbar.inner().has_next_page() { + // Clicked NEXT. Scroll down. + self.scrollbar.inner_mut().go_to_next_page(); + self.change_page(ctx); + } else { + // Clicked CONFIRM. Send result. + return Some(PageMsg::Controls(true)); + } + } + _ => {} } - } else if let Some(ButtonMsg::Clicked) = self.confirm.event(ctx, event) { - return Some(PageMsg::Controls(true)); } if let Some(msg) = self.content.event(ctx, event) { @@ -100,128 +216,60 @@ where self.pad.paint(); self.content.paint(); self.scrollbar.paint(); - if self.scrollbar.has_previous_page() { - self.prev.paint(); - } else { - self.cancel.paint(); - } - if self.scrollbar.has_next_page() { - self.next.paint(); - } else { - self.confirm.paint(); - } + self.buttons.paint(); } } #[cfg(feature = "ui_debug")] -impl crate::trace::Trace for ButtonPage +use super::ButtonAction; +#[cfg(feature = "ui_debug")] +use heapless::String; + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for ButtonPage where T: crate::trace::Trace, + S: AsRef, { + fn get_btn_action(&self, pos: ButtonPos) -> String<25> { + match pos { + ButtonPos::Left => { + if self.scrollbar.inner().has_previous_page() { + ButtonAction::PrevPage.string() + } else if self.cancel_btn_details.is_some() { + ButtonAction::Cancel.string() + } else { + ButtonAction::empty() + } + } + ButtonPos::Right => { + if self.scrollbar.inner().has_next_page() { + ButtonAction::NextPage.string() + } else if self.confirm_btn_details.is_some() { + ButtonAction::Confirm.string() + } else { + ButtonAction::empty() + } + } + ButtonPos::Middle => ButtonAction::empty(), + } + } + fn trace(&self, t: &mut dyn crate::trace::Tracer) { t.open("ButtonPage"); - t.field("active_page", &self.scrollbar.active_page); - t.field("page_count", &self.scrollbar.page_count); + t.kw_pair( + "active_page", + inttostr!(self.scrollbar.inner().active_page as u8), + ); + t.kw_pair( + "page_count", + inttostr!(self.scrollbar.inner().page_count as u8), + ); + self.report_btn_actions(t); + // TODO: it seems the button text is not updated when paginating (but actions + // above are) + t.field("buttons", &self.buttons); t.field("content", &self.content); t.close(); } } - -pub struct ScrollBar { - area: Rect, - page_count: usize, - active_page: usize, -} - -impl ScrollBar { - pub const WIDTH: i16 = 8; - pub const DOT_SIZE: Offset = Offset::new(4, 4); - pub const DOT_INTERVAL: i16 = 6; - - pub fn vertical() -> Self { - Self { - area: Rect::zero(), - page_count: 0, - active_page: 0, - } - } - - pub fn set_count_and_active_page(&mut self, page_count: usize, active_page: usize) { - self.page_count = page_count; - self.active_page = active_page; - } - - pub fn has_next_page(&self) -> bool { - self.active_page < self.page_count - 1 - } - - pub fn has_previous_page(&self) -> bool { - self.active_page > 0 - } - - pub fn go_to_next_page(&mut self) { - self.active_page = self.active_page.saturating_add(1).min(self.page_count - 1); - } - - pub fn go_to_previous_page(&mut self) { - self.active_page = self.active_page.saturating_sub(1); - } - - fn paint_dot(&self, active: bool, top_left: Point) { - let sides = [ - Rect::from_top_left_and_size(top_left + Offset::x(1), Offset::new(2, 1)), - Rect::from_top_left_and_size(top_left + Offset::y(1), Offset::new(1, 2)), - Rect::from_top_left_and_size( - top_left + Offset::new(1, Self::DOT_SIZE.y - 1), - Offset::new(2, 1), - ), - Rect::from_top_left_and_size( - top_left + Offset::new(Self::DOT_SIZE.x - 1, 1), - Offset::new(1, 2), - ), - ]; - for side in sides { - display::rect_fill(side, theme::FG) - } - if active { - display::rect_fill( - Rect::from_top_left_and_size(top_left, Self::DOT_SIZE).inset(Insets::uniform(1)), - theme::FG, - ) - } - } -} - -impl Component for ScrollBar { - type Msg = Never; - - fn place(&mut self, bounds: Rect) -> Rect { - self.area = bounds; - self.area - } - - fn event(&mut self, _ctx: &mut EventCtx, _event: Event) -> Option { - None - } - - fn paint(&mut self) { - let count = self.page_count as i16; - let interval = { - let available_height = self.area.height(); - let naive_height = count * Self::DOT_INTERVAL; - if naive_height > available_height { - available_height / count - } else { - Self::DOT_INTERVAL - } - }; - let mut dot = Point::new( - self.area.center().x - Self::DOT_SIZE.x / 2, - self.area.center().y - (count / 2) * interval, - ); - for i in 0..self.page_count { - self.paint_dot(i == self.active_page, dot); - dot.y += interval - } - } -} diff --git a/core/embed/rust/src/ui/model_tr/component/passphrase.rs b/core/embed/rust/src/ui/model_tr/component/passphrase.rs new file mode 100644 index 0000000000..4c2539292e --- /dev/null +++ b/core/embed/rust/src/ui/model_tr/component/passphrase.rs @@ -0,0 +1,365 @@ +use crate::{ + time::Duration, + ui::{ + component::{text::common::TextBox, Child, Component, ComponentExt, Event, EventCtx}, + geometry::Rect, + }, +}; + +use super::{ + choice_item::BigCharacterChoiceItem, ButtonDetails, ButtonLayout, ChangingTextLine, + ChoiceFactory, ChoiceItem, ChoicePage, ChoicePageMsg, MultilineTextChoiceItem, TextChoiceItem, +}; +use heapless::String; + +pub enum PassphraseEntryMsg { + Confirmed, + Cancelled, +} + +/// Defines the choices currently available on the screen +#[derive(PartialEq, Clone)] +enum ChoiceCategory { + Menu, + LowercaseLetter, + UppercaseLetter, + Digit, + SpecialSymbol, +} + +const MAX_PASSPHRASE_LENGTH: usize = 50; +const HOLD_DURATION: Duration = Duration::from_secs(1); + +const DIGITS: [char; 10] = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; +const LOWERCASE_LETTERS: [char; 26] = [ + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', + 't', 'u', 'v', 'w', 'x', 'y', 'z', +]; +const UPPERCASE_LETTERS: [char; 26] = [ + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', + 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', +]; +const SPECIAL_SYMBOLS: [char; 30] = [ + '_', '<', '>', '.', ':', '@', '/', '|', '\\', '!', '(', ')', '+', '%', '&', '-', '[', ']', '?', + '{', '}', ',', '\'', '`', ';', '"', '~', '$', '^', '=', +]; +const MENU_LENGTH: usize = 6; +const DEL_INDEX: usize = MENU_LENGTH - 1; +const SHOW_INDEX: usize = MENU_LENGTH - 2; +const MENU: [&str; MENU_LENGTH] = ["abc", "ABC", "123", "*#_", "SHOW PASS", "DEL LAST CHAR"]; + +/// Get a character at a specified index for a specified category. +fn get_char(current_category: &ChoiceCategory, index: u8) -> char { + let index = index as usize; + match current_category { + ChoiceCategory::LowercaseLetter => LOWERCASE_LETTERS[index], + ChoiceCategory::UppercaseLetter => UPPERCASE_LETTERS[index], + ChoiceCategory::Digit => DIGITS[index], + ChoiceCategory::SpecialSymbol => SPECIAL_SYMBOLS[index], + ChoiceCategory::Menu => unreachable!(), + } +} + +/// Return category from menu based on page index. +fn get_category_from_menu(page_index: u8) -> ChoiceCategory { + match page_index { + 0 => ChoiceCategory::LowercaseLetter, + 1 => ChoiceCategory::UppercaseLetter, + 2 => ChoiceCategory::Digit, + 3 => ChoiceCategory::SpecialSymbol, + _ => unreachable!(), + } +} + +/// How many choices are available for a specified category. +/// (does not count the extra MENU choice for characters) +fn get_category_length(current_category: &ChoiceCategory) -> u8 { + match current_category { + ChoiceCategory::LowercaseLetter => LOWERCASE_LETTERS.len() as u8, + ChoiceCategory::UppercaseLetter => UPPERCASE_LETTERS.len() as u8, + ChoiceCategory::Digit => DIGITS.len() as u8, + ChoiceCategory::SpecialSymbol => SPECIAL_SYMBOLS.len() as u8, + ChoiceCategory::Menu => MENU.len() as u8, + } +} + +/// Whether this index is the MENU index - the last one in the list. +fn is_menu_choice(current_category: &ChoiceCategory, page_index: u8) -> bool { + if let ChoiceCategory::Menu = current_category { + unreachable!() + } + let category_length = get_category_length(current_category); + page_index == category_length +} + +struct ChoiceFactoryPassphrase { + current_category: ChoiceCategory, +} + +impl ChoiceFactoryPassphrase { + fn new(current_category: ChoiceCategory) -> Self { + Self { current_category } + } + + /// MENU choices with accept and cancel hold-to-confirm side buttons. + fn get_menu_item(&self, choice_index: u8) -> ChoiceItem { + let choice = MENU[choice_index as usize]; + let item = + MultilineTextChoiceItem::new(String::from(choice), ButtonLayout::default_three_icons()) + .use_delimiter(' '); + let mut menu_item = ChoiceItem::MultilineText(item); + + // Including accept button on the left and cancel on the very right + // TODO: could have some icons instead of the shortcut text + if choice_index == 0 { + menu_item.set_left_btn(Some( + ButtonDetails::text("ACC").with_duration(HOLD_DURATION), + )); + } else if choice_index == MENU.len() as u8 - 1 { + menu_item.set_right_btn(Some( + ButtonDetails::text("CAN").with_duration(HOLD_DURATION), + )); + } + + menu_item + } + + /// Character choices with a MENU choice at the end (visible from start) to + /// return back + fn get_character_item(&self, choice_index: u8) -> ChoiceItem { + if is_menu_choice(&self.current_category, choice_index) { + let menu_choice = + TextChoiceItem::new("MENU", ButtonLayout::three_icons_middle_text("RETURN")); + ChoiceItem::Text(menu_choice) + } else { + let ch = get_char(&self.current_category, choice_index); + let char_choice = BigCharacterChoiceItem::new(ch, ButtonLayout::default_three_icons()); + ChoiceItem::BigCharacter(char_choice) + } + } +} + +impl ChoiceFactory for ChoiceFactoryPassphrase { + fn get(&self, choice_index: u8) -> ChoiceItem { + match self.current_category { + ChoiceCategory::Menu => self.get_menu_item(choice_index), + _ => self.get_character_item(choice_index), + } + } + + fn count(&self) -> u8 { + let length = get_category_length(&self.current_category); + // All non-MENU categories have an extra item for returning back to MENU + match self.current_category { + ChoiceCategory::Menu => length, + _ => length + 1, + } + } +} + +/// Component for entering a passphrase. +pub struct PassphraseEntry { + choice_page: ChoicePage, + passphrase_dots: Child>>, + show_plain_passphrase: bool, + textbox: TextBox, + current_category: ChoiceCategory, + menu_position: u8, // position in the menu so we can return back +} + +impl PassphraseEntry { + pub fn new() -> Self { + let menu_choices = ChoiceFactoryPassphrase::new(ChoiceCategory::Menu); + Self { + choice_page: ChoicePage::new(menu_choices), + passphrase_dots: Child::new(ChangingTextLine::center_mono(String::new())), + show_plain_passphrase: false, + textbox: TextBox::empty(), + current_category: ChoiceCategory::Menu, + menu_position: 0, + } + } + + fn update_passphrase_dots(&mut self, ctx: &mut EventCtx) { + // TODO: when the passphrase is longer than fits the screen, we might show + // ellipsis + if self.show_plain_passphrase { + let passphrase = String::from(self.passphrase()); + self.passphrase_dots.inner_mut().update_text(passphrase); + } else { + let mut dots: String = String::new(); + for _ in 0..self.textbox.len() { + unwrap!(dots.push_str("*")); + } + self.passphrase_dots.inner_mut().update_text(dots); + } + self.passphrase_dots.request_complete_repaint(ctx); + } + + fn append_char(&mut self, ctx: &mut EventCtx, ch: char) { + self.textbox.append(ctx, ch); + } + + fn delete_last_digit(&mut self, ctx: &mut EventCtx) { + self.textbox.delete_last(ctx); + } + + /// Displaying the MENU + fn show_menu_page(&mut self, ctx: &mut EventCtx) { + let menu_choices = ChoiceFactoryPassphrase::new(ChoiceCategory::Menu); + self.choice_page.reset(ctx, menu_choices, true, false); + // Going back to the last MENU position before showing the MENU + self.choice_page.set_page_counter(ctx, self.menu_position); + } + + /// Displaying the character category + fn show_category_page(&mut self, ctx: &mut EventCtx) { + let category_choices = ChoiceFactoryPassphrase::new(self.current_category.clone()); + self.choice_page.reset(ctx, category_choices, true, true); + } + + pub fn passphrase(&self) -> &str { + self.textbox.content() + } + + fn is_full(&self) -> bool { + self.textbox.is_full() + } +} + +impl Component for PassphraseEntry { + type Msg = PassphraseEntryMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + let passphrase_area_height = self.passphrase_dots.inner().needed_height(); + let (passphrase_area, choice_area) = bounds.split_top(passphrase_area_height); + self.passphrase_dots.place(passphrase_area); + self.choice_page.place(choice_area); + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + // Any event when showing real passphrase should hide it + if self.show_plain_passphrase { + self.show_plain_passphrase = false; + self.update_passphrase_dots(ctx); + } + + let msg = self.choice_page.event(ctx, event); + + if self.current_category == ChoiceCategory::Menu { + match msg { + // Going to new category, applying some action or returning the result + Some(ChoicePageMsg::Choice(page_counter)) => match page_counter as usize { + DEL_INDEX => { + self.delete_last_digit(ctx); + self.update_passphrase_dots(ctx); + ctx.request_paint(); + } + SHOW_INDEX => { + self.show_plain_passphrase = true; + self.update_passphrase_dots(ctx); + ctx.request_paint(); + } + _ => { + self.menu_position = page_counter; + self.current_category = get_category_from_menu(page_counter); + self.show_category_page(ctx); + ctx.request_paint(); + } + }, + Some(ChoicePageMsg::LeftMost) => return Some(PassphraseEntryMsg::Confirmed), + Some(ChoicePageMsg::RightMost) => return Some(PassphraseEntryMsg::Cancelled), + _ => {} + } + } else { + // Coming back to MENU or adding new character + if let Some(ChoicePageMsg::Choice(page_counter)) = msg { + if is_menu_choice(&self.current_category, page_counter) { + self.current_category = ChoiceCategory::Menu; + self.show_menu_page(ctx); + ctx.request_paint(); + } else if !self.is_full() { + let new_char = get_char(&self.current_category, page_counter); + self.append_char(ctx, new_char); + self.update_passphrase_dots(ctx); + ctx.request_paint(); + } + } + } + + None + } + + fn paint(&mut self) { + self.passphrase_dots.paint(); + self.choice_page.paint(); + } +} + +#[cfg(feature = "ui_debug")] +use super::{ButtonAction, ButtonPos}; +#[cfg(feature = "ui_debug")] +use crate::ui::util; + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for PassphraseEntry { + fn get_btn_action(&self, pos: ButtonPos) -> String<25> { + match pos { + ButtonPos::Left => match self.current_category { + ChoiceCategory::Menu => match self.choice_page.has_previous_choice() { + true => ButtonAction::PrevPage.string(), + false => ButtonAction::Confirm.string(), + }, + _ => ButtonAction::PrevPage.string(), + }, + ButtonPos::Right => match self.current_category { + ChoiceCategory::Menu => match self.choice_page.has_next_choice() { + true => ButtonAction::NextPage.string(), + false => ButtonAction::Cancel.string(), + }, + _ => ButtonAction::NextPage.string(), + }, + ButtonPos::Middle => { + let current_index = self.choice_page.page_index() as usize; + match &self.current_category { + ChoiceCategory::Menu => match current_index { + DEL_INDEX => ButtonAction::Action("Del last char").string(), + SHOW_INDEX => ButtonAction::Action("Show pass").string(), + _ => ButtonAction::select_item(MENU[current_index]), + }, + _ => { + // There is "MENU" option at the end + match self.choice_page.has_next_choice() { + false => ButtonAction::Action("Back to MENU").string(), + true => { + let ch = match &self.current_category { + ChoiceCategory::LowercaseLetter => { + LOWERCASE_LETTERS[current_index] + } + ChoiceCategory::UppercaseLetter => { + UPPERCASE_LETTERS[current_index] + } + ChoiceCategory::Digit => DIGITS[current_index], + ChoiceCategory::SpecialSymbol => SPECIAL_SYMBOLS[current_index], + ChoiceCategory::Menu => unreachable!(), + }; + ButtonAction::select_item(util::char_to_string::<1>(ch)) + } + } + } + } + } + } + } + + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.open("PassphraseEntry"); + // NOTE: `show_plain_passphrase` was not able to be transferred, + // as it is true only for a very small amount of time + t.kw_pair("textbox", self.textbox.content()); + self.report_btn_actions(t); + t.field("choice_page", &self.choice_page); + t.close(); + } +} diff --git a/core/embed/rust/src/ui/model_tr/component/pin.rs b/core/embed/rust/src/ui/model_tr/component/pin.rs new file mode 100644 index 0000000000..c9d63b34c2 --- /dev/null +++ b/core/embed/rust/src/ui/model_tr/component/pin.rs @@ -0,0 +1,246 @@ +use crate::{ + micropython::buffer::StrBuffer, + trezorhal::random, + ui::{ + component::{text::common::TextBox, Child, Component, ComponentExt, Event, EventCtx}, + geometry::Rect, + }, +}; + +use super::{ + choice_item::BigCharacterChoiceItem, ButtonDetails, ButtonLayout, ChangingTextLine, + ChoiceFactory, ChoiceItem, ChoicePage, ChoicePageMsg, MultilineTextChoiceItem, +}; +use heapless::String; + +pub enum PinEntryMsg { + Confirmed, + Cancelled, +} + +const MAX_PIN_LENGTH: usize = 50; +const MAX_VISIBLE_DOTS: usize = 18; +const MAX_VISIBLE_DIGITS: usize = 18; + +const CHOICE_LENGTH: usize = 14; +const EXIT_INDEX: usize = 0; +const DELETE_INDEX: usize = 1; +const SHOW_INDEX: usize = 2; +const PROMPT_INDEX: usize = 3; +const CHOICES: [&str; CHOICE_LENGTH] = [ + "EXIT", + "DELETE", + "SHOW PIN", + "PLACEHOLDER FOR THE PROMPT", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", +]; + +struct ChoiceFactoryPIN { + prompt: StrBuffer, +} + +impl ChoiceFactoryPIN { + fn new(prompt: StrBuffer) -> Self { + Self { prompt } + } +} + +impl ChoiceFactory for ChoiceFactoryPIN { + fn get(&self, choice_index: u8) -> ChoiceItem { + let choice = CHOICES[choice_index as usize]; + + // Depending on whether it is a digit (one character) or a text. + // Digits are BIG, the rest is multiline. + let mut choice_item = if choice.len() == 1 { + let item = + BigCharacterChoiceItem::from_str(choice, ButtonLayout::default_three_icons()); + ChoiceItem::BigCharacter(item) + } else { + let item = MultilineTextChoiceItem::new( + String::from(choice), + ButtonLayout::default_three_icons(), + ) + .use_delimiter(' '); + ChoiceItem::MultilineText(item) + }; + + // Action buttons have different middle button text + if [EXIT_INDEX, DELETE_INDEX, SHOW_INDEX, PROMPT_INDEX].contains(&(choice_index as usize)) { + let confirm_btn = ButtonDetails::armed_text("CONFIRM"); + choice_item.set_middle_btn(Some(confirm_btn)); + } + + // Changing the prompt text for the wanted one + if choice_index == PROMPT_INDEX as u8 { + choice_item.set_text(String::from(self.prompt.as_ref())); + } + + choice_item + } + + fn count(&self) -> u8 { + CHOICE_LENGTH as u8 + } +} + +/// Component for entering a PIN. +pub struct PinEntry { + choice_page: ChoicePage, + pin_dots: Child>>, + show_real_pin: bool, + textbox: TextBox, +} + +impl PinEntry { + pub fn new(prompt: StrBuffer) -> Self { + let choices = ChoiceFactoryPIN::new(prompt); + + Self { + choice_page: ChoicePage::new(choices) + .with_initial_page_counter(3) + .with_carousel(), + pin_dots: Child::new(ChangingTextLine::center_mono(String::new())), + show_real_pin: false, + textbox: TextBox::empty(), + } + } + + fn append_new_digit(&mut self, ctx: &mut EventCtx, page_counter: u8) { + let digit = CHOICES[page_counter as usize]; + self.textbox.append_slice(ctx, digit); + } + + fn delete_last_digit(&mut self, ctx: &mut EventCtx) { + self.textbox.delete_last(ctx); + } + + fn update_pin_dots(&mut self, ctx: &mut EventCtx) { + // TODO: this is the same action as for the passphrase entry, + // might do a common component that will handle this part, + // (something like `SecretTextLine`) + // also with things like shifting the dots when too many etc. + // TODO: when the PIN is longer than fits the screen, we might show ellipsis + if self.show_real_pin { + let pin = String::from(self.pin()); + self.pin_dots.inner_mut().update_text(pin); + } else { + let mut dots: String = String::new(); + for _ in 0..self.textbox.len() { + unwrap!(dots.push_str("*")); + } + self.pin_dots.inner_mut().update_text(dots); + } + self.pin_dots.request_complete_repaint(ctx); + } + + pub fn pin(&self) -> &str { + self.textbox.content() + } + + fn is_full(&self) -> bool { + self.textbox.is_full() + } + + fn is_empty(&self) -> bool { + self.textbox.is_empty() + } +} + +impl Component for PinEntry { + type Msg = PinEntryMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + let pin_area_height = self.pin_dots.inner().needed_height(); + let (pin_area, choice_area) = bounds.split_top(pin_area_height); + self.pin_dots.place(pin_area); + self.choice_page.place(choice_area); + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + // Any event when showing real PIN should hide it + if self.show_real_pin { + self.show_real_pin = false; + self.update_pin_dots(ctx); + } + + let msg = self.choice_page.event(ctx, event); + if let Some(ChoicePageMsg::Choice(page_counter)) = msg { + // Performing action under specific index or appending new digit + match page_counter as usize { + EXIT_INDEX => return Some(PinEntryMsg::Cancelled), + DELETE_INDEX => { + self.delete_last_digit(ctx); + self.update_pin_dots(ctx); + ctx.request_paint(); + } + SHOW_INDEX => { + self.show_real_pin = true; + self.update_pin_dots(ctx); + ctx.request_paint(); + } + PROMPT_INDEX => return Some(PinEntryMsg::Confirmed), + _ => { + if !self.is_full() { + self.append_new_digit(ctx, page_counter); + self.update_pin_dots(ctx); + // Choosing any random digit to be shown next + let new_page_counter = + random::uniform_between(4, (CHOICE_LENGTH - 1) as u32); + self.choice_page + .set_page_counter(ctx, new_page_counter as u8); + ctx.request_paint(); + } + } + } + } + None + } + + fn paint(&mut self) { + self.pin_dots.paint(); + self.choice_page.paint(); + } +} + +#[cfg(feature = "ui_debug")] +use super::{ButtonAction, ButtonPos}; + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for PinEntry { + fn get_btn_action(&self, pos: ButtonPos) -> String<25> { + match pos { + ButtonPos::Left => ButtonAction::PrevPage.string(), + ButtonPos::Right => ButtonAction::NextPage.string(), + ButtonPos::Middle => { + let current_index = self.choice_page.page_index() as usize; + match current_index { + EXIT_INDEX => ButtonAction::Cancel.string(), + DELETE_INDEX => ButtonAction::Action("Delete last digit").string(), + SHOW_INDEX => ButtonAction::Action("Show PIN").string(), + PROMPT_INDEX => ButtonAction::Confirm.string(), + _ => ButtonAction::select_item(CHOICES[current_index]), + } + } + } + } + + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.open("PinEntry"); + // NOTE: `show_real_pin` was not able to be transferred, + // as it is true only for a very small amount of time + t.kw_pair("textbox", self.textbox.content()); + self.report_btn_actions(t); + t.field("choice_page", &self.choice_page); + t.close(); + } +} diff --git a/core/embed/rust/src/ui/model_tr/component/result_popup.rs b/core/embed/rust/src/ui/model_tr/component/result_popup.rs index 06cf7e9f2f..23ba7fae4c 100644 --- a/core/embed/rust/src/ui/model_tr/component/result_popup.rs +++ b/core/embed/rust/src/ui/model_tr/component/result_popup.rs @@ -15,6 +15,8 @@ use crate::{ }, }; +use super::ButtonStyleSheet; + pub enum ResultPopupMsg { Confirmed, } @@ -48,10 +50,11 @@ impl ResultPopup { .with_placement(LinearPlacement::vertical().align_at_center()); let button = button_text.map(|t| { + // TODO: use `ButtonController` for this Child::new(Button::with_text( ButtonPos::Right, t, - theme::button_default(), + ButtonStyleSheet::default(true, false, None, None), )) }); diff --git a/core/embed/rust/src/ui/model_tr/component/scrollbar.rs b/core/embed/rust/src/ui/model_tr/component/scrollbar.rs new file mode 100644 index 0000000000..27ca055b76 --- /dev/null +++ b/core/embed/rust/src/ui/model_tr/component/scrollbar.rs @@ -0,0 +1,165 @@ +use crate::ui::{ + component::{Component, Event, EventCtx, Never, Pad}, + display, + geometry::{Offset, Point, Rect}, + model_tr::theme, +}; + +/// In which direction should the scrollbar be positioned +pub enum ScrollbarOrientation { + Vertical, + Horizontal, +} + +pub struct ScrollBar { + area: Rect, + pad: Pad, + pub page_count: usize, + pub active_page: usize, + pub orientation: ScrollbarOrientation, +} + +impl ScrollBar { + pub const WIDTH: i16 = 8; + pub const DOT_SIZE: Offset = Offset::new(4, 4); + pub const DOT_INTERVAL: i16 = 6; + + pub fn new(page_count: usize, orientation: ScrollbarOrientation) -> Self { + Self { + area: Rect::zero(), + pad: Pad::with_background(theme::BG), + page_count, + active_page: 0, + orientation, + } + } + + /// Page count will be given later as it is not available yet. + pub fn vertical_to_be_filled_later() -> Self { + Self::vertical(0) + } + + pub fn vertical(page_count: usize) -> Self { + Self::new(page_count, ScrollbarOrientation::Vertical) + } + + pub fn horizontal(page_count: usize) -> Self { + Self::new(page_count, ScrollbarOrientation::Horizontal) + } + + pub fn set_page_count(&mut self, page_count: usize) { + self.page_count = page_count; + } + + pub fn set_active_page(&mut self, active_page: usize) { + self.active_page = active_page; + } + + pub fn has_next_page(&self) -> bool { + self.active_page < self.page_count - 1 + } + + pub fn has_previous_page(&self) -> bool { + self.active_page > 0 + } + + pub fn go_to_next_page(&mut self) { + self.active_page = self.active_page.saturating_add(1).min(self.page_count - 1); + } + + pub fn go_to_previous_page(&mut self) { + self.active_page = self.active_page.saturating_sub(1); + } + + /// Create a (seemingly circular) dot given its top left point. + /// Make it full when it is active, otherwise paint just the perimeter and + /// leave center empty. + fn paint_dot(&self, active: bool, top_left: Point) { + let full_square = Rect::from_top_left_and_size(top_left, ScrollBar::DOT_SIZE); + + // FG - painting the full square + display::rect_fill(full_square, theme::FG); + + // BG - erase four corners + for p in full_square.corner_points().iter() { + display::paint_point(p, theme::BG); + } + + // BG - erasing the middle when not active + if !active { + display::rect_fill(full_square.shrink(1), theme::BG) + } + } + + fn paint_vertical(&mut self) { + let count = self.page_count as i16; + let interval = { + let available_space = self.area.height(); + let naive_space = count * Self::DOT_INTERVAL; + if naive_space > available_space { + available_space / count + } else { + Self::DOT_INTERVAL + } + }; + let mut top_left = Point::new( + self.area.center().x - Self::DOT_SIZE.x / 2, + self.area.center().y - (count / 2) * interval, + ); + for i in 0..self.page_count { + self.paint_dot(i == self.active_page, top_left); + top_left.y += interval; + } + } + + fn paint_horizontal(&mut self) { + let count = self.page_count as i16; + let interval = { + let available_space = self.area.width(); + let naive_space = count * Self::DOT_INTERVAL; + if naive_space > available_space { + available_space / count + } else { + Self::DOT_INTERVAL + } + }; + let mut top_left = Point::new( + self.area.center().x - (count / 2) * interval, + self.area.center().y - Self::DOT_SIZE.y / 2, + ); + for i in 0..self.page_count { + self.paint_dot(i == self.active_page, top_left); + top_left.x += interval; + } + } +} + +impl Component for ScrollBar { + type Msg = Never; + + fn place(&mut self, bounds: Rect) -> Rect { + self.area = bounds; + self.pad.place(bounds); + bounds + } + + fn event(&mut self, _ctx: &mut EventCtx, _event: Event) -> Option { + None + } + + /// Displaying one dot for each page. + fn paint(&mut self) { + // Not showing the scrollbar dot when there is only one page + if self.page_count <= 1 { + return; + } + + self.pad.clear(); + self.pad.paint(); + if matches!(self.orientation, ScrollbarOrientation::Vertical) { + self.paint_vertical() + } else { + self.paint_horizontal() + } + } +} diff --git a/core/embed/rust/src/ui/model_tr/component/simple_choice.rs b/core/embed/rust/src/ui/model_tr/component/simple_choice.rs new file mode 100644 index 0000000000..c0a4821f63 --- /dev/null +++ b/core/embed/rust/src/ui/model_tr/component/simple_choice.rs @@ -0,0 +1,134 @@ +use crate::ui::{ + component::{Component, Event, EventCtx}, + geometry::Rect, +}; + +use super::{ButtonLayout, ChoiceFactory, ChoiceItem, ChoicePage, ChoicePageMsg, TextChoiceItem}; +use heapless::{String, Vec}; + +#[cfg(feature = "ui_debug")] +use super::{ButtonAction, ButtonPos}; + +pub enum SimpleChoiceMsg { + Result(String<50>), +} + +struct ChoiceFactorySimple { + choices: Vec, +} + +impl ChoiceFactorySimple +where + T: AsRef, +{ + fn new(choices: Vec) -> Self { + Self { choices } + } +} + +impl ChoiceFactory for ChoiceFactorySimple +where + T: AsRef, +{ + fn get(&self, choice_index: u8) -> ChoiceItem { + let text = &self.choices[choice_index as usize]; + let text_item = TextChoiceItem::new(text, ButtonLayout::default_three_icons()); + let mut choice_item = ChoiceItem::Text(text_item); + + // Disabling prev/next buttons for the first/last choice. + if choice_index == 0 { + choice_item.set_left_btn(None); + } else if choice_index as usize == N - 1 { + choice_item.set_right_btn(None); + } + + choice_item + } + + fn count(&self) -> u8 { + N as u8 + } +} + +/// Simple wrapper around `ChoicePage` that allows for +/// inputting a list of values and receiving the chosen one. +pub struct SimpleChoice +where + T: AsRef, + T: Clone, +{ + choices: Vec, + choice_page: ChoicePage>, +} + +impl SimpleChoice +where + T: AsRef, + T: Clone, +{ + pub fn new(str_choices: Vec) -> Self { + let choices = ChoiceFactorySimple::new(str_choices.clone()); + Self { + choices: str_choices, + choice_page: ChoicePage::new(choices), + } + } +} + +impl Component for SimpleChoice +where + T: AsRef, + T: Clone, +{ + type Msg = SimpleChoiceMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + self.choice_page.place(bounds) + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + let msg = self.choice_page.event(ctx, event); + match msg { + Some(ChoicePageMsg::Choice(page_counter)) => { + let result = String::from(self.choices[page_counter as usize].as_ref()); + Some(SimpleChoiceMsg::Result(result)) + } + _ => None, + } + } + + fn paint(&mut self) { + self.choice_page.paint(); + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for SimpleChoice +where + T: AsRef, + T: Clone, +{ + fn get_btn_action(&self, pos: ButtonPos) -> String<25> { + match pos { + ButtonPos::Left => match self.choice_page.has_previous_choice() { + true => ButtonAction::PrevPage.string(), + false => ButtonAction::empty(), + }, + ButtonPos::Right => match self.choice_page.has_next_choice() { + true => ButtonAction::NextPage.string(), + false => ButtonAction::empty(), + }, + ButtonPos::Middle => { + let current_index = self.choice_page.page_index() as usize; + ButtonAction::select_item(self.choices[current_index].as_ref()) + } + } + } + + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.open("SimpleChoice"); + self.report_btn_actions(t); + t.field("choice_page", &self.choice_page); + t.close(); + } +} diff --git a/core/embed/rust/src/ui/model_tr/layout.rs b/core/embed/rust/src/ui/model_tr/layout.rs index e70a9a06bd..40ba08087f 100644 --- a/core/embed/rust/src/ui/model_tr/layout.rs +++ b/core/embed/rust/src/ui/model_tr/layout.rs @@ -1,30 +1,47 @@ use core::convert::TryInto; +use heapless::{String, Vec}; + use crate::{ error::Error, - micropython::{buffer::StrBuffer, map::Map, module::Module, obj::Obj, qstr::Qstr, util}, + micropython::{ + buffer::StrBuffer, + iter::{Iter, IterBuf}, + map::Map, + module::Module, + obj::Obj, + qstr::Qstr, + util, + }, + time::Duration, ui::{ component::{ - base::Component, + base::{Component, ComponentExt}, paginated::{PageMsg, Paginate}, text::paragraphs::{Paragraph, Paragraphs}, FormattedText, }, layout::{ obj::{ComponentMsgObj, LayoutObj}, - result::{CANCELLED, CONFIRMED, INFO}, + result::{CANCELLED, CONFIRMED, INFO}, util::iter_into_vec, }, }, }; use super::{ - component::{Button, ButtonPage, ButtonPos, Frame}, + component::{ + Bip39Entry, Bip39EntryMsg, ButtonActions, ButtonDetails, ButtonLayout, ButtonPage, Flow, + FlowMsg, FlowPages, Frame, Page, PassphraseEntry, PassphraseEntryMsg, PinEntry, + PinEntryMsg, SimpleChoice, SimpleChoiceMsg, + }, theme, }; -impl ComponentMsgObj for ButtonPage +impl ComponentMsgObj for ButtonPage where T: Component + Paginate, + S: AsRef, + S: Clone, { fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { match msg { @@ -35,6 +52,56 @@ where } } +impl ComponentMsgObj for Flow +where + F: Fn(u8) -> Page, +{ + fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { + match msg { + FlowMsg::Confirmed => Ok(CONFIRMED.as_obj()), + FlowMsg::Cancelled => Ok(CANCELLED.as_obj()), + } + } +} + +impl ComponentMsgObj for PinEntry { + fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { + match msg { + PinEntryMsg::Confirmed => self.pin().try_into(), + PinEntryMsg::Cancelled => Ok(CANCELLED.as_obj()), + } + } +} + +impl ComponentMsgObj for SimpleChoice +where + T: AsRef, + T: Clone, +{ + fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { + match msg { + SimpleChoiceMsg::Result(choice) => choice.as_str().try_into(), + } + } +} + +impl ComponentMsgObj for Bip39Entry { + fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { + match msg { + Bip39EntryMsg::ResultWord(word) => word.as_str().try_into(), + } + } +} + +impl ComponentMsgObj for PassphraseEntry { + fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { + match msg { + PassphraseEntryMsg::Confirmed => self.passphrase().try_into(), + PassphraseEntryMsg::Cancelled => Ok(CANCELLED.as_obj()), + } + } +} + impl ComponentMsgObj for Frame where T: ComponentMsgObj, @@ -55,28 +122,53 @@ extern "C" fn new_confirm_action(n_args: usize, args: *const Obj, kwargs: *mut M let verb_cancel: Option = kwargs.get(Qstr::MP_QSTR_verb_cancel)?.try_into_option()?; let reverse: bool = kwargs.get(Qstr::MP_QSTR_reverse)?.try_into()?; + let hold: bool = kwargs.get(Qstr::MP_QSTR_hold)?.try_into()?; + + // TODO: could be replaced by Flow with one element after it supports pagination let format = match (&action, &description, reverse) { - (Some(_), Some(_), false) => "{bold}{action}\n\r{normal}{description}", - (Some(_), Some(_), true) => "{normal}{description}\n\r{bold}{action}", - (Some(_), None, _) => "{bold}{action}", - (None, Some(_), _) => "{normal}{description}", + (Some(_), Some(_), false) => "{Font::bold}{action}\n\r{Font::normal}{description}", + (Some(_), Some(_), true) => "{Font::normal}{description}\n\r{Font::bold}{action}", + (Some(_), None, _) => "{Font::bold}{action}", + (None, Some(_), _) => "{Font::normal}{description}", _ => "", }; - let _left = verb_cancel - .map(|label| Button::with_text(ButtonPos::Left, label, theme::button_cancel())); - let _right = - verb.map(|label| Button::with_text(ButtonPos::Right, label, theme::button_default())); + let verb_cancel = verb_cancel.unwrap_or_default(); + let verb = verb.unwrap_or_default(); + + let cancel_btn = if verb_cancel.len() > 0 { + Some(ButtonDetails::cancel_icon()) + } else { + None + }; + + let mut confirm_btn = if verb.len() > 0 { + Some(ButtonDetails::text(verb)) + } else { + None + }; + + // Optional HoldToConfirm + if hold { + // TODO: clients might want to set the duration + confirm_btn = confirm_btn.map(|btn| btn.with_duration(Duration::from_secs(2))); + } + + // TODO: make sure the text will not be colliding with the buttons + // - make there some space on the bottom of the text let obj = LayoutObj::new(Frame::new( title, - ButtonPage::new( + None, + ButtonPage::new_str_buf( FormattedText::new(theme::TEXT_NORMAL, theme::FORMATTED, format) .with("action", action.unwrap_or_default()) .with("description", description.unwrap_or_default()), theme::BG, - ), + ) + .with_cancel_btn(cancel_btn) + .with_confirm_btn(confirm_btn), ))?; Ok(obj.into()) }; @@ -90,9 +182,12 @@ extern "C" fn new_confirm_text(n_args: usize, args: *const Obj, kwargs: *mut Map let description: Option = kwargs.get(Qstr::MP_QSTR_description)?.try_into_option()?; + // TODO: could be replaced by Flow with one element after it supports pagination + let obj = LayoutObj::new(Frame::new( title, - ButtonPage::new( + None, + ButtonPage::new_str( Paragraphs::new([ Paragraph::new(&theme::TEXT_NORMAL, description.unwrap_or_default()), Paragraph::new(&theme::TEXT_BOLD, data), @@ -105,6 +200,401 @@ extern "C" fn new_confirm_text(n_args: usize, args: *const Obj, kwargs: *mut Map unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } } +extern "C" fn confirm_output(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = |_args: &[Obj], kwargs: &Map| { + let address: StrBuffer = kwargs.get(Qstr::MP_QSTR_address)?.try_into()?; + // Getting this from micropython so it is also a `StrBuffer`, not having + // to handle the string operation in Rust, which would make it a `String` + // (which would them cause issues with general `T: AsRef` parameter) + let truncated_address: StrBuffer = + kwargs.get(Qstr::MP_QSTR_truncated_address)?.try_into()?; + let amount: StrBuffer = kwargs.get(Qstr::MP_QSTR_amount)?.try_into()?; + let title: StrBuffer = "Send".into(); + + let get_page = move |page_index| { + // Showing two screens - the recipient address and summary confirmation + match page_index { + 0 => { + // `icon + label + address` + let btn_layout = ButtonLayout::new( + Some(ButtonDetails::cancel_icon()), + None, + Some(ButtonDetails::text("CONTINUE")), + ); + let btn_actions = ButtonActions::cancel_next(); + Page::<20>::new(btn_layout, btn_actions).icon_label_text( + theme::ICON_USER, + "Recipient".into(), + address.clone(), + ) + } + 1 => { + // 2 pairs `icon + label + text` + let btn_layout = ButtonLayout::new( + Some(ButtonDetails::cancel_icon()), + None, + Some( + ButtonDetails::text("HOLD TO CONFIRM") + .with_duration(Duration::from_secs(2)), + ), + ); + let btn_actions = ButtonActions::cancel_confirm(); + Page::<20>::new(btn_layout, btn_actions) + .icon_label_text( + theme::ICON_USER, + "Recipient".into(), + truncated_address.clone(), + ) + .newline() + .icon_label_text(theme::ICON_AMOUNT, "Amount".into(), amount.clone()) + } + _ => unreachable!(), + } + }; + let pages = FlowPages::new(get_page, 2); + + let obj = LayoutObj::new(Flow::new(pages).with_common_title(title).into_child())?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn confirm_total(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = |_args: &[Obj], kwargs: &Map| { + let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; + let total_amount: StrBuffer = kwargs.get(Qstr::MP_QSTR_total_amount)?.try_into()?; + let fee_amount: StrBuffer = kwargs.get(Qstr::MP_QSTR_fee_amount)?.try_into()?; + let fee_rate_amount: Option = kwargs + .get(Qstr::MP_QSTR_fee_rate_amount)? + .try_into_option()?; + let total_label: StrBuffer = kwargs.get(Qstr::MP_QSTR_total_label)?.try_into()?; + let fee_label: StrBuffer = kwargs.get(Qstr::MP_QSTR_fee_label)?.try_into()?; + + let get_page = move |page_index| { + // One page with 2 or 3 pairs `icon + label + text` + assert!(page_index == 0); + + let btn_layout = ButtonLayout::new( + Some(ButtonDetails::cancel_icon()), + None, + Some(ButtonDetails::text("HOLD TO SEND").with_duration(Duration::from_secs(2))), + ); + let btn_actions = ButtonActions::cancel_confirm(); + + let mut flow_page = Page::<25>::new(btn_layout, btn_actions) + .icon_label_text(theme::ICON_PARAM, total_label.clone(), total_amount.clone()) + .newline() + .icon_label_text(theme::ICON_PARAM, fee_label.clone(), fee_amount.clone()); + + if let Some(fee_rate_amount) = &fee_rate_amount { + flow_page = flow_page.newline().icon_label_text( + theme::ICON_PARAM, + "Fee rate".into(), + fee_rate_amount.clone(), + ) + } + flow_page + }; + let pages = FlowPages::new(get_page, 1); + + let obj = LayoutObj::new(Flow::new(pages).with_common_title(title).into_child())?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn tutorial(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = |_args: &[Obj], _kwargs: &Map| { + let get_page = |page_index| { + // Lazy-loaded list of screens to show, with custom content, + // buttons and actions triggered by these buttons. + // Cancelling the first screen will point to the last one, + // which asks for confirmation whether user wants to + // really cancel the tutorial. + let screen = match page_index { + // title, text, btn_layout, btn_actions + 0 => ( + "Hello!", + "Welcome to Trezor.\n\n\nPress right to continue.", + ButtonLayout::cancel_and_arrow(), + ButtonActions::last_next(), + ), + 1 => ( + "Basics", + "Use Trezor by clicking left & right.\nPress right to continue.", + ButtonLayout::left_right_arrows(), + ButtonActions::prev_next(), + ), + 2 => ( + "Confirm", + "Press both left & right at the same time to confirm.", + ButtonLayout::new( + Some(ButtonDetails::left_arrow_icon()), + Some(ButtonDetails::armed_text("CONFIRM")), + None, + ), + ButtonActions::prev_next_with_middle(), + ), + 3 => ( + "Hold to confirm", + "Press & hold right to approve important operations.", + ButtonLayout::new( + Some(ButtonDetails::left_arrow_icon()), + None, + Some( + ButtonDetails::text("HOLD TO CONFIRM") + .with_duration(Duration::from_millis(2000)), + ), + ), + ButtonActions::prev_next(), + ), + // TODO: merge these two scrolls into one, with using a scrollbar + 4 => ( + "Screen scroll", + "Press right to scroll down to read all content when text doesn't...", + ButtonLayout::new( + Some(ButtonDetails::left_arrow_icon()), + None, + Some(ButtonDetails::down_arrow_icon_wide()), + ), + ButtonActions::prev_next(), + ), + 5 => ( + "Screen scroll", + "fit on one screen. Press left to scroll up.", + ButtonLayout::new( + Some(ButtonDetails::up_arrow_icon_wide()), + None, + Some(ButtonDetails::text("CONFIRM")), + ), + ButtonActions::prev_next(), + ), + 6 => ( + "Congrats!", + "You're ready to use Trezor.", + ButtonLayout::new( + Some(ButtonDetails::text("AGAIN")), + None, + Some(ButtonDetails::text("FINISH")), + ), + ButtonActions::beginning_confirm(), + ), + 7 => ( + "Skip tutorial?", + "Sure you want to skip the tutorial?", + ButtonLayout::new( + Some(ButtonDetails::left_arrow_icon()), + None, + Some(ButtonDetails::text("CONFIRM")), + ), + ButtonActions::beginning_cancel(), + ), + _ => unreachable!(), + }; + + Page::<10>::new(screen.2.clone(), screen.3.clone()) + .text_bold(screen.0.into()) + .newline() + .newline_half() + .text_normal(screen.1.into()) + }; + + let pages = FlowPages::new(get_page, 8); + + let obj = LayoutObj::new(Flow::new(pages).into_child())?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn pin_confirm_action(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = |_args: &[Obj], kwargs: &Map| { + let action: StrBuffer = kwargs.get(Qstr::MP_QSTR_action)?.try_into()?; + + let get_page = move |page_index| { + let screen = match page_index { + // title, text, btn_layout, btn_actions + // NOTE: doing the newlines manually to look exactly same + // as in the design. + 0 => ( + "PIN settings".into(), + "PIN should\ncontain at\nleast four\ndigits", + ButtonLayout::cancel_and_text("GOT IT"), + ButtonActions::cancel_next(), + ), + 1 => ( + action.clone(), + "You'll use\nthis PIN to\naccess this\ndevice.", + ButtonLayout::cancel_and_htc_text( + "HOLD TO CONFIRM", + Duration::from_millis(1000), + ), + ButtonActions::cancel_confirm(), + ), + _ => unreachable!(), + }; + + Page::<10>::new(screen.2.clone(), screen.3.clone()) + .text_bold(screen.0) + .newline() + .newline_half() + .text_normal(screen.1.into()) + }; + let pages = FlowPages::new(get_page, 2); + + let obj = LayoutObj::new(Flow::new(pages).into_child())?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn request_pin(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = |_args: &[Obj], kwargs: &Map| { + let prompt: StrBuffer = kwargs.get(Qstr::MP_QSTR_prompt)?.try_into()?; + let _subprompt: StrBuffer = kwargs.get(Qstr::MP_QSTR_subprompt)?.try_into()?; + let _allow_cancel: Option = + kwargs.get(Qstr::MP_QSTR_allow_cancel)?.try_into_option()?; + + let obj = LayoutObj::new(PinEntry::new(prompt).into_child())?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn show_share_words(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = |_args: &[Obj], kwargs: &Map| { + let share_words_obj: Obj = kwargs.get(Qstr::MP_QSTR_share_words)?; + let title = "Recovery seed"; + + // Parsing the list of share words. + // Assume there is always up to 24 words in the newly generated seed + // (for now, later we might support SLIP39 with up to 33 words) + let mut iter_buf = IterBuf::new(); + let iter_words = Iter::try_from_obj_with_buf(share_words_obj, &mut iter_buf)?; + let mut share_words: Vec = Vec::new(); + for word in iter_words { + share_words.push(word.try_into()?).unwrap(); + } + + let share_words_len = share_words.len() as u8; + + let beginning_text = build_string!( + 40, + "Write down these ", + inttostr!(share_words_len), + " words:\n\n" + ); + + let mut middle_words: String<360> = String::new(); + // Vec does not support `enumerate()` + let mut index: u8 = 0; + for word in share_words { + index += 1; + let line = build_string!(15, inttostr!(index), ". ", word.as_ref(), "\n"); + + middle_words.push_str(&line).unwrap(); + } + + let end_text = build_string!( + 40, + "I wrote down all ", + inttostr!(share_words_len), + " words in order." + ); + + // TODO: instead of this could create a new paragraph for the beginning, + // each word and the end + let text_to_show = build_string!( + 440, + beginning_text.as_str(), + middle_words.as_str(), + end_text.as_str() + ); + + // Adding hold-to-confirm button at the end + // Also no possibility of cancelling + let cancel_btn = None; + let confirm_btn = + Some(ButtonDetails::text("CONFIRM").with_duration(Duration::from_secs(2))); + + let obj = LayoutObj::new(Frame::new( + title, + None, + ButtonPage::new_str( + Paragraphs::new( + [ + Paragraph::new(&theme::TEXT_BOLD, text_to_show) + ] + ), + theme::BG, + ) + .with_cancel_btn(cancel_btn) + .with_confirm_btn(confirm_btn), + ))?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn select_word(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = |_args: &[Obj], kwargs: &Map| { + let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; + let description: StrBuffer = kwargs.get(Qstr::MP_QSTR_description)?.try_into()?; + let words_iterable: Obj = kwargs.get(Qstr::MP_QSTR_words)?; + let words: Vec = iter_into_vec(words_iterable)?; + + let obj = LayoutObj::new(Frame::new( + title, + Some(description), + SimpleChoice::new(words).into_child(), + ))?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn request_word_count(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = |_args: &[Obj], kwargs: &Map| { + let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; + let text: StrBuffer = kwargs.get(Qstr::MP_QSTR_text)?.try_into()?; + + let choices: Vec<&str, 5> = ["12", "18", "20", "24", "33"].into_iter().collect(); + + let obj = LayoutObj::new(Frame::new( + title, + Some(text), + SimpleChoice::new(choices).into_child(), + ))?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn request_word_bip39(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = |_args: &[Obj], kwargs: &Map| { + let prompt: StrBuffer = kwargs.get(Qstr::MP_QSTR_prompt)?.try_into()?; + + let obj = LayoutObj::new(Frame::new(prompt, None, Bip39Entry::new().into_child()))?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn request_passphrase(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = |_args: &[Obj], kwargs: &Map| { + let prompt: StrBuffer = kwargs.get(Qstr::MP_QSTR_prompt)?.try_into()?; + let _max_len: u8 = kwargs.get(Qstr::MP_QSTR_max_len)?.try_into()?; + + let obj = LayoutObj::new(Frame::new( + prompt, + None, + PassphraseEntry::new().into_child(), + ))?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + #[no_mangle] pub static mp_module_trezorui2: Module = obj_module! { Qstr::MP_QSTR___name__ => Qstr::MP_QSTR_trezorui2.to_obj(), @@ -125,12 +615,50 @@ pub static mp_module_trezorui2: Module = obj_module! { /// description: str | None = None, /// verb: str | None = None, /// verb_cancel: str | None = None, - /// hold: bool | None = None, + /// hold: bool = False, /// reverse: bool = False, /// ) -> object: /// """Confirm action.""" Qstr::MP_QSTR_confirm_action => obj_fn_kw!(0, new_confirm_action).as_obj(), + /// def confirm_output_r( + /// *, + /// address: str, + /// truncated_address: str, + /// amount: str, + /// ) -> object: + /// """Confirm output. Specific for model R.""" + Qstr::MP_QSTR_confirm_output_r => obj_fn_kw!(0, confirm_output).as_obj(), + + /// def confirm_total_r( + /// *, + /// title: str, + /// total_amount: str, + /// fee_amount: str, + /// fee_rate_amount: str | None = None, + /// total_label: str, + /// fee_label: str, + /// ) -> object: + /// """Confirm summary of a transaction. Specific for model R.""" + Qstr::MP_QSTR_confirm_total_r => obj_fn_kw!(0, confirm_total).as_obj(), + + /// def tutorial() -> object: + /// """Show user how to interact with the device.""" + Qstr::MP_QSTR_tutorial => obj_fn_kw!(0, tutorial).as_obj(), + + /// def pin_confirm_action(*, action: str) -> object: + /// """Confirm PIN action and informing user about it.""" + Qstr::MP_QSTR_pin_confirm_action => obj_fn_kw!(0, pin_confirm_action).as_obj(), + + /// def request_pin( + /// *, + /// prompt: str, + /// subprompt: str | None = None, + /// allow_cancel: bool | None = None, + /// ) -> str | object: + /// """Request pin on device.""" + Qstr::MP_QSTR_request_pin => obj_fn_kw!(0, request_pin).as_obj(), + /// def confirm_text( /// *, /// title: str, @@ -139,103 +667,143 @@ pub static mp_module_trezorui2: Module = obj_module! { /// ) -> object: /// """Confirm text.""" Qstr::MP_QSTR_confirm_text => obj_fn_kw!(0, new_confirm_text).as_obj(), + + /// def show_share_words( + /// *, + /// share_words: Iterable[str], + /// ) -> None: + /// """Shows a backup seed.""" + Qstr::MP_QSTR_show_share_words => obj_fn_kw!(0, show_share_words).as_obj(), + + /// def select_word( + /// *, + /// title: str, + /// description: str, + /// words: Iterable[str], + /// ) -> int: + /// """Select mnemonic word from three possibilities - seed check after backup. The + /// iterable must be of exact size. Returns index in range `0..3`.""" + Qstr::MP_QSTR_select_word => obj_fn_kw!(0, select_word).as_obj(), + + /// def request_word_count( + /// *, + /// title: str, + /// text: str, + /// ) -> str: # TODO: make it return int + /// """Get word count for recovery.""" + Qstr::MP_QSTR_request_word_count => obj_fn_kw!(0, request_word_count).as_obj(), + + /// def request_word_bip39( + /// *, + /// prompt: str, + /// ) -> str: + /// """Get recovery word for BIP39.""" + Qstr::MP_QSTR_request_word_bip39 => obj_fn_kw!(0, request_word_bip39).as_obj(), + + /// def request_passphrase( + /// *, + /// prompt: str, + /// max_len: int, + /// ) -> str: + /// """Get passphrase.""" + Qstr::MP_QSTR_request_passphrase => obj_fn_kw!(0, request_passphrase).as_obj(), }; #[cfg(test)] mod tests { - use crate::{ - trace::Trace, - ui::{ - component::Component, - model_tr::{ - component::{Dialog, DialogMsg}, - constant, - }, - }, - }; + // use crate::{ + // trace::Trace, + // ui::{ + // component::Component, + // model_tr::{ + // component::{Dialog, DialogMsg}, + // constant, + // }, + // }, + // }; - use super::*; + // use super::*; - fn trace(val: &impl Trace) -> String { - let mut t = Vec::new(); - val.trace(&mut t); - String::from_utf8(t).unwrap() - } + // fn trace(val: &impl Trace) -> String { + // let mut t = Vec::new(); + // val.trace(&mut t); + // String::from_utf8(t).unwrap() + // } - impl ComponentMsgObj for Dialog - where - T: ComponentMsgObj, - U: AsRef, - { - fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { - match msg { - DialogMsg::Content(c) => self.inner().msg_try_into_obj(c), - DialogMsg::LeftClicked => Ok(CANCELLED.as_obj()), - DialogMsg::RightClicked => Ok(CONFIRMED.as_obj()), - } - } - } + // impl ComponentMsgObj for Dialog + // where + // T: ComponentMsgObj, + // U: AsRef, + // { + // fn msg_try_into_obj(&self, msg: Self::Msg) -> Result + // { match msg { + // DialogMsg::Content(c) => + // self.inner().msg_try_into_obj(c), + // DialogMsg::LeftClicked => Ok(CANCELLED.as_obj()), + // DialogMsg::RightClicked => Ok(CONFIRMED.as_obj()), } + // } + // } - #[test] - fn trace_example_layout() { - let mut layout = Dialog::new( - FormattedText::new( - theme::TEXT_NORMAL, - theme::FORMATTED, - "Testing text layout, with some text, and some more text. And {param}", - ) - .with("param", "parameters!"), - Some(Button::with_text( - ButtonPos::Left, - "Left", - theme::button_cancel(), - )), - Some(Button::with_text( - ButtonPos::Right, - "Right", - theme::button_default(), - )), - ); - layout.place(constant::screen()); - assert_eq!( - trace(&layout), - r#" left: