diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 294e20beb..9fba7af51 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,6 +27,9 @@ jobs: steps: - uses: actions/checkout@v3 + with: + # Needs to also pull the seedsigner-translations repo + submodules: recursive - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: @@ -45,16 +48,20 @@ jobs: --cov=seedsigner \ --cov-append \ --cov-branch \ - --cov-report term \ - --cov-report html \ - --cov-report html:./artifacts/cov_html \ - --cov-report xml \ --durations 5 \ -vv - name: Generate screenshots run: | - python -m pytest tests/screenshot_generator/generator.py + python -m pytest tests/screenshot_generator/generator.py \ + --color=yes \ + --cov=seedsigner \ + --cov-append \ + --cov-branch \ + --cov-report html:./artifacts/cov_html \ + -vv cp -r ./seedsigner-screenshots ./artifacts/ + - name: Coverage report + run: coverage report - name: Archive CI Artifacts uses: actions/upload-artifact@v3 with: diff --git a/.gitignore b/.gitignore index f2fbb160e..9c70709f3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ src/seedsigner.egg-info/ .vscode src/seedsigner/models/settings_definition.json .idea -*.mo -.coverage -seedsigner-screenshots \ No newline at end of file +.coverage* + +*.po +*.mo \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..6bf87ec18 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "src/seedsigner/resources/seedsigner-translations"] + path = src/seedsigner/resources/seedsigner-translations + url = https://github.com/SeedSigner/seedsigner-translations.git + branch = 0.8.5-rc1 +[submodule "seedsigner-screenshots"] + path = seedsigner-screenshots + url = https://github.com/SeedSigner/seedsigner-screenshots.git + branch = dev + update = none diff --git a/README.md b/README.md index 8d4163e28..f28e6a8d4 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ * [Project Summary](#project-summary) * [Shopping List](#shopping-list) * [Software Installation](#software-installation) - * [Verifying the Software](#verifying-the-software) + * [Verifying your download](#verifying-your-download) * [Enclosure Designs](#enclosure-designs) * [SeedQR Printable Templates](#seedqr-printable-templates) * [Build from Source](#build-from-source) @@ -21,7 +21,7 @@ [![CI](https://github.com/SeedSigner/seedsigner/actions/workflows/tests.yml/badge.svg)](https://github.com/SeedSigner/seedsigner/actions/workflows/tests.yml) [![Build](https://github.com/SeedSigner/seedsigner/actions/workflows/build.yml/badge.svg)](https://github.com/SeedSigner/seedsigner/actions/workflows/build.yml) -The goal of SeedSigner is to lower the cost and complexity of Bitcoin multi-signature wallet use. To accomplish this goal, SeedSigner offers anyone the opportunity to build a verifiably air-gapped, stateless Bitcoin signing device using inexpensive, publicly available hardware components (usually < $50). SeedSigner helps users save with Bitcoin by assisting with trustless private key generation and multisignature (aka "multisig") wallet setup, and helps users transact with Bitcoin via a secure, air-gapped QR-exchange signing model. +The goal of SeedSigner is to lower the cost and complexity of Bitcoin multisignature wallet use. To accomplish this goal, SeedSigner offers anyone the opportunity to build a verifiably air-gapped, stateless Bitcoin signing device using inexpensive, publicly available hardware components (usually < $50). SeedSigner helps users save with Bitcoin by assisting with trustless private key generation and multisignature (aka "multisig") wallet setup, and helps users transact with Bitcoin via a secure, air-gapped QR-exchange signing model. Additional information about the project can be found at [SeedSigner.com](https://seedsigner.com). @@ -30,40 +30,55 @@ You can follow [@SeedSigner](https://twitter.com/SeedSigner) on Twitter for the If you have specific questions about the project, our [Telegram Group](https://t.me/joinchat/GHNuc_nhNQjLPWsS) is a great place to ask them. ### Feature Highlights: -* Calculate the final word (aka checksum) of a 12- or 24-word BIP39 seed phrase -* Create a 24-word BIP39 seed phrase with 99 dice rolls or a 12-word with 50 rolls [(Verifying dice seed generation)](docs/dice_verification.md) -* Create a 12- or 24-word BIP39 seed phrase via image entropy from the onboard camera -* Temporarily stores seeds in memory while the device is powered; all memory is wiped when power is removed -* SD card removable after boot to ensure no secret data can be written to it -* Guided interface to manually transcribe a seed to the SeedQR format for instant seed loading [(demo video here)](https://youtu.be/c1-PqTNx1vc) -* BIP39 passphrase (aka "word 25") support -* Native Segwit Multisig XPUB generation -* PSBT-compliant; scan and parse transaction data from animated QR codes -* Sign transactions & transfer XPUB data using animated QR codes [(demo video here)](https://youtu.be/LPqvdQ2gSzs) -* Live preview during image entropy seed generation and QR scanning UX -* Optimized seed word entry interface -* Support for Bitcoin Mainnet & Testnet -* Support for custom user-defined derivation paths -* Support for loading Electrum Segwit seed phrases with feature limitations: [Electrum support info](docs/electrum.md) -* On-demand receive address verification -* Address Explorer for single sig and multisig wallets -* User-configurable QR code display density -* Responsive, event-driven user interface - -### Considerations: -* Built for compatibility with Specter Desktop, Sparrow, and BlueWallet Vaults -* Device takes up to 60 seconds to boot before menu appears (be patient!) -* Always test your setup before transferring larger amounts of bitcoin (try Testnet first!) -* Taproot not quite yet supported -* Slightly rotating the screen clockwise or counter-clockwise should resolve lighting/glare issues -* If you think SeedSigner adds value to the Bitcoin ecosystem, please help us spread the word! (tweets, pics, videos, etc.) - -### Planned Upcoming Improvements / Functionality: -* Multi-language support -* Significantly faster boot time -* Reproducible builds -* Port to MicroPython to broaden the range of compatible hardware to include low-cost microcontrollers -* Other optimizations based on user feedback! +* Stateless, air-gapped operation: + * Temporarily stores seeds in memory while the device is powered; all memory is wiped when power is removed. + * SD card removable after boot to ensure no secret data can be written to it. + * No wifi or Bluetooth hardware onboard. + * Can only receive data via reading QR codes with its camera. + * Can only send data by displaying QR codes on its screen. + +* Trustless, auditable: + * Completely FOSS code, MIT license + * Reproducible builds + * Created and maintained by volunteers. There is no corporation. No profit motive. + +* Creating and handling seeds: + * Create a seed phrase by picking BIP39 words, calculates the final word (aka checksum). + * Create a seed phrase [via dice rolls](docs/dice_verification.md). + * Create a seed phrase via image entropy from the onboard camera. + * Guided interface to manually transcribe a seed to the SeedQR format for instant seed loading [(video)](https://youtu.be/c1-PqTNx1vc). + * BIP39 passphrase (aka 13th or 25th word) support. + * Import any existing seed phrase via an optimized seed word entry interface. + * Partial support for Electrum Segwit seed phrases [(info)](docs/electrum.md). + +* Wallet setup and transaction signing + * Script types: Taproot, native segwit, nested segwit, legacy (p2pkh). + * Single sig and multisig xpub export. + * Support for user-defined custom derivation paths. + * In-depth transaction (aka PSBT) review flow before signing. + * Verify the PSBT's single sig or multisig change outputs or self-transfer outputs. + * Mainnet, testnet, and regtest. + +* Additional utilities + * [SettingsQR](https://github.com/SeedSigner/seedsigner-settings-generator) to instantly reconfigure a SeedSigner for beginners, advanced users, or tailored to your preferences. + * Scan a software wallet's receive or change address to verify that it's correct. + * Address Explorer for single sig and multisig wallets. + * Message signing to prove address ownership. + * BIP85 child seed generation. + +* Compatible with: + * Sparrow + * Nunchuk + * Keeper + * BlueWallet + * Specter Desktop + * Any bitcoin wallet software that supports QR codes + +* Supported languages: + * English + * Español + * Many more coming soon! + --------------- @@ -71,14 +86,17 @@ If you have specific questions about the project, our [Telegram Group](https://t To build a SeedSigner, you will need: -* Raspberry Pi Zero (preferably version 1.3 with no WiFi/Bluetooth capability, but any Raspberry Pi 2/3/4 or Zero model will work, Raspberry Pi 1 devices will require a hardware modification to the Waveshare LCD Hat, as per the [instructions here](./docs/legacy_hardware.md)) -* Waveshare 1.3" 240x240 pxl LCD (correct pixel count is important, more info at https://www.waveshare.com/wiki/1.3inch_LCD_HAT) -* Pi Zero-compatible camera (tested to work with the Aokin / AuviPal 5MP 1080p with OV5647 Sensor) +* Raspberry Pi Zero + * Preferably version 1.3 which has no WiFi/Bluetooth capability, but any Raspberry Pi 2/3/4 or Zero "W"/"2W" model will work. +* Waveshare 1.3" 240x240 LCD (MUST be the 240x240 version!) https://www.waveshare.com/wiki/1.3inch_LCD_HAT. +* Pi Zero-compatible camera (tested to work with the Aokin / AuviPal 5MP 1080p with OV5647 Sensor). Notes: -* You will need to solder the 40 GPIO pins (20 pins per row) to the Raspberry Pi Zero board. If you don't want to solder, purchase "GPIO Hammer Headers" for a solderless experience. -* Other cameras with the above sensor module should work, but may not fit in the Orange Pill enclosure -* Choose the Waveshare screen carefully; make sure to purchase the model that has a resolution of 240x240 pixels +* You may need to solder the 40 GPIO pins (20 pins per row) to the Raspberry Pi Zero board. If you don't want to solder, most stores offer the board "with headers" already soldered on. +* The Pi Zero "W" or "2W" is often easier to find but has wifi/Bluetooth hardware. You can still use these boards and can optionally [disable the wifi/Bluetooth hardware](https://github.com/DesobedienteTecnologico/rpi_disable_wifi_and_bt_by_hardware). +* Other cameras with the above sensor module should work, but may not fit in the Orange Pill enclosure. +* Choose the Waveshare screen carefully; they make a number of different boards that look very similar but ARE NOT COMPATIBLE! Make sure you purchase the model that has a resolution of 240x240 pixels. +* Raspberry Pi 1 is also compatible, but will require a [hardware modification to the Waveshare LCD Hat](./docs/legacy_hardware.md). --------------- @@ -119,13 +137,13 @@ Once the files have all finished downloading, follow the steps below to verify t [Our previous software versions are available here](https://github.com/SeedSigner/seedsigner/releases). Choose a specific version and then expand the *Assets* sub-heading to display the .img file binary and also the 2 associated signature files. **Note:** The prior version files will have lower numbers than the scripts and examples provided in this document, but the naming format will be the same, so you can edit them as required for signature verification etc. -## Verifying that the downloaded files are authentic (optional but highly recommended!) +## Verifying your download -You can quickly verify that the software you just downloaded is both authentic and unaltered, by following these instructions. -We assume you are running the commands from a computer where both [GPG](https://gnupg.org/download/index.html) and [shasum](https://command-not-found.com/shasum) are already installed, and that you also know [how to navigate on a terminal](https://terminalcheatsheet.com/guides/navigate-terminal). +You can quickly verify that the software you just downloaded is both authentic and unaltered by following these instructions. +We assume you are running the commands from a computer where both [GPG](https://gnupg.org/download/index.html) and [shasum](https://command-not-found.com/shasum) are already installed and that you also know [how to navigate on a terminal](https://terminalcheatsheet.com/guides/navigate-terminal). > You must run the following verification before opening or mounting the .img file. -> Some operating systems modify the file on mount causing verification to fail. +> Some operating systems modify the file on mount, causing verification to fail. ### Step 1. Verify that the signature (.sig) file is genuine: diff --git a/docs/img/OrangePillMini_Thumb.jpg b/docs/img/OrangePillMini_Thumb.jpg new file mode 100644 index 000000000..a31293cf0 Binary files /dev/null and b/docs/img/OrangePillMini_Thumb.jpg differ diff --git a/docs/raspberry_pi_os_build_instructions.md b/docs/raspberry_pi_os_build_instructions.md index fdfb894e3..b1102a38a 100644 --- a/docs/raspberry_pi_os_build_instructions.md +++ b/docs/raspberry_pi_os_build_instructions.md @@ -122,7 +122,7 @@ SeedSigner requires `zbar` at 0.23.x or higher. Download the binary: ```bash -curl -L http://raspbian.raspberrypi.org/raspbian/pool/main/z/zbar/libzbar0_0.23.90-1_armhf.deb --output libzbar0_0.23.90-1_armhf.deb +curl -L http://raspbian.raspberrypi.org/raspbian/pool/main/z/zbar/libzbar0_0.23.90-1+deb11u1_armhf.deb --output libzbar0_0.23.90-1_armhf.deb ``` And then install it: @@ -211,7 +211,9 @@ Description=Seedsigner [Service] User=pi WorkingDirectory=/home/pi/seedsigner/src/ -ExecStart=/usr/bin/python3 main.py > /dev/null 2>&1 +ExecStart=/usr/bin/python3 main.py +StandardOutput=null +ErrorOutput=null Restart=always [Install] @@ -220,7 +222,7 @@ WantedBy=multi-user.target _Note: For local dev you'll want to edit the `Restart=always` line to `Restart=no`. This way when your dev code crashes it won't keep trying to restart itself. Note that the UI "Reset" will no longer work when auto-restarts are disabled._ -_Note: Debugging output is completely wiped via routing the output to `/dev/null 2>&1`. When working in local dev, you'll `kill` the `systemd` SeedSigner service and just directly run the code on demand so you can see all the debugging output live._ +_Note: Debugging output is completely wiped via routing the stdout and stderr to `/dev/null`. When working in local dev, you'll `kill` the `systemd` SeedSigner service and just directly run the code on demand so you can see all the debugging output live._ Use `CTRL-X` and `y` to exit and save changes. diff --git a/docs/recovery.md b/docs/recovery.md index 7e8569ffa..b4a92abf0 100644 --- a/docs/recovery.md +++ b/docs/recovery.md @@ -13,6 +13,10 @@ Derivation paths for standard script types for mainnet: - Derivation Path: m/49'/0'/0' - Script Type: P2WPKH in P2SH - Public Key Encoding: 0x049d7cb2 - ypub + - Taproot + - Derivation Path: m/86'/0'/0' + - Script Type: P2TR + - Public Key Encoding: 0x0488b21e - xpub - Multisig - Native Segwit - Derivation Path: m/48'/0'/0'/2' @@ -33,3 +37,4 @@ Related Standards: - [bip-0048](https://github.com/bitcoin/bips/blob/master/bip-0048.mediawiki) - [bip-0049](https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki) - [bip-0084](https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki) +- [bip-0086](https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki) diff --git a/docs/seed_qr/printable_templates/grid_wfingerprint_25x25.pdf b/docs/seed_qr/printable_templates/grid_wfingerprint_25x25.pdf new file mode 100644 index 000000000..72baa7af9 Binary files /dev/null and b/docs/seed_qr/printable_templates/grid_wfingerprint_25x25.pdf differ diff --git a/docs/seed_qr/printable_templates/grid_wfingerprint_29x29.pdf b/docs/seed_qr/printable_templates/grid_wfingerprint_29x29.pdf new file mode 100644 index 000000000..8195acd35 Binary files /dev/null and b/docs/seed_qr/printable_templates/grid_wfingerprint_29x29.pdf differ diff --git a/enclosures/orange_pill_mini/OrangePillMini_Bottom.3mf b/enclosures/orange_pill_mini/OrangePillMini_Bottom.3mf new file mode 100644 index 000000000..1c96ba190 Binary files /dev/null and b/enclosures/orange_pill_mini/OrangePillMini_Bottom.3mf differ diff --git a/enclosures/orange_pill_mini/OrangePillMini_Buttons.stl b/enclosures/orange_pill_mini/OrangePillMini_Buttons.stl new file mode 100644 index 000000000..74dffa359 Binary files /dev/null and b/enclosures/orange_pill_mini/OrangePillMini_Buttons.stl differ diff --git a/enclosures/orange_pill_mini/OrangePillMini_Top.stl b/enclosures/orange_pill_mini/OrangePillMini_Top.stl new file mode 100644 index 000000000..1247eb72d Binary files /dev/null and b/enclosures/orange_pill_mini/OrangePillMini_Top.stl differ diff --git a/enclosures/orange_pill_mini/README.md b/enclosures/orange_pill_mini/README.md new file mode 100644 index 000000000..d8e4a2b01 --- /dev/null +++ b/enclosures/orange_pill_mini/README.md @@ -0,0 +1,19 @@ +## The Orange Pill Mini Enclosure + + + +- The Orange Pill Mini is a smaller, hommage to the original Orange Pill enclosure. +- Hardware free build, no screws or spacers needed. +- This enclosure requires use of the so called "zerocam", which allows for a more compact form factor. +- The enclosure offers a very comfortable handling experience. +- Top and bottom part snap fit together. +- SD-card removable +- Data port is closed. + + +### Printing tips +- OrangePillMini_Bottom.3mf is ready for multicolor printing but can also be printed in a single color. +- Files are preoriented for optimal printing, no supports required. +- Controls - buttons, thumbstick and trackball work best if printed from TPU. +- The included controls can be printed from PLA, but TPU is recommended. +- Recommended shore hardness is 95A, but any TPU will do. diff --git a/enclosures/orange_pill_mini/ThumbStick_small.stl b/enclosures/orange_pill_mini/ThumbStick_small.stl new file mode 100644 index 000000000..a8bbb2116 Binary files /dev/null and b/enclosures/orange_pill_mini/ThumbStick_small.stl differ diff --git a/enclosures/orange_pill_mini/Trackball.stl b/enclosures/orange_pill_mini/Trackball.stl new file mode 100644 index 000000000..cbb114126 Binary files /dev/null and b/enclosures/orange_pill_mini/Trackball.stl differ diff --git a/l10n/README.md b/l10n/README.md new file mode 100644 index 000000000..561bb49af --- /dev/null +++ b/l10n/README.md @@ -0,0 +1,316 @@ +# Localization (l10n) Developer Notes + +## High-level overview +1. Python code indicates text that needs to be translated. +1. Those marked strings are extracted into a master `messages.pot` file. +1. That file is uploaded [Transifex](https://app.transifex.com/seedsigner/seedsigner). +1. Translators work within Transifex on their respective languages. +1. Completed translations are downloaded as `messages.po` files for each language. +1. Python "compiles" them into `messages.mo` files ready for use. +1. The `*.po` and `*.mo` files are written to the [seedsigner-translations](https://github.com/SeedSigner/seedsigner-translations) repo. +1. That repo is linked as a submodule here as `seedsigner.resources.seedsigner-translations`. +1. Python code retrieves a translation on demand. + + +## "Wrapping" text for translation +Any text that we want to be presented in multiple languages needs to "wrapped". + +The CORE CONCEPT to understand is that wrapping is used in TWO different contexts: +1. Pre-translation: This is how we identify text that translators need to translate. Any wrapped string literals will appear in translators' Transifex UI. +2. Post-translation: Return the locale-specific translation for that source string (defaults to the English string if no translation is found). + +We have three techniques to wrap code, depending on which of the above contexts we're in and where we are in the code: + + +#### Technique 1: `ButtonOption` +Most `View` classes will render themselves via some variation of the `ButtonListScreen` which takes a `button_data` list as an input. Each entry in +`button_data` must be a `ButtonOption`. The first argument for `ButtonOption` is the `button_label` string. This is the English string literal that +is displayed in that button. If you look at `setup.cfg` you'll see that `ButtonOption` is listed as a keyword in `extract_messages`. That means +that the first argument for `ButtonOption` -- its `button_label` string -- will be marked for translation (by default the `extract_messages` +integration will only look at the first argument of any method listed in `keywords`). + +```python +class SomeView(View): + # These string literals will be marked for translation + OPTION_1 = ButtonOption("Option 1!") + OPTION_2 = ButtonOption("Option 2!") + + def run(self): + button_data = [self.OPTION_1, self.OPTION_2] + + # No way for `extract_messages` to know what's in `some_var`; won't be marked for + # translation unless it's specified elsewhere. + some_var = some_value + button_data.append(ButtonOption(some_var)) +``` + +These `ButtonOption` values are generally specified in class-level attributes, as in the above example. Classes in python are imported once, after +which class-level attributes are never reinterpreted again; **the value at import time for a class-level attribute is its value for the duration of +the program execution.** + +This means that we must assume that `ButtonOption.button_label` strings are ALWAYS the original English string. This is crucial because the English +values are the lookup keys for the translations: + +* `ButtonOption.button_label` = "Hello!" in the python code. +* Run the code, the class that contains our `ButtonOption` as a class-level attribute is imported. +* Regardless of language selection, that `ButtonOption` will always return "Hello!". +* `Screen` then uses "Hello!" as a key to find the translation "¡Hola!". +* User sees "¡Hola!". + +IF `ButtonOption` were wired to return the translated string, we'd have a problem: +* User sets their language to Spanish and enables persistent settings. +* Launch SeedSigner. At import time the `button_label`'s value is translated to "¡Hola!". +* User sees "¡Hola!" in the UI. All good. +* User changes language to English (or any other language). +* Now the `Screen` must find the matching string in a different translation file. +* But the `button_label` value was fixed at import time; it's still providing "¡Hola!" as the lookup key. +* Since all the translation files map English -> translation, no such "¡Hola!" match exists in any translation file. +* So the translation falls back to just displaying the unmatched key: "¡Hola!" + +tldr: `ButtonOption` marks its `button_label` English string literal for translation, but NEVER provides a translated value. + +--- + +#### Technique 2: `seedsigner.helpers.l10n.mark_for_translation` +You'll see that `mark_for_translation` is imported as `_mft` for short. + +As far as translations are concerned, `_mft` serves the same purpose as `ButtonOption`. The only difference is that `_mft` is for all other +(non-`button_data`) class-level attributes. + +```python +from seedsigner.helpers.l10n import mark_for_translation as _mft + +@classmethod +class SomeView(View): + title: str = _mft("Default Title") + text: str = _mft("My default body text") + + def run(self): + self.run_screen( + SomeScreen, + title=self.title, + text=self.text + ) +``` + +In general we try to avoid using `_mft` at all, but some class-level attributes just can't be avoided. + +--- + +#### Technique 3: `gettext`, aka `_()` +This is the way you'll see text wrapping handled in the vast majority of tutorials. + +```python +from gettext import gettext as _ + +my_text = _("Hello!") + +# Specify Spanish +os.environ['LANGUAGE'] = "es" +print(my_text) +>> ¡Hola! + +# Specify English +os.environ['LANGUAGE'] = "en" +print(my_text) +>> Hello! +``` + +This approach marks string literals for translation AND retrieves the translated text. + +We do the same in SeedSigner code, but only when the string literal is in a part of the code that is dynamically evaluated: + +```python +from gettext import gettext as _ + +class SomeView(View): + def __init__(self): + # Mark string literal for translation AND dynamically retrieve its translated value + self.some_var = _("I will be dynamically fetched") +``` + +Though note that there are times when we use `_()` only for the retrieval side: + +```python +from seedsigner.helpers.l10n import mark_for_translation as _mft + +class SomeView(View): + message = _mft("Hello!") # mark for translation, but always return "Hello!" + + def run(self): + self.run_screen( + SomeScreen, + message=self.title + ) + +# elsewhere... +@dataclass +class SomeScreen(Screen): + message: str = None + + def __post_init__(self): + message_display = TextArea( + text=_(self.message) # The _() wrapping here now retrieves the translated value, if one is available + ) +``` + +--- + +## Basic rules +* English string literals in class-level attributes should be wrapped with either `ButtonOption` (for `button_data` entries) or `_mft` (for misc class-level attrs) so they'll be picked up for translation. +* English string literals anywhere else should be wrapped with `_()` to be marked for translation AND provide the dynamic translated value. +* In general, don't go out of your way to translate text before passing it into `Screen` classes. + * The `Screen` itself should do most of the `_()` calls to fetch translations for final display. + * Minor risk of double-translation weirdness otherwise. + +Mark for translation in the `View`. Retrieve translated values in the `Screen`. Pass final display text into the basic gui `Component`s. + +--- + +## Provide translation context hints +In many cases the English string literal on its own does not provide enough context for translators to understand how the word is being used. + +For example, is "change" referring to altering a value OR is it the amount coming back to you in a transaction? + +Whenever necessary, add explanatory context as a comment. This applies to all three ways of marking strings for translation. + +The `extract_messages` command is explictly looking for the exact string: `# TRANSLATOR_NOTE:` in comments. + +```python +class SeedAddressVerificationView(View): + # TRANSLATOR_NOTE: Option when scanning for a matching address; skips ten addresses ahead + SKIP_10 = ButtonOption("Skip 10") +``` + +Note that the comment MUST be on the preceding line of executable code for it to work: + +```python +class SettingsConstants + # TRANSLATOR_NOTE: QR code density option: Low, Medium, High <-- ✅ Correct way to add context + density_low = _mft("Low") + + ALL_DENSITIES = [ + (DENSITY__LOW, density_low), + # TRANSLATOR_NOTE: QR code density option: Low, Medium, High <-- ❌ Note will NOT be picked up + (DENSITY__MEDIUM, "Medium"), + (DENSITY__HIGH, "High"), + ] +``` + +```python +# TRANSLATOR_NOTE: Refers to the user's change output in a psbt +some_var = _("change") +``` + +--- + +## `_()` Wrapping syntax details +* Use `.format()` to wrap strings with variable injections. Note that `.format()` is OUTSIDE the `_()` wrapping. + ```python + mystr = f"My dad's name is {dad.name} and my name is {self.name}." + mystr = _("My dad's name is {} and my name is {}").format(dad.name, self.name) + ``` + + The translators will only see: "My dad's name is {} and my name is {}" in Transifex. Often the English string literal is + basically incomprehensible on its own so always provide an explanation for what is being injected: + + ```python + # TRANSLATOR_NOTE: Address verification success message (e.g. "bc1qabc = seed 12345678's receive address #0.") + text = _("{} = {}'s {} address #{}.").format(...) + ``` + + If there are a lot of variables to inject, placeholder names can be used (TODO: how does Transifex display this?): + ```python + mystr = _("My dad's name is {dad_name} and my name is {my_name}").format(dad_name=dad.name, my_name=self.name) + ``` +* Use `ngettext` to dynamically handle singular vs plural forms based on an integer quantity: + ```python + n = 1 + print(ngettext("apple", "apples", n)) + >> apple + + n = 5 + print(ngettext("apple", "apples", n)) + >> apples + ``` + +Transifex will ask translators to provide the singular and plural forms on a language-specific basis (e.g. Arabic as THREE plural forms!). + +--- + +## Set up localization dependencies +```bash +pip install -r l10n/requirements-l10n.txt +``` + +Make sure that your local repo has fetched the `seedsigner-translations` submodule. It's configured to add it in src/seedsigner/resources. +```bash +# Need --remote in order to respect the target branch listed in .gitmodules +git submodule update --remote +``` + + +### Pre-configured `babel` commands +The `setup.cfg` file in the project root specifies params for the various `babel` commands discussed below. + +You should have already added the local code as an editable project in pip: +```bash +# From the repo root +pip install -e . +``` + + +### Rescanning for text that needs translations +Re-generate the `messages.pot` file: + +```bash +python setup.py extract_messages +``` + +This will rescan all wrapped text, picking up new strings as well as updating existings strings that have been edited. + +_TODO: Github Action to auto-generate messages.pot and fail a PR update if the PR has an out of date messages.pot?_ + + +### Making new text available to translators +Upload the master `messages.pot` to Transifex. It will automatically update each language with the new or changed source strings. + +_TODO: Look into Transifex options to automatically pull updates?_ + + +### Once new translations are complete +The translation file for each language will need to be downloaded via Transifex's "Download for use" option (sends you a `messages.po` file for that language). + +This updated `messages.po` should be added to the seedsigner-translations repo in l10n/`{TARGET_LOCALE}`/LC_MESSAGES. + + +### Compile all the translations +The `messages.po` files must be compiled into `*.mo` files: + +```bash +python setup.py compile_catalog + +# Or target a specific language code: +python setup.py compile_catalog -l es +``` + +### Unused babel commands +Transifex eliminates the need for the `init_catalog` and `update_catalog` commands. + + +## Keep the seedsigner-translations repo up to date +The *.po files for each language and their compiled *.mo files should all be kept up to date in the seedsigner-translations repo. + +_TODO: Github Actions automation to regenerate / verify that the *.mo files have been updated after *.po changes._ + +--- + +## Generate screenshots in each language +Simply run the screenshot generator: + +```bash +pytest tests/screenshot_generator/generator.py + +# Or target a specific language code: +pytest tests/screenshot_generator/generator.py --locale es +``` diff --git a/l10n/messages.pot b/l10n/messages.pot new file mode 100644 index 000000000..cdbd2e776 --- /dev/null +++ b/l10n/messages.pot @@ -0,0 +1,1489 @@ +# Translations template for seedsigner. +# Copyright (C) 2024 ORGANIZATION +# This file is distributed under the same license as the seedsigner project. +# FIRST AUTHOR , 2024. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: seedsigner 0.8.5\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2024-11-03 14:15-0600\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.16.0\n" + +#. Testnet bitcoin +#: src/seedsigner/gui/components.py +msgid "tBtc" +msgstr "" + +#. Testnet sats +#: src/seedsigner/gui/components.py +msgid "tSats" +msgstr "" + +#: src/seedsigner/gui/components.py src/seedsigner/gui/screens/psbt_screens.py +msgid "btc" +msgstr "" + +#: src/seedsigner/gui/components.py src/seedsigner/gui/screens/psbt_screens.py +#: src/seedsigner/models/settings_definition.py +msgid "sats" +msgstr "" + +#. The abbreviated label for the special key on a standard keyboard. +#: src/seedsigner/gui/keyboard.py +msgid "del" +msgstr "" + +#. The abbreviated label for the special key on a standard keyboard. +#: src/seedsigner/gui/keyboard.py +msgid "space" +msgstr "" + +#: src/seedsigner/gui/toast.py +msgid "" +"You can remove\n" +"the SD card now" +msgstr "" + +#: src/seedsigner/gui/toast.py +msgid "SD card removed" +msgstr "" + +#: src/seedsigner/gui/toast.py +msgid "SD card inserted" +msgstr "" + +#: src/seedsigner/gui/screens/psbt_screens.py +msgid "Review PSBT" +msgstr "" + +#: src/seedsigner/gui/screens/psbt_screens.py +msgid "Review Details" +msgstr "" + +#: src/seedsigner/gui/screens/psbt_screens.py +msgid "1 input" +msgstr "" + +#: src/seedsigner/gui/screens/psbt_screens.py +msgid "input 1" +msgstr "" + +#: src/seedsigner/gui/screens/psbt_screens.py +msgid "input 2" +msgstr "" + +#. Indicates that items have been omitted from a series: e.g. "1, 2, 3, [...], +#. 8" +#: src/seedsigner/gui/screens/psbt_screens.py +msgid "[ ... ]" +msgstr "" + +#. Input number will be inserted (e.g. "input 3") +#: src/seedsigner/gui/screens/psbt_screens.py +msgid "input {}" +msgstr "" + +#. Ellipsis ("...") characters used to truncate an address (e.g. "bc1qabc...") +#: src/seedsigner/gui/screens/psbt_screens.py +msgid "..." +msgstr "" + +#: src/seedsigner/gui/screens/psbt_screens.py +#: src/seedsigner/views/psbt_views.py +msgid "self-transfer" +msgstr "" + +#: src/seedsigner/gui/screens/psbt_screens.py +msgid "recipient 1" +msgstr "" + +#. Inserts the recipient number (e.g. the fifth one is: "recipient 5") +#: src/seedsigner/gui/screens/psbt_screens.py +msgid "recipient {}" +msgstr "" + +#: src/seedsigner/gui/screens/psbt_screens.py +msgid "fee" +msgstr "" + +#. Technical term, should probably NOT be translated in most languages +#: src/seedsigner/gui/screens/psbt_screens.py +#: src/seedsigner/views/psbt_views.py +msgid "OP_RETURN" +msgstr "" + +#. Label for a change output in the PSBT Overview flow diagram +#. Used in a sentence describing the address type (change or receive) +#: src/seedsigner/gui/screens/psbt_screens.py +#: src/seedsigner/views/psbt_views.py src/seedsigner/views/seed_views.py +msgid "change" +msgstr "" + +#: src/seedsigner/gui/screens/psbt_screens.py +msgid "PSBT Math" +msgstr "" + +#: src/seedsigner/gui/screens/psbt_screens.py +msgid "Review Recipients" +msgstr "" + +#: src/seedsigner/gui/screens/psbt_screens.py +msgid "input" +msgid_plural "inputs" +msgstr[0] "" +msgstr[1] "" + +#: src/seedsigner/gui/screens/psbt_screens.py +msgid "recipient" +msgid_plural "recipients" +msgstr[0] "" +msgstr[1] "" + +#. Denonination is inserted (e.g. your "btc change" or "sats change") +#: src/seedsigner/gui/screens/psbt_screens.py +msgid "{} change" +msgstr "" + +#: src/seedsigner/gui/screens/psbt_screens.py +#: src/seedsigner/models/settings_definition.py +#: src/seedsigner/views/seed_views.py +msgid "Multisig" +msgstr "" + +#: src/seedsigner/gui/screens/psbt_screens.py +msgid "Change" +msgstr "" + +#. Abbreviation for receive address +#: src/seedsigner/gui/screens/psbt_screens.py +msgid "Addr" +msgstr "" + +#: src/seedsigner/gui/screens/psbt_screens.py +msgid "Address verified!" +msgstr "" + +#. Shown when displaying OP_RETURN as non-human-readable hexadecimal data +#: src/seedsigner/gui/screens/psbt_screens.py +msgid "raw hex data" +msgstr "" + +#: src/seedsigner/gui/screens/psbt_screens.py +msgid "Sign PSBT" +msgstr "" + +#: src/seedsigner/gui/screens/psbt_screens.py +msgid "Click to approve this transaction" +msgstr "" + +#: src/seedsigner/gui/screens/scan_screens.py +#: src/seedsigner/gui/screens/tools_screens.py +msgid "back" +msgstr "" + +#. Inserts the percentage value of the animated QR scan progress +#: src/seedsigner/gui/screens/scan_screens.py +msgid "{}%" +msgstr "" + +#. Increase QR code screen brightness +#: src/seedsigner/gui/screens/screen.py +msgid "Brighter" +msgstr "" + +#. Decrease QR code screen brightness +#: src/seedsigner/gui/screens/screen.py +msgid "Darker" +msgstr "" + +#: src/seedsigner/gui/screens/screen.py src/seedsigner/views/seed_views.py +msgid "Success!" +msgstr "" + +#: src/seedsigner/gui/screens/screen.py src/seedsigner/views/seed_views.py +msgid "OK" +msgstr "" + +#: src/seedsigner/gui/screens/screen.py src/seedsigner/views/psbt_views.py +msgid "Caution" +msgstr "" + +#: src/seedsigner/gui/screens/screen.py src/seedsigner/views/seed_views.py +msgid "Privacy Leak!" +msgstr "" + +#: src/seedsigner/gui/screens/screen.py +msgid "I Understand" +msgstr "" + +#: src/seedsigner/gui/screens/screen.py +msgid "Classified Info!" +msgstr "" + +#: src/seedsigner/gui/screens/screen.py +msgid "Restarting" +msgstr "" + +#: src/seedsigner/gui/screens/screen.py +msgid "" +"SeedSigner is restarting.\n" +"\n" +"All in-memory data will be wiped." +msgstr "" + +#: src/seedsigner/gui/screens/screen.py +msgid "Powering Off" +msgstr "" + +#: src/seedsigner/gui/screens/screen.py +msgid "Please wait about 30 seconds before disconnecting power." +msgstr "" + +#: src/seedsigner/gui/screens/screen.py +msgid "Just Unplug It" +msgstr "" + +#: src/seedsigner/gui/screens/screen.py +msgid "It is safe to disconnect power at any time." +msgstr "" + +#: src/seedsigner/gui/screens/seed_screens.py +msgid "Finalize Seed" +msgstr "" + +#. a label for the shortened Key-id of a BIP-32 master HD wallet +#: src/seedsigner/gui/screens/seed_screens.py +#: src/seedsigner/gui/screens/tools_screens.py +#: src/seedsigner/views/seed_views.py +msgid "fingerprint" +msgstr "" + +#: src/seedsigner/gui/screens/seed_screens.py +#: src/seedsigner/views/seed_views.py +msgid "Backup Seed" +msgstr "" + +#. Additional explainer for the two seed backup options (mnemonic phrase and +#. SeedQR). +#: src/seedsigner/gui/screens/seed_screens.py +msgid "Backups do not include your passphrase." +msgstr "" + +#. Displays the page number and total: (e.g. page 1 of 6) +#: src/seedsigner/gui/screens/seed_screens.py +msgid "Seed Words: {}/{}" +msgstr "" + +#: src/seedsigner/gui/screens/seed_screens.py +msgid "BIP-85 Index" +msgstr "" + +#: src/seedsigner/gui/screens/seed_screens.py +msgid "Verify Backup?" +msgstr "" + +#: src/seedsigner/gui/screens/seed_screens.py +msgid "Optionally verify that your mnemonic backup is correct." +msgstr "" + +#: src/seedsigner/gui/screens/seed_screens.py +msgid "Derivation Path" +msgstr "" + +#: src/seedsigner/gui/screens/seed_screens.py +#: src/seedsigner/views/seed_views.py +msgid "Export Xpub" +msgstr "" + +#: src/seedsigner/gui/screens/seed_screens.py +msgid "Xpub Details" +msgstr "" + +#. Short for "BIP32 Master Fingerprint" +#. a label for the shortened Key-id of a BIP-32 master HD wallet +#: src/seedsigner/gui/screens/seed_screens.py +#: src/seedsigner/gui/screens/tools_screens.py +msgid "Fingerprint" +msgstr "" + +#. Short for "Derivation Path" +#. a label for the derivation-path into a BIP-32 HD wallet +#: src/seedsigner/gui/screens/seed_screens.py +#: src/seedsigner/gui/screens/tools_screens.py +msgid "Derivation" +msgstr "" + +#. Short for "BIP32 Extended Public Key" +#: src/seedsigner/gui/screens/seed_screens.py +msgid "Xpub" +msgstr "" + +#: src/seedsigner/gui/screens/seed_screens.py +#: src/seedsigner/models/settings_definition.py +#: src/seedsigner/views/seed_views.py +msgid "BIP-39 Passphrase" +msgstr "" + +#: src/seedsigner/gui/screens/seed_screens.py +msgid "Verify Passphrase" +msgstr "" + +#. Describes the effect of applying a BIP-39 passphrase; it changes the seed's +#. fingerprint +#: src/seedsigner/gui/screens/seed_screens.py +msgid "changes fingerprint" +msgstr "" + +#. Refers to the SeedQR type: Standard or Compact +#: src/seedsigner/gui/screens/seed_screens.py +msgid "Standard" +msgstr "" + +#. Briefly explains the Standard SeedQR data format +#: src/seedsigner/gui/screens/seed_screens.py +msgid "BIP-39 wordlist indices" +msgstr "" + +#. Refers to the SeedQR type: Standard or Compact +#: src/seedsigner/gui/screens/seed_screens.py +msgid "Compact" +msgstr "" + +#. Briefly explains the Compact SeedQR data format +#: src/seedsigner/gui/screens/seed_screens.py +msgid "Raw entropy bits" +msgstr "" + +#: src/seedsigner/gui/screens/seed_screens.py +msgid "Transcribe SeedQR" +msgstr "" + +#. Refers to the QR code size: 21x21, 25x25, or 29x29 +#: src/seedsigner/gui/screens/seed_screens.py +msgid "Begin {}x{}" +msgstr "" + +#: src/seedsigner/gui/screens/seed_screens.py +msgid "click to exit" +msgstr "" + +#: src/seedsigner/gui/screens/seed_screens.py +msgid "" +"Optionally scan your transcribed SeedQR to confirm that it reads back " +"correctly." +msgstr "" + +#: src/seedsigner/gui/screens/seed_screens.py +#: src/seedsigner/views/seed_views.py src/seedsigner/views/tools_views.py +msgid "Verify Address" +msgstr "" + +#. Inserts the nth address number (e.g. "Checking address 7") +#: src/seedsigner/gui/screens/seed_screens.py +msgid "Checking address {}" +msgstr "" + +#: src/seedsigner/gui/screens/seed_screens.py +msgid "Multisig Verification" +msgstr "" + +#: src/seedsigner/gui/screens/seed_screens.py +msgid "" +"Load your multisig wallet descriptor to verify your receive/self-transfer" +" or change address." +msgstr "" + +#: src/seedsigner/gui/screens/seed_screens.py +msgid "Descriptor Loaded" +msgstr "" + +#. Label for the multisig wallet's signing policy (e.g. 2-of-3) +#: src/seedsigner/gui/screens/seed_screens.py +msgid "Policy" +msgstr "" + +#: src/seedsigner/gui/screens/seed_screens.py +msgid "Signing Keys" +msgstr "" + +#: src/seedsigner/gui/screens/seed_screens.py +msgid "Review Message" +msgstr "" + +#. Short for "Next step" +#: src/seedsigner/gui/screens/seed_screens.py +#: src/seedsigner/views/psbt_views.py src/seedsigner/views/seed_views.py +#: src/seedsigner/views/tools_views.py +msgid "Next" +msgstr "" + +#: src/seedsigner/gui/screens/seed_screens.py +msgid "Confirm Address" +msgstr "" + +#: src/seedsigner/gui/screens/seed_screens.py +#: src/seedsigner/views/seed_views.py +msgid "Sign Message" +msgstr "" + +#: src/seedsigner/gui/screens/seed_screens.py +msgid "derivation path" +msgstr "" + +#: src/seedsigner/gui/screens/settings_screens.py +#: src/seedsigner/views/settings_views.py src/seedsigner/views/view.py +msgid "Settings" +msgstr "" + +#. Short for "Input/Output"; screen to make sure the buttons and camera are +#. working properly +#: src/seedsigner/gui/screens/settings_screens.py +msgid "I/O Test" +msgstr "" + +#. Blank the screen +#: src/seedsigner/gui/screens/settings_screens.py +msgid "Clear" +msgstr "" + +#: src/seedsigner/gui/screens/settings_screens.py +msgid "Exit" +msgstr "" + +#: src/seedsigner/gui/screens/settings_screens.py +#: src/seedsigner/gui/screens/tools_screens.py +msgid "Capturing image..." +msgstr "" + +#: src/seedsigner/gui/screens/settings_screens.py +#: src/seedsigner/views/settings_views.py +msgid "Donate" +msgstr "" + +#. If your language uses the percent sign ("%"), your translation must also use +#. two percent signs ("%%") due to python formatting oddities. "100%%" will be +#. rendered as "100%". +#: src/seedsigner/gui/screens/settings_screens.py +#, python-format +msgid "" +"SeedSigner is 100%% free & open source, funded solely by the Bitcoin " +"community.\n" +"\n" +"Donate onchain or LN at:" +msgstr "" + +#: src/seedsigner/gui/screens/settings_screens.py +#: src/seedsigner/views/settings_views.py +msgid "Settings QR" +msgstr "" + +#: src/seedsigner/gui/screens/settings_screens.py +msgid "Settings updated..." +msgstr "" + +#: src/seedsigner/gui/screens/settings_screens.py src/seedsigner/views/view.py +msgid "Home" +msgstr "" + +#: src/seedsigner/gui/screens/tools_screens.py +msgid "click joystick" +msgstr "" + +#. A prompt to the user to either accept or reshoot the image +#: src/seedsigner/gui/screens/tools_screens.py +msgid "reshoot" +msgstr "" + +#. A prompt to the user to either accept or reshoot the image +#: src/seedsigner/gui/screens/tools_screens.py +msgid "accept" +msgstr "" + +#. current roll number vs total rolls (e.g. roll 7 of 50) +#: src/seedsigner/gui/screens/tools_screens.py +msgid "Dice Roll {}/{}" +msgstr "" + +#. Build the last word in a 12 or 24 word BIP-39 mnemonic seed phrase. +#: src/seedsigner/gui/screens/tools_screens.py +msgid "Build Final Word" +msgstr "" + +#. Number of BIP-39 seed words, and the entropy -- in bits, contained within. +#: src/seedsigner/gui/screens/tools_screens.py +msgid "" +"The {}th word is built from {} more entropy bits plus auto-calculated " +"checksum." +msgstr "" + +#. current coin-flip number vs total flips (e.g. flip 3 of 4) +#: src/seedsigner/gui/screens/tools_screens.py +msgid "Coin Flip {}/{}" +msgstr "" + +#. How we call the "front" side result during a coin toss. +#: src/seedsigner/gui/screens/tools_screens.py +msgid "Heads = 1" +msgstr "" + +#. How we call the "back" side result during a coin toss. +#: src/seedsigner/gui/screens/tools_screens.py +msgid "Tails = 0" +msgstr "" + +#. The additional entropy the user supplied (e.g. coin flips) +#: src/seedsigner/gui/screens/tools_screens.py +msgid "Your input: \"{}\"" +msgstr "" + +#. A function of "x" to be used for detecting errors in "x" +#: src/seedsigner/gui/screens/tools_screens.py +msgid "Checksum" +msgstr "" + +#. labeled presentation of the last word in a BIP-39 mnemonic seed phrase. +#: src/seedsigner/gui/screens/tools_screens.py +msgid "Final Word: \"{}\"" +msgstr "" + +#. a label for the last word of a 12-word BIP-39 mnemonic seed phrase +#: src/seedsigner/gui/screens/tools_screens.py +msgid "12th Word" +msgstr "" + +#. a label for the last word of a 24-word BIP-39 mnemonic seed phrase +#: src/seedsigner/gui/screens/tools_screens.py +msgid "24th Word" +msgstr "" + +#. a label for the tool to explore public addresses for this seed. +#: src/seedsigner/gui/screens/tools_screens.py +#: src/seedsigner/views/seed_views.py src/seedsigner/views/tools_views.py +msgid "Address Explorer" +msgstr "" + +#. a label for a BIP-380-ish Output Descriptor +#: src/seedsigner/gui/screens/tools_screens.py +msgid "Wallet descriptor" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Enabled" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Disabled" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Prompt" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Required" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "BTC" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Threshold at 0.01" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "BTC | sats hybrid" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "0°" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "90°" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "180°" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "270°" +msgstr "" + +#. QR code density option: Low, Medium, High +#: src/seedsigner/models/settings_definition.py +msgid "Low" +msgstr "" + +#. QR code density option: Low, Medium, High +#: src/seedsigner/models/settings_definition.py +msgid "Medium" +msgstr "" + +#. QR code density option: Low, Medium, High +#: src/seedsigner/models/settings_definition.py +msgid "High" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Mainnet" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Testnet" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Regtest" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Store Settings on SD card" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Insert SD card to enable" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +#: src/seedsigner/views/seed_views.py +msgid "Single Sig" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Native Segwit" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Nested Segwit" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Legacy" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Taproot" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Custom Derivation" +msgstr "" + +#. Terminology used by Electrum seeds; equivalent to bip39 passphrase +#: src/seedsigner/models/settings_definition.py +msgid "Custom Extension" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Language" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Mnemonic language" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Persistent settings" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Coordinator software" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Denomination display" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Bitcoin network" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "QR code density" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Xpub export" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Sig types" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Script types" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Show xpub details" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "BIP-39 passphrase" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Camera rotation" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Compact SeedQR" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "BIP-85 child seeds" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Native Segwit only" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Message signing" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Show privacy warnings" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Show dire warnings" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Show QR brightness tips" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "Show partner logos" +msgstr "" + +#: src/seedsigner/models/settings_definition.py +msgid "QR background color" +msgstr "" + +#: src/seedsigner/views/psbt_views.py src/seedsigner/views/seed_views.py +#: src/seedsigner/views/tools_views.py +msgid "Scan a seed" +msgstr "" + +#: src/seedsigner/views/psbt_views.py src/seedsigner/views/seed_views.py +#: src/seedsigner/views/tools_views.py +msgid "Enter 12-word seed" +msgstr "" + +#: src/seedsigner/views/psbt_views.py src/seedsigner/views/seed_views.py +#: src/seedsigner/views/tools_views.py +msgid "Enter 24-word seed" +msgstr "" + +#: src/seedsigner/views/psbt_views.py src/seedsigner/views/seed_views.py +#: src/seedsigner/views/tools_views.py +msgid "Enter Electrum seed" +msgstr "" + +#. Inserts fingerprint w/"?" to indicate that this seed can't sign the current +#. PSBT +#: src/seedsigner/views/psbt_views.py +msgid "{} (?)" +msgstr "" + +#: src/seedsigner/views/psbt_views.py +msgid "Select Signer" +msgstr "" + +#: src/seedsigner/views/psbt_views.py +msgid "Parsing PSBT..." +msgstr "" + +#: src/seedsigner/views/psbt_views.py +msgid "Unsupported Script Type!" +msgstr "" + +#: src/seedsigner/views/psbt_views.py +msgid "" +"PSBT has unsupported input script type, please verify your change " +"addresses." +msgstr "" + +#: src/seedsigner/views/psbt_views.py +msgid "Continue" +msgstr "" + +#. User will receive no change back; the inputs to this transaction are fully +#. spent +#: src/seedsigner/views/psbt_views.py +msgid "Full Spend!" +msgstr "" + +#: src/seedsigner/views/psbt_views.py +msgid "" +"This PSBT spends its entire input value. No change is coming back to your" +" wallet." +msgstr "" + +#. Future-tense used to indicate that this transaction will send this amount, +#. as opposed to "Send" on its own which could be misread as an instant command +#. (e.g. "Send Now"). +#: src/seedsigner/views/psbt_views.py +msgid "Will Send" +msgstr "" + +#: src/seedsigner/views/psbt_views.py +msgid "Next Recipient" +msgstr "" + +#: src/seedsigner/views/psbt_views.py +msgid "Skip Verification" +msgstr "" + +#: src/seedsigner/views/psbt_views.py +msgid "Verify Multisig Change" +msgstr "" + +#. The amount you're receiving back from the transaction +#: src/seedsigner/views/psbt_views.py +msgid "Your Change" +msgstr "" + +#: src/seedsigner/views/psbt_views.py +msgid "Self-Transfer" +msgstr "" + +#: src/seedsigner/views/psbt_views.py +msgid "Verify Multisig Addr" +msgstr "" + +#: src/seedsigner/views/psbt_views.py +msgid "Verifying Change..." +msgstr "" + +#: src/seedsigner/views/psbt_views.py +msgid "Verifying Self-Transfer..." +msgstr "" + +#. Variable is either "change" or "self-transfer". +#: src/seedsigner/views/psbt_views.py +msgid "" +"PSBT's {} address could not be verified with your multisig wallet " +"descriptor." +msgstr "" + +#: src/seedsigner/views/psbt_views.py +msgid "Suspicious PSBT" +msgstr "" + +#. Variable is either "change" or "self-transfer". +#: src/seedsigner/views/psbt_views.py +msgid "PSBT's {} address could not be generated from your seed." +msgstr "" + +#: src/seedsigner/views/psbt_views.py +msgid "Address Verification Failed" +msgstr "" + +#: src/seedsigner/views/psbt_views.py +msgid "Discard PSBT" +msgstr "" + +#: src/seedsigner/views/psbt_views.py +msgid "Approve PSBT" +msgstr "" + +#: src/seedsigner/views/psbt_views.py +msgid "Select Diff Seed" +msgstr "" + +#: src/seedsigner/views/psbt_views.py +msgid "PSBT Error" +msgstr "" + +#: src/seedsigner/views/psbt_views.py +msgid "Signing Failed" +msgstr "" + +#: src/seedsigner/views/psbt_views.py +msgid "Signing with this seed did not add a valid signature." +msgstr "" + +#: src/seedsigner/views/scan_views.py +msgid "Scan a QR code" +msgstr "" + +#: src/seedsigner/views/scan_views.py +msgid "QRCode not recognized or not yet supported." +msgstr "" + +#: src/seedsigner/views/scan_views.py +msgid "Wrong QR Type" +msgstr "" + +#: src/seedsigner/views/scan_views.py src/seedsigner/views/view.py +msgid "Error" +msgstr "" + +#: src/seedsigner/views/scan_views.py +msgid "Unknown QR Type" +msgstr "" + +#: src/seedsigner/views/scan_views.py +msgid "QRCode is invalid or is a data format not yet supported." +msgstr "" + +#: src/seedsigner/views/scan_views.py src/seedsigner/views/seed_views.py +#: src/seedsigner/views/view.py +msgid "Done" +msgstr "" + +#: src/seedsigner/views/scan_views.py src/seedsigner/views/seed_views.py +msgid "Scan PSBT" +msgstr "" + +#: src/seedsigner/views/scan_views.py +msgid "Expected a PSBT" +msgstr "" + +#: src/seedsigner/views/scan_views.py +msgid "Scan SeedQR" +msgstr "" + +#: src/seedsigner/views/scan_views.py +msgid "Expected a SeedQR" +msgstr "" + +#: src/seedsigner/views/scan_views.py +msgid "Scan descriptor" +msgstr "" + +#: src/seedsigner/views/scan_views.py +msgid "Expected a wallet descriptor QR" +msgstr "" + +#: src/seedsigner/views/scan_views.py +msgid "Scan address QR" +msgstr "" + +#: src/seedsigner/views/scan_views.py +msgid "Expected an address QR" +msgstr "" + +#. This is on the opening splash screen, displayed above the HRF logo +#: src/seedsigner/views/screensaver.py +msgid "With support from:" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Load a seed" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "In-Memory Seeds" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Load the seed to verify" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Select seed to verify" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Load the seed to sign with" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Select seed to sign with" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Scan a SeedQR" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Create a seed" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Load A Seed" +msgstr "" + +#. Inserts the word number (e.g. "Seed Word #6") +#: src/seedsigner/views/seed_views.py +msgid "Seed Word #{}" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Review & Edit" +msgstr "" + +#: src/seedsigner/views/seed_views.py src/seedsigner/views/tools_views.py +msgid "Discard" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Invalid Mnemonic!" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Checksum failure; not a valid seed phrase." +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Edit passphrase" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Discard passphrase" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Discard passphrase?" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Your current passphrase entry will be erased" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Keep Seed" +msgstr "" + +#. Inserts the seed fingerprint +#: src/seedsigner/views/seed_views.py +msgid "Wipe seed {} from the device?" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Discard Seed?" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Electrum warning" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Some features are disabled for Electrum seeds." +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Verify Addr" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "BIP-85 Child Seed" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Discard Seed" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "View Seed Words" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Export as SeedQR" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Xpub can be used to view all future transactions." +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Generating xpub..." +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "You must keep your seed words private & away from all online devices." +msgstr "" + +#. Inserts the child index (e.g. "Child #0") +#: src/seedsigner/views/seed_views.py +msgid "Child #{}" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Seed Words" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "12 Words" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "24 Words" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "BIP-85 Num Words" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "BIP-85 Index Error" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Invalid Child Index" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "BIP-85 Child Index must be between 0 and 2^31-1." +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Try Again" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Verify" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Skip" +msgstr "" + +#. Inserts the word number (e.g. "Verify Word #1") +#: src/seedsigner/views/seed_views.py +msgid "Verify Word #{}" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Review Seed Words" +msgstr "" + +#. Inserts the word number and the word (e.g. "Word #1 is not "apple"!") +#: src/seedsigner/views/seed_views.py +msgid "Word #{} is not \"{}\"!" +msgstr "" + +#. User selected the wrong word during the mnemonic backup test (e.g. +#. incorrectly said the 5th word was "zoo") +#: src/seedsigner/views/seed_views.py +msgid "Wrong Word!" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Verification Error" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Backup Verified" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "All mnemonic backup words were successfully verified!" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Standard: 25x25" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Compact: 21x21" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Standard: 29x29" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Compact: 25x25" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "SeedQR Format" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "SeedQR is your private key!" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Never photograph or scan it into a device that connects to the internet." +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Confirm SeedQR" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Confirm SeedQR?" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Scan your SeedQR" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Error!" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Your transcribed SeedQR does not match your original seed!" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Review SeedQR" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Your transcribed SeedQR successfully scanned and yielded the same seed." +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Your transcribed SeedQR could not be read!" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Sig type can't be auto-detected from this address. Please specify:" +msgstr "" + +#. Option when scanning for a matching address; skips ten addresses ahead +#: src/seedsigner/views/seed_views.py +msgid "Skip 10" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Cancel" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Can't validate a single sig addr without specifying a seed" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "multisig" +msgstr "" + +#. Inserts the seed fingerprint +#: src/seedsigner/views/seed_views.py +msgid "seed {}" +msgstr "" + +#. Used in a sentence describing the address type (change or receive) +#: src/seedsigner/views/seed_views.py +msgid "receive" +msgstr "" + +#. Address verification success message (e.g. "bc1qabc = seed 12345678's +#. receive address #0.") +#: src/seedsigner/views/seed_views.py +msgid "{} = {}'s {} address #{}." +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Address Verified" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Scan Descriptor" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Return to PSBT" +msgstr "" + +#: src/seedsigner/views/seed_views.py +msgid "Signing messages for custom derivation paths not supported" +msgstr "" + +#: src/seedsigner/views/settings_views.py +msgid "I/O test" +msgstr "" + +#: src/seedsigner/views/settings_views.py +msgid "Advanced" +msgstr "" + +#: src/seedsigner/views/settings_views.py +msgid "Dev Options" +msgstr "" + +#: src/seedsigner/views/settings_views.py +msgid "Persistent Settings enabled. Settings saved to SD card." +msgstr "" + +#: src/seedsigner/views/settings_views.py +msgid "Settings updated in temporary memory" +msgstr "" + +#: src/seedsigner/views/tools_views.py +msgid "New seed" +msgstr "" + +#: src/seedsigner/views/tools_views.py +msgid "Calc 12th/24th word" +msgstr "" + +#: src/seedsigner/views/tools_views.py src/seedsigner/views/view.py +msgid "Tools" +msgstr "" + +#: src/seedsigner/views/tools_views.py +msgid "12 words" +msgstr "" + +#: src/seedsigner/views/tools_views.py +msgid "24 words" +msgstr "" + +#: src/seedsigner/views/tools_views.py +msgid "Mnemonic Length?" +msgstr "" + +#. Inserts the number of dice rolls needed for a 12-word mnemonic +#: src/seedsigner/views/tools_views.py +msgid "12 words ({} rolls)" +msgstr "" + +#. Inserts the number of dice rolls needed for a 24-word mnemonic +#: src/seedsigner/views/tools_views.py +msgid "24 words ({} rolls)" +msgstr "" + +#: src/seedsigner/views/tools_views.py +msgid "Mnemonic Length" +msgstr "" + +#. Label to gather entropy through coin tosses +#: src/seedsigner/views/tools_views.py +msgid "Coin flip entropy" +msgstr "" + +#. Label to gather entropy through user specified BIP-39 word +#: src/seedsigner/views/tools_views.py +msgid "Word selection entropy" +msgstr "" + +#. Label to allow user to default entropy as all-zeros +#: src/seedsigner/views/tools_views.py +msgid "Finalize with zeros" +msgstr "" + +#. label to calculate the last word of a BIP-39 mnemonic seed phrase +#: src/seedsigner/views/tools_views.py +msgid "Final Word Calc" +msgstr "" + +#: src/seedsigner/views/tools_views.py +msgid "Load seed" +msgstr "" + +#: src/seedsigner/views/tools_views.py +msgid "Scan wallet descriptor" +msgstr "" + +#. label for addresses where others send us incoming payments +#: src/seedsigner/views/tools_views.py +msgid "Receive Addresses" +msgstr "" + +#. label for addresses that collect the change from our own outgoing payments +#: src/seedsigner/views/tools_views.py +msgid "Change Addresses" +msgstr "" + +#. a status message that our payment addresses are being calculated +#: src/seedsigner/views/tools_views.py +msgid "Calculating addrs..." +msgstr "" + +#: src/seedsigner/views/tools_views.py +msgid "Custom Derivation address explorer not yet implemented" +msgstr "" + +#: src/seedsigner/views/tools_views.py +msgid "Single sig descriptors not yet supported" +msgstr "" + +#. Insert the number of addrs displayed per screen (e.g. "Next 10") +#: src/seedsigner/views/tools_views.py +msgid "Next {}" +msgstr "" + +#: src/seedsigner/views/tools_views.py +msgid "Receive Addrs" +msgstr "" + +#: src/seedsigner/views/tools_views.py +msgid "Change Addrs" +msgstr "" + +#: src/seedsigner/views/view.py +msgid "Scan" +msgstr "" + +#: src/seedsigner/views/view.py +msgid "Seeds" +msgstr "" + +#: src/seedsigner/views/view.py +msgid "Restart" +msgstr "" + +#: src/seedsigner/views/view.py +msgid "Power Off" +msgstr "" + +#: src/seedsigner/views/view.py +msgid "Reset / Power" +msgstr "" + +#: src/seedsigner/views/view.py +msgid "This is still on our to-do list!" +msgstr "" + +#: src/seedsigner/views/view.py +msgid "Work In Progress" +msgstr "" + +#: src/seedsigner/views/view.py +msgid "Not Yet Implemented" +msgstr "" + +#: src/seedsigner/views/view.py +msgid "Back to Main Menu" +msgstr "" + +#: src/seedsigner/views/view.py +msgid "Network Mismatch" +msgstr "" + +#: src/seedsigner/views/view.py +msgid "Change Setting" +msgstr "" + +#. Inserts mainnet/testnet/regtest and derivation path +#: src/seedsigner/views/view.py +msgid "Current network setting ({}) doesn't match {}." +msgstr "" + +#: src/seedsigner/views/view.py +msgid "System Error" +msgstr "" + +#: src/seedsigner/views/view.py +msgid "Update Setting" +msgstr "" + +#. Inserts the name of a settings option (e.g. "Persistent Settings" is +#. currently...) +#: src/seedsigner/views/view.py +msgid "\"{}\" is currently disabled in Settings." +msgstr "" + +#: src/seedsigner/views/view.py +msgid "Option Disabled" +msgstr "" + diff --git a/l10n/requirements-l10n.txt b/l10n/requirements-l10n.txt new file mode 100644 index 000000000..b83c7a02c --- /dev/null +++ b/l10n/requirements-l10n.txt @@ -0,0 +1 @@ +Babel==2.16.0 diff --git a/pyproject.toml b/pyproject.toml index 25345e0da..84757b949 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ description = "Build an offline, airgapped Bitcoin signing device for less than name = "seedsigner" readme = "README.md" requires-python = ">=3.10" -version = "0.8.0" +version = "0.8.5-rc1" [project.urls] "Bug Tracker" = "https://github.com/SeedSigner/seedsigner/issues" @@ -47,13 +47,12 @@ exclude_lines = [ omit = [ "*/__init__.py", "*/tests/*", - "*/pyzbar/*", - "*/gui/*" ] -skip_covered = true +skip_covered = false skip_empty = true [tool.coverage.run] +source = ["src"] branch = true [tool.pytest.ini_options] diff --git a/seedsigner-screenshots b/seedsigner-screenshots new file mode 160000 index 000000000..23cf46e14 --- /dev/null +++ b/seedsigner-screenshots @@ -0,0 +1 @@ +Subproject commit 23cf46e1482b49ed429b9ff31d29457af3ea07be diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 000000000..d2b328227 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,12 @@ +[extract_messages] +keywords = _ _mft mark_for_translation ButtonOption +add_comments = TRANSLATOR_NOTE: +strip_comments = yes +add_location = file +input_dirs = src +output_file = l10n/messages.pot + + +[compile_catalog] +use_fuzzy = yes +directory = src/seedsigner/resources/seedsigner-translations/l10n diff --git a/setup.py b/setup.py new file mode 100644 index 000000000..1dff9efbc --- /dev/null +++ b/setup.py @@ -0,0 +1,8 @@ +""" +Empty setup.py necessary for the python-babel integration (e.g. python setup.py extract_messages). + +See the configuration in setup.cfg. +""" +import setuptools + +setuptools.setup() \ No newline at end of file diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 5ac33d3a5..5d34fa29e 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -99,7 +99,7 @@ class Controller(Singleton): rather than at the top in order avoid circular imports. """ - VERSION = "0.8.0" + VERSION = "0.8.5-rc1" # Declare class member vars with type hints to enable richer IDE support throughout # the code. @@ -147,20 +147,20 @@ def get_instance(cls): else: # Instantiate the one and only Controller instance return cls.configure_instance() - + @classmethod - def configure_instance(cls, disable_hardware=False): + def reset_instance(cls): + """ + Currently used by the screenshot generator, but could potentially be used to + wipe and reset the state of the device. """ - - `disable_hardware` is only meant to be used by the test suite so that it - can keep re-initializing a Controller in however many tests it needs to. But - this is only possible if the hardware isn't already being reserved. Without - this you get: + cls._instance = None + cls.configure_instance() - RuntimeError: Conflicting edge detection already enabled for this GPIO channel - each time you try to re-initialize a Controller. - """ + @classmethod + def configure_instance(cls): from seedsigner.gui.renderer import Renderer from seedsigner.hardware.microsd import MicroSD @@ -248,10 +248,10 @@ def start(self, initial_destination: Destination = None) -> None: used. Only used by the test suite. """ from seedsigner.views import MainMenuView, BackStackView - from seedsigner.views.screensaver import OpeningSplashScreen + from seedsigner.views.screensaver import OpeningSplashView from seedsigner.gui.toast import RemoveSDCardToastManagerThread - OpeningSplashScreen().start() + OpeningSplashView().run() """ Class references can be stored as variables in python! diff --git a/src/seedsigner/gui/components.py b/src/seedsigner/gui/components.py index 9193e451b..00edb498e 100644 --- a/src/seedsigner/gui/components.py +++ b/src/seedsigner/gui/components.py @@ -3,20 +3,24 @@ import os import pathlib import re -from time import time +import time from dataclasses import dataclass from decimal import Decimal +from gettext import gettext as _ from PIL import Image, ImageDraw, ImageFont, ImageFilter -from typing import List, Tuple +from typing import Any, List, Tuple +from seedsigner.gui.renderer import Renderer from seedsigner.models.settings import Settings from seedsigner.models.settings_definition import SettingsConstants from seedsigner.models.singleton import Singleton +from seedsigner.models.threads import BaseThread logger = logging.getLogger(__name__) + # TODO: Remove all pixel hard coding class GUIConstants: EDGE_PADDING = 8 @@ -43,14 +47,33 @@ class GUIConstants: ICON_TOAST_FONT_SIZE = 30 ICON_PRIMARY_SCREEN_SIZE = 50 - TOP_NAV_TITLE_FONT_NAME = "OpenSans-SemiBold" - TOP_NAV_TITLE_FONT_SIZE = 20 + TOP_NAV_TITLE_FONT_NAME = { + "default": "OpenSans-SemiBold", + # "ar": "multilanguage/NotoSansAR-Regular", + # "he": "multilanguage/NotoSansHE-Regular", + # "ja": "multilanguage/NotoSansJP-Regular", + # "kr": "multilanguage/NotoSansKR-Regular", + # "ru": "multilanguage/NotoSans-Regular", + } + TOP_NAV_TITLE_FONT_SIZE = { + "default": 20, + } TOP_NAV_HEIGHT = 48 TOP_NAV_BUTTON_SIZE = 32 - BODY_FONT_NAME = "OpenSans-Regular" - BODY_FONT_SIZE = 17 - BODY_FONT_MAX_SIZE = TOP_NAV_TITLE_FONT_SIZE + BODY_FONT_NAME = { + "default": "OpenSans-Regular", + # "ar": "multilanguage/NotoSansAR-Regular", + # "he": "multilanguage/NotoSansHE-Regular", + # "ja": "multilanguage/NotoSansJP-Regular", + # "kr": "multilanguage/NotoSansKR-Regular", + # "ru": "multilanguage/NotoSans-Regular", + } + BODY_FONT_SIZE = { + "default": 17, + # "ar": 16, + } + BODY_FONT_MAX_SIZE = TOP_NAV_TITLE_FONT_SIZE["default"] BODY_FONT_MIN_SIZE = 15 BODY_FONT_COLOR = "#FCFCFC" BODY_LINE_SPACING = COMPONENT_PADDING @@ -61,8 +84,19 @@ class GUIConstants: LABEL_FONT_SIZE = BODY_FONT_MIN_SIZE LABEL_FONT_COLOR = "#777777" - BUTTON_FONT_NAME = "OpenSans-SemiBold" - BUTTON_FONT_SIZE = 18 + BUTTON_FONT_NAME = { + "default": "OpenSans-SemiBold", + # "ar": "multilanguage/NotoSansAR-Regular", + # "he": "multilanguage/NotoSansHE-Regular", + # "ja": "multilanguage/NotoSansJP-Regular", + # "kr": "multilanguage/NotoSansKR-Regular", + # "ru": "multilanguage/NotoSans-Regular", + } + BUTTON_FONT_SIZE = { + "default": 18, + # "ar": 16, + # "ja": 16, + } BUTTON_FONT_COLOR = "#FCFCFC" BUTTON_BACKGROUND_COLOR = "#2C2C2C" BUTTON_HEIGHT = 32 @@ -71,6 +105,60 @@ class GUIConstants: NOTIFICATION_COLOR = "#00F100" + @staticmethod + def get_body_font_name(): + locale = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE) + if locale in GUIConstants.BODY_FONT_NAME: + return GUIConstants.BODY_FONT_NAME[locale] + else: + return GUIConstants.BODY_FONT_NAME["default"] + + + @staticmethod + def get_body_font_size(): + locale = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE) + if locale in GUIConstants.BODY_FONT_SIZE: + return GUIConstants.BODY_FONT_SIZE[locale] + else: + return GUIConstants.BODY_FONT_SIZE["default"] + + + @staticmethod + def get_top_nav_title_font_name(): + locale = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE) + if locale in GUIConstants.TOP_NAV_TITLE_FONT_NAME: + return GUIConstants.TOP_NAV_TITLE_FONT_NAME[locale] + else: + return GUIConstants.TOP_NAV_TITLE_FONT_NAME["default"] + + + @staticmethod + def get_top_nav_title_font_size(): + locale = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE) + if locale in GUIConstants.TOP_NAV_TITLE_FONT_SIZE: + return GUIConstants.TOP_NAV_TITLE_FONT_SIZE[locale] + else: + return GUIConstants.TOP_NAV_TITLE_FONT_SIZE["default"] + + + @staticmethod + def get_button_font_name(): + locale = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE) + if locale in GUIConstants.BUTTON_FONT_NAME: + return GUIConstants.BUTTON_FONT_NAME[locale] + else: + return GUIConstants.BUTTON_FONT_NAME["default"] + + + @staticmethod + def get_button_font_size(): + locale = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE) + if locale in GUIConstants.BUTTON_FONT_SIZE: + return GUIConstants.BUTTON_FONT_SIZE[locale] + else: + return GUIConstants.BUTTON_FONT_SIZE["default"] + + class FontAwesomeIconConstants: ANGLE_DOWN = "\uf107" @@ -102,6 +190,7 @@ class FontAwesomeIconConstants: X = "\u0058" + class SeedSignerIconConstants: # Menu icons SCAN = "\ue900" @@ -203,7 +292,11 @@ def load_image(image_name: str) -> Image.Image: class Fonts(Singleton): - font_path = os.path.join(pathlib.Path(__file__).parent.resolve(), "..", "resources", "fonts") + font_path = os.path.join( + pathlib.Path(__file__).parent.resolve().parent.resolve(), + "resources", + "fonts" + ) fonts = {} @classmethod @@ -214,7 +307,7 @@ def get_font(cls, font_name, size, file_extension: str = "ttf") -> ImageFont.Fre if font_name in [GUIConstants.ICON_FONT_NAME__FONT_AWESOME, GUIConstants.ICON_FONT_NAME__SEEDSIGNER]: file_extension = "otf" - + if size not in cls.fonts[font_name]: try: cls.fonts[font_name][size] = ImageFont.truetype(os.path.join(cls.font_path, f"{font_name}.{file_extension}"), size) @@ -239,11 +332,14 @@ class BaseComponent: canvas: Image.Image = None def __post_init__(self): - from seedsigner.gui import Renderer + from seedsigner.gui.renderer import Renderer self.renderer: Renderer = Renderer.get_instance() self.canvas_width = self.renderer.canvas_width self.canvas_height = self.renderer.canvas_height + # Component threads will be managed in their parent's `Screen.display()` + self.threads: list[BaseThread] = [] + if not self.image_draw: self.set_image_draw(self.renderer.draw) @@ -254,6 +350,7 @@ def __post_init__(self): def set_image_draw(self, image_draw: ImageDraw): self.image_draw = image_draw + def set_canvas(self, canvas: Image): self.canvas = canvas @@ -282,20 +379,37 @@ class TextArea(BaseComponent): height: int = None # None = special case: autosize to min height screen_x: int = 0 screen_y: int = 0 + scroll_y: int = 0 min_text_x: int = 0 # Text can not start at x any less than this background_color: str = GUIConstants.BACKGROUND_COLOR - font_name: str = GUIConstants.BODY_FONT_NAME - font_size: int = GUIConstants.BODY_FONT_SIZE + font_name: str = None + font_size: int = None font_color: str = GUIConstants.BODY_FONT_COLOR edge_padding: int = GUIConstants.EDGE_PADDING is_text_centered: bool = True - supersampling_factor: int = 1 + supersampling_factor: int = 2 # 1 = disabled; 2 = default, double sample (4px square rendered for 1px) auto_line_break: bool = True allow_text_overflow: bool = False + is_horizontal_scrolling_enabled: bool = False + horizontal_scroll_speed: int = 40 # px per sec + horizontal_scroll_begin_hold_secs: float = 2.0 + horizontal_scroll_end_hold_secs: float = 1.0 height_ignores_below_baseline: bool = False # If True, characters that render below the baseline (e.g. "pqgy") will not affect the final height calculation def __post_init__(self): + if self.is_horizontal_scrolling_enabled and self.auto_line_break: + raise Exception("TextArea: Cannot have auto_line_break and horizontal scrolling enabled at the same time") + + if self.is_horizontal_scrolling_enabled and not self.allow_text_overflow: + self.allow_text_overflow = True + logger.warning("TextArea: allow_text_overflow gets overridden to True when horizontal scrolling is enabled") + + if not self.font_name: + self.font_name = GUIConstants.get_body_font_name() + if not self.font_size: + self.font_size = GUIConstants.get_body_font_size() + super().__post_init__() if not self.width: @@ -306,34 +420,53 @@ def __post_init__(self): self.line_spacing = GUIConstants.BODY_LINE_SPACING - # We have to figure out if and where to make line breaks in the text so that it - # fits in its bounding rect (plus accounting for edge padding) using its given - # font. - # Do initial calcs without worrying about supersampling. - self.text_lines = reflow_text_for_width( - text=self.text, - width=self.width - 2*self.edge_padding, - font_name=self.font_name, - font_size=self.font_size, - allow_text_overflow=self.allow_text_overflow, - ) - # Calculate the actual font height from the "baseline" anchor ("_s") font = Fonts.get_font(self.font_name, self.font_size) # Note: from the baseline anchor, `top` is a negative number while `bottom` # conveys the height of the pixels that rendered below the baseline, if any # (e.g. "py" in "python"). - (left, top, right, bottom) = font.getbbox(self.text, anchor="ls") + (left, top, full_text_width, bottom) = font.getbbox(self.text, anchor="ls") self.text_height_above_baseline = -1 * top self.text_height_below_baseline = bottom # Initialize the text rendering relative to the baseline self.text_y = self.text_height_above_baseline - # Other components, like IconTextLine will need to know how wide the actual - # rendered text will be, separate from the TextArea's defined overall `width`. - self.text_width = max(line["text_width"] for line in self.text_lines) + self.visible_width = self.width - max(self.edge_padding, self.min_text_x) - self.edge_padding + if not ImageFont.core.HAVE_RAQM: + # Fudge factor for imprecise width calcs w/out libraqm + full_text_width = int(full_text_width * 1.05) + self.visible_width = int(self.visible_width * 1.05) + + if self.is_horizontal_scrolling_enabled or not self.auto_line_break: + # Guaranteed to be a single line of text, possibly wider than self.width + self.text_lines = [{"text": self.text, "text_width": full_text_width}] + self.text_width = full_text_width + if self.text_width > self.visible_width: + # We'll have to left justify the text and scroll it (if scrolling is enabled, + # otherwise it'll just run off the right edge). + self.is_text_centered = False + else: + # The text fits; horizontal scrolling isn't needed + self.is_horizontal_scrolling_enabled = False + + else: + # We have to figure out if and where to make line breaks in the text so that it + # fits in its bounding rect (plus accounting for edge padding) using its given + # font. + # Do initial calcs without worrying about supersampling. + self.text_lines = reflow_text_for_width( + text=self.text, + width=self.width - 2*self.edge_padding, + font_name=self.font_name, + font_size=self.font_size, + allow_text_overflow=self.allow_text_overflow, + ) + + # Other components, like IconTextLine will need to know how wide the actual + # rendered text will be, separate from the TextArea's defined overall `width`. + self.text_width = max(line["text_width"] for line in self.text_lines) # Calculate the actual height if len(self.text_lines) == 1: @@ -348,6 +481,7 @@ def __post_init__(self): # Last line has at least one char that dips below baseline total_text_height += self.text_height_below_baseline + self.text_offset_y = 0 if self.height is None: # Autoscale height to text lines self.height = total_text_height @@ -355,53 +489,78 @@ def __post_init__(self): else: if total_text_height > self.height: if not self.allow_text_overflow: - raise TextDoesNotFitException(f"Text cannot fit in target rect with this font/size\n\ttotal_text_height: {total_text_height} | self.height: {self.height}") + # For now, early into the l10n rollout, we can't enforce strict + # conformance here. Too many screens will just break if this is were + # to raise an exception. + logger.warning(f"Text cannot fit in target rect with this font/size\n\ttotal_text_height: {total_text_height} | self.height: {self.height}") else: - # Just let it render off the edge, but preserve the top portion + # Just let it render past the bottom edge pass else: # Vertically center the text's starting point - self.text_y += int(self.height - total_text_height)/2 - + self.text_offset_y = int((self.height - total_text_height)/2) + self.text_y += self.text_offset_y # (relative to text rendering baseline) - def render(self): # Render to a temp img scaled up by self.supersampling_factor, then resize down # with bicubic resampling. # Add a `resample_padding` above and below when supersampling to avoid edge # effects (resized text that's right up against the top/bottom gets slightly # dimmer at the edge otherwise). - if self.font_size < 20 and (not self.supersampling_factor or self.supersampling_factor == 1): - self.supersampling_factor = 2 + # if self.background_color == GUIConstants.ACCENT_COLOR and self.supersampling_factor == 1: + # # Don't boost supersampling factor. Text against the accent color does not + # # render well when supersampled. + # pass + # elif self.font_size < 20 and (not self.supersampling_factor or self.supersampling_factor == 1): + # self.supersampling_factor = 2 + if self.font_size >= 20 and self.supersampling_factor != 1: + self.supersampling_factor = 1 + logger.warning(f"Supersampling disabled for large font size: {self.font_size}") - actual_text_height = self.height if self.height_ignores_below_baseline: # Even though we're ignoring the pixels below the baseline for spacing # purposes, we have to make sure we don't crop those pixels out during the # supersampling operations here. - actual_text_height += self.text_height_below_baseline + total_text_height += self.text_height_below_baseline + + resample_padding = 10 if self.supersampling_factor > 1 else 0 + + if self.is_horizontal_scrolling_enabled: + # Temp img will be the full width of the text + image_width = self.text_width + else: + # Temp img will be the component's width, but must respect right edge padding + image_width = self.width - self.edge_padding + + if self.supersampling_factor > 1: + start = time.time() + supersampled_font = Fonts.get_font(self.font_name, int(self.supersampling_factor * self.font_size)) + print(f"Supersampled font load time: {time.time() - start:.04}") + else: + supersampled_font = font - resample_padding = 10 if self.supersampling_factor > 1.0 else 0 img = Image.new( "RGBA", ( - self.width * self.supersampling_factor, - (actual_text_height + 2*resample_padding) * self.supersampling_factor + image_width * self.supersampling_factor, + (total_text_height + 2*resample_padding) * self.supersampling_factor ), self.background_color + # "red" ) draw = ImageDraw.Draw(img) - cur_y = (self.text_y + resample_padding) * self.supersampling_factor - supersampled_font = Fonts.get_font(self.font_name, int(self.supersampling_factor * self.font_size)) + cur_y = (resample_padding + self.text_height_above_baseline) * self.supersampling_factor if self.is_text_centered: + # middle baseline anchor = "ms" else: + # left baseline anchor = "ls" - # Position where we'll render each line of text - text_x = self.edge_padding + # Default, not-centered text will be relative to its left-justified starting point + text_x = max([self.edge_padding, self.min_text_x]) for line in self.text_lines: if self.is_text_centered: @@ -410,23 +569,197 @@ def render(self): if text_x - int(line["text_width"]/2) < self.min_text_x: # The left edge of the centered text will protrude too far; nudge it right text_x = self.min_text_x + int(line["text_width"]/2) + + elif self.is_horizontal_scrolling_enabled: + # Scrolling temp img isn't relative to any positioning other than its own text + text_x = 0 draw.text((text_x * self.supersampling_factor, cur_y), line["text"], fill=self.font_color, font=supersampled_font, anchor=anchor) # Debugging: show the exact vertical extents of each line of text - # draw.line((0, cur_y - self.text_height_above_baseline * self.supersampling_factor, self.width * self.supersampling_factor, cur_y - self.text_height_above_baseline * self.supersampling_factor), fill="red", width=int(self.supersampling_factor)) - # draw.line((0, cur_y, self.width * self.supersampling_factor, cur_y), fill="red", width=int(self.supersampling_factor)) + # draw.line((0, cur_y - self.text_height_above_baseline * self.supersampling_factor, image_width * self.supersampling_factor, cur_y - self.text_height_above_baseline * self.supersampling_factor), fill="blue", width=int(self.supersampling_factor)) + # draw.line((0, cur_y, image_width * self.supersampling_factor, cur_y), fill="red", width=int(self.supersampling_factor)) cur_y += (self.text_height_above_baseline + self.line_spacing) * self.supersampling_factor # Crop off the top_padding and resize the result down to onscreen size - if self.supersampling_factor > 1.0: - resized = img.resize((self.width, actual_text_height + 2*resample_padding), Image.LANCZOS) + if self.supersampling_factor > 1: + resized = img.resize((image_width, total_text_height + 2*resample_padding), Image.LANCZOS) sharpened = resized.filter(ImageFilter.SHARPEN) - # Crop args are actually (left, top, WIDTH, HEIGHT) - img = sharpened.crop((0, resample_padding, self.width, actual_text_height + resample_padding)) - self.canvas.paste(img, (self.screen_x, self.screen_y)) + img = sharpened.crop((0, resample_padding, image_width, resample_padding + total_text_height)) + + self.rendered_text_img = img + + if not ImageFont.core.HAVE_RAQM: + # At this point we need the visible_width to be the "actual" (yet still incorrect) width + self.visible_width = int(self.visible_width * 0.95) + + self.horizontal_text_scroll_thread: TextArea.HorizontalTextScrollThread = None + if self.is_horizontal_scrolling_enabled: + self.horizontal_text_scroll_thread = TextArea.HorizontalTextScrollThread( + rendered_text_img=self.rendered_text_img, + screen_x=self.screen_x + self.min_text_x, + screen_y=self.screen_y + self.text_y - self.text_height_above_baseline, + visible_width=self.visible_width, + horizontal_scroll_speed=self.horizontal_scroll_speed, + begin_hold_secs=self.horizontal_scroll_begin_hold_secs, + end_hold_secs=self.horizontal_scroll_end_hold_secs + ) + + + class HorizontalTextScrollThread(BaseThread): + """ + Note that Components in general should not try to manage the Renderer.lock; we + leave that up to the calling Screen to manage. HOWEVER, since this renders in its + own thread, we lose all normal timing guarantees and therefore this thread must + manage the lock itself. + """ + def __init__(self, rendered_text_img: Image, screen_x: int, screen_y: int, visible_width: int, horizontal_scroll_speed:int, begin_hold_secs: float, end_hold_secs: float): + super().__init__() + self.rendered_text_img = rendered_text_img + self.screen_x = screen_x + self.screen_y = screen_y + self.visible_width = visible_width + self.horizontal_scroll_speed = horizontal_scroll_speed + self.begin_hold_secs = begin_hold_secs + self.end_hold_secs = end_hold_secs + + self.scroll_y = 0 + self.scrolling_active = True + self.horizontal_scroll_position = 0 + self.scroll_increment_sign = 1 # flip to negative to scroll text to the right + + self.renderer = Renderer.get_instance() + + + def stop_scrolling(self): + self.scrolling_active = False + + + def start_scrolling(self): + # Reset scroll position to left edge + self.horizontal_scroll_position = 0 + self.scroll_increment_sign = 1 + self.scrolling_active = True + + + def run(self): + """ + Subjective opinion: on a Pi Zero, scrolling at 40px/sec looks smooth but + 50px/sec creates a slight ghosting / doubling effect that impedes + readability. 45px/sec is better but still perceptually a bit stuttery. + """ + max_scroll = self.rendered_text_img.width - self.visible_width + + while self.keep_running: + if not self.scrolling_active: + time.sleep(0.1) + continue + + with self.renderer.lock: + if not self.scrolling_active: + # We were stopped while waiting for the lock + continue + + img = self.rendered_text_img.crop((self.horizontal_scroll_position, 0, self.horizontal_scroll_position + self.visible_width, self.rendered_text_img.height)) + self.renderer.canvas.paste(img, (self.screen_x, self.screen_y - self.scroll_y)) + self.renderer.show_image() + + if self.horizontal_scroll_position == 0: + # Pause on initial (left-justified) position... + time.sleep(self.begin_hold_secs) + + # Don't count those pause seconds + last_render_time = None + + # Scroll the text left + self.scroll_increment_sign = 1 + + elif self.horizontal_scroll_position == max_scroll: + # ...and slight pause at end of scroll + time.sleep(self.end_hold_secs) + + # Don't count those pause seconds + last_render_time = None + + # Scroll the text right + self.scroll_increment_sign = -1 + + else: + # No need to CPU limit when running in its own thread? + time.sleep(0.02) + + next_render_time = time.time() + + if not last_render_time: + # First frame when pulling off either end will move 1 pixel; have to + # "get off zero" for the real increment calc logic to kick in. + scroll_position_increment = 1 * self.scroll_increment_sign + else: + scroll_position_increment = int(self.horizontal_scroll_speed * (next_render_time - last_render_time) * self.scroll_increment_sign) + + if abs(scroll_position_increment) > 0: + self.horizontal_scroll_position += scroll_position_increment + self.horizontal_scroll_position = max(0, min(self.horizontal_scroll_position, max_scroll)) + + last_render_time = next_render_time + else: + # Wait to accumulate more time before scrolling + pass + + + def render(self): + """ + Even if we need to animate for scrolling, all instances should explicitly render + their initial state. Note that the screenshot generator currently does not render + anything from within child threads. + """ + # Default text and centered text already include any edge padding considerations + # in their rendered img that we're about to paste onto the canvas. + text_x = self.screen_x + text_img = self.rendered_text_img + if self.is_horizontal_scrolling_enabled: + # Scrolling text has no edge considerations so must be placed exactly + text_x += self.min_text_x + + # Must also account for the right edge running off our visible width + text_img = text_img.crop((0, 0, self.visible_width, text_img.height)) + + self.canvas.paste(text_img, (text_x, self.screen_y + self.text_y - self.text_height_above_baseline - self.scroll_y)) + + + def set_scroll_y(self, scroll_y: int): + """ Used by ButtonListScreen """ + self.scroll_y = scroll_y + if self.horizontal_text_scroll_thread: + self.horizontal_text_scroll_thread.scroll_y = scroll_y + + + +@dataclass +class ScrollableTextLine(TextArea): + """ + Convenience class to more clearly communicate usage intention: + * A single line of text + * Can be centered (e.g. TopNav title) + * Will automatically scroll if it does not fit the specified width + """ + def __post_init__(self): + self.auto_line_break = False + self.is_horizontal_scrolling_enabled = True + self.allow_text_overflow = True + super().__post_init__() + + + @property + def needs_scroll(self) -> bool: + return self.horizontal_text_scroll_thread is not None + + + @property + def scroll_thread(self) -> TextArea.HorizontalTextScrollThread: + return self.horizontal_text_scroll_thread @@ -444,7 +777,7 @@ def __post_init__(self): if SeedSignerIconConstants.MIN_VALUE <= self.icon_name and self.icon_name <= SeedSignerIconConstants.MAX_VALUE: self.icon_font = Fonts.get_font(GUIConstants.ICON_FONT_NAME__SEEDSIGNER, self.icon_size, file_extension="otf") else: - self.icon_font = Fonts.get_font(GUIConstants.ICON_FONT_NAME__FONT_AWESOME, self.icon_size, file_extension="otf") + self.icon_font = Fonts.get_font(GUIConstants.ICON_FONT_NAME__FONT_AWESOME, self.icon_size) # Set width/height based on exact pixels that are rendered (left, top, self.width, bottom) = self.icon_font.getbbox(self.icon_name, anchor="ls") @@ -472,16 +805,20 @@ class IconTextLine(BaseComponent): icon_size: int = GUIConstants.ICON_FONT_SIZE icon_color: str = GUIConstants.BODY_FONT_COLOR label_text: str = None - value_text: str = "73c5da0a" - font_name: str = GUIConstants.BODY_FONT_NAME - font_size: int = GUIConstants.BODY_FONT_SIZE + value_text: str = "" + font_name: str = None + font_size: int = None is_text_centered: bool = False auto_line_break: bool = False - allow_text_overflow: bool = False + allow_text_overflow: bool = True screen_x: int = 0 screen_y: int = 0 def __post_init__(self): + if not self.font_name: + self.font_name = GUIConstants.get_body_font_name() + if not self.font_size: + self.font_size = GUIConstants.get_body_font_size() super().__post_init__() if self.height is not None and self.label_text: @@ -509,7 +846,7 @@ def __post_init__(self): image_draw=self.image_draw, canvas=self.canvas, text=self.label_text, - font_size=GUIConstants.BODY_FONT_SIZE - 2, + font_size=GUIConstants.get_body_font_size() - 2, font_color=GUIConstants.LABEL_FONT_COLOR, edge_padding=0, is_text_centered=self.is_text_centered if not self.icon_name else False, @@ -774,11 +1111,14 @@ def __post_init__(self): denomination = Settings.get_instance().get_value(SettingsConstants.SETTING__BTC_DENOMINATION) network = Settings.get_instance().get_value(SettingsConstants.SETTING__NETWORK) - btc_unit = "tBtc" - sats_unit = "tSats" + # TRANSLATOR_NOTE: Testnet bitcoin + btc_unit = _("tBtc") + + # TRANSLATOR_NOTE: Testnet sats + sats_unit = _("tSats") if network == SettingsConstants.MAINNET: - btc_unit = "btc" - sats_unit = "sats" + btc_unit = _("btc") + sats_unit = _("sats") btc_color = GUIConstants.ACCENT_COLOR elif network == SettingsConstants.TESTNET: @@ -787,9 +1127,9 @@ def __post_init__(self): elif network == SettingsConstants.REGTEST: btc_color = GUIConstants.REGTEST_COLOR - digit_font = Fonts.get_font(font_name=GUIConstants.BODY_FONT_NAME, size=self.font_size) - smaller_digit_font = Fonts.get_font(font_name=GUIConstants.BODY_FONT_NAME, size=self.font_size - 2) - unit_font_size = GUIConstants.BUTTON_FONT_SIZE + 2 + digit_font = Fonts.get_font(font_name=GUIConstants.get_body_font_name(), size=self.font_size) + smaller_digit_font = Fonts.get_font(font_name=GUIConstants.get_body_font_name(), size=self.font_size - 2) + unit_font_size = GUIConstants.get_button_font_size() + 2 # Render to a temp surface self.paste_image = Image.new(mode="RGB", size=(self.canvas_width, self.icon_size), color=GUIConstants.BACKGROUND_COLOR) @@ -913,7 +1253,7 @@ def __post_init__(self): cur_x += text_width - int(GUIConstants.COMPONENT_PADDING/2) # Draw the pipe separator - pipe_font = Fonts.get_font(font_name=GUIConstants.BODY_FONT_NAME, size=self.icon_size - 4) + pipe_font = Fonts.get_font(font_name=GUIConstants.get_body_font_name(), size=self.icon_size - 4) (left, top, text_width, bottom) = pipe_font.getbbox("|", anchor="ls") draw.text( xy=( @@ -944,7 +1284,7 @@ def __post_init__(self): unit_text = sats_unit # Draw the unit - unit_font = Fonts.get_font(font_name=GUIConstants.BODY_FONT_NAME, size=unit_font_size) + unit_font = Fonts.get_font(font_name=GUIConstants.get_body_font_name(), size=unit_font_size) (left, top, unit_text_width, bottom) = unit_font.getbbox(unit_text, anchor="ls") unit_font_height = -1 * top @@ -952,7 +1292,7 @@ def __post_init__(self): image_draw=draw, canvas=self.paste_image, text=f" {unit_text}", - font_name=GUIConstants.BODY_FONT_NAME, + font_name=GUIConstants.get_body_font_name(), font_size=unit_font_size, font_color=GUIConstants.BODY_FONT_COLOR, supersampling_factor=2, @@ -982,12 +1322,29 @@ def render(self): @dataclass class Button(BaseComponent): - # TODO: Rename the seedsigner.helpers.Buttons class (to Inputs?) to reduce confusion - # with this GUI component. """ - Attrs with defaults must be listed last. + Buttons offer two rendering methods: + + * Reusable in-memory image (is_scrollable_text = True; default): For both active and + inactive states, the text is rendered once (on a just-in-time basis) into an + in-memory image that is then reused as needed during the life of the Component. + + Specifically built with l10n in mind. Will automatically add scrolling via + ScrollableTextLine for the Button's active state when necessary; a static + TextArea is used otherwise. + + This means that this setting is not suitable for Buttons whose + text label needs to interactively change (e.g. the "ABC" vs "abc" soft keys in + the passphrase entry Keyboard). + + * Real-time text (is_scrollable_text = False): The label text's active/inactive state + is just rendered as basic text on-the-fly, so it can support uses where the button + label can change. Text scrolling is not supported in this mode so in general it + should not to used with l10n content whose length might vary by language. + """ text: str = "Button Label" + active_text: str = None # Optional alt text to replace the button label when the button is selected screen_x: int = 0 screen_y: int = 0 scroll_y: int = 0 @@ -1005,17 +1362,24 @@ class Button(BaseComponent): text_y_offset: int = 0 background_color: str = GUIConstants.BUTTON_BACKGROUND_COLOR selected_color: str = GUIConstants.ACCENT_COLOR - font_name: str = GUIConstants.BUTTON_FONT_NAME - font_size: int = GUIConstants.BUTTON_FONT_SIZE + font_name: str = None + font_size: int = None font_color: str = GUIConstants.BUTTON_FONT_COLOR selected_font_color: str = GUIConstants.BUTTON_SELECTED_FONT_COLOR outline_color: str = None selected_outline_color: str = None is_text_centered: bool = True is_selected: bool = False + is_scrollable_text: bool = True # True: active state will automatically scroll if necessary, text is rendered once (not dynamic) def __post_init__(self): + if not self.font_name: + self.font_name = GUIConstants.get_button_font_name() + + if not self.font_size: + self.font_size = GUIConstants.get_button_font_size() + super().__post_init__() if not self.width: @@ -1027,16 +1391,11 @@ def __post_init__(self): if not self.icon_color: self.icon_color = GUIConstants.BUTTON_FONT_COLOR - self.font = Fonts.get_font(self.font_name, self.font_size) + self.active_button_label = None if self.text is not None: - if self.is_text_centered: - self.text_x = int(self.width/2) - self.text_anchor = "ms" # centered horizontally, baseline - else: - self.text_x = GUIConstants.COMPONENT_PADDING - self.text_anchor = "ls" # left, baseline - + self.font = Fonts.get_font(self.font_name, self.font_size) + # Calc true pixel height (any anchor from "baseline" will work) (left, top, self.text_width, bottom) = self.font.getbbox(self.text, anchor="ls") # print(f"left: {left} | top: {top} | right: {self.text_width} | bottom: {bottom}") @@ -1046,25 +1405,63 @@ def __post_init__(self): # regardless of the Button text. self.text_height = -1 * top - # TODO: Only apply screen_y at render + # Total space available just for the text (will contract later if there are icons) + self.visible_text_width = self.width - 2*GUIConstants.COMPONENT_PADDING + + if self.text_width > self.visible_text_width and not self.is_scrollable_text: + logger.warning("Button label \"{self.text}\" will not fit but is_scrollable_text is False") + + if self.is_text_centered and self.text_width < self.visible_text_width: + # self.text_x = int(self.width/2) + # self.text_anchor = "ms" # centered horizontally, baseline + + # Calculate the centered text's starting point, but relative to the "ls" + # anchor point. + self.text_x = int((self.width - self.text_width)/2) + self.text_anchor = "ls" # left, baseline + else: + # Text is left-justified or has to be because it will be scrolled + self.is_text_centered = False + self.text_x = GUIConstants.COMPONENT_PADDING + self.text_anchor = "ls" # left, baseline + if self.text_y_offset: self.text_y = self.text_y_offset + self.text_height else: self.text_y = self.height - int((self.height - self.text_height)/2) # Preload the icon and its "_selected" variant + icon_padding = GUIConstants.COMPONENT_PADDING if self.icon_name: - icon_padding = GUIConstants.COMPONENT_PADDING self.icon = Icon(icon_name=self.icon_name, icon_size=self.icon_size, icon_color=self.icon_color) self.icon_selected = Icon(icon_name=self.icon_name, icon_size=self.icon_size, icon_color=self.selected_icon_color) + if self.icon_y_offset: + self.icon_y = self.icon_y_offset + else: + self.icon_y = math.ceil((self.height - self.icon.height)/2) + if self.is_icon_inline: + self.visible_text_width -= self.icon.width + icon_padding + if self.text_width > self.visible_text_width: + self.is_text_centered = False + self.text_x = GUIConstants.COMPONENT_PADDING + + if not self.is_scrollable_text: + logger.warning("Button label \"{self.text}\" with icon inline will not fit but is_scrollable_text is False") + if self.is_text_centered: - # Shift the text's centering if self.text: + # Shift the text's center-based anchor to the right to make room + # self.text_x += int((self.icon.width + icon_padding) / 2) + + # Shift the text's "ls"-based anchor to the right to make room self.text_x += int((self.icon.width + icon_padding) / 2) - self.icon_x = self.text_x - int(self.text_width/2) - (self.icon.width + icon_padding) + + # Position the icon's left-based anchor on the left + self.icon_x = self.text_x - (self.icon.width + icon_padding) else: + # TODO: Is an inline icon but w/no text even a sensible input configuration? self.icon_x = math.ceil((self.width - self.icon.width)/2) else: @@ -1074,20 +1471,62 @@ def __post_init__(self): else: self.icon_x = int((self.width - self.icon.width) / 2) - - if self.icon_y_offset: - self.icon_y = self.icon_y_offset - else: - self.icon_y = math.ceil((self.height - self.icon.height)/2) + if self.text: + self.text_y = self.icon_y + self.icon.height + GUIConstants.COMPONENT_PADDING if self.right_icon_name: self.right_icon = Icon(icon_name=self.right_icon_name, icon_size=self.right_icon_size, icon_color=self.right_icon_color) self.right_icon_selected = Icon(icon_name=self.right_icon_name, icon_size=self.right_icon_size, icon_color=self.selected_icon_color) + self.visible_text_width -= self.right_icon.width + icon_padding + if self.text_width > self.visible_text_width: + self.is_text_centered = False + + if not self.is_scrollable_text: + logger.warning("Button label \"{self.text}\" with icon inline will not fit but is_scrollable_text is False") + self.right_icon_x = self.width - self.right_icon.width - GUIConstants.COMPONENT_PADDING self.right_icon_y = math.ceil((self.height - self.right_icon.height)/2) + if self.text and self.is_scrollable_text: + button_kwargs = dict( + text=self.active_text if self.active_text else self.text, + font_name=self.font_name, + font_size=self.font_size, + supersampling_factor=1, # disable; not necessary at button font size. Also black text on orange supersamples poorly + font_color=self.selected_font_color, + background_color=self.selected_color, + screen_x=self.screen_x, + screen_y=self.screen_y + self.text_y_offset, + width=self.width, + height=self.text_height if self.icon_name and not self.is_icon_inline else self.height, + min_text_x=self.text_x if self.icon_name and self.is_icon_inline else GUIConstants.COMPONENT_PADDING, + is_text_centered=self.is_text_centered, + height_ignores_below_baseline=True, # Consistently vertically center text, ignoring chars that render below baseline (e.g. "pqyj") + horizontal_scroll_speed=30, #px per sec + horizontal_scroll_begin_hold_secs=0.5, + horizontal_scroll_end_hold_secs=0.5, + ) + + # ButtonListScreens with lots of buttons will take too long to pre-render all + # the Buttons, so we use a just-in-time approach to create BOTH the active and + # inactive Buttons. For simple "Done" screens, the inactive state will never be + # rendered. + self.active_button_label = None + self.active_button_label_kwargs = button_kwargs.copy() + + button_kwargs["text"] = self.text + button_kwargs["font_color"] = self.font_color + button_kwargs["background_color"] = self.background_color + button_kwargs["allow_text_overflow"] = True + button_kwargs["auto_line_break"] = False + del button_kwargs["horizontal_scroll_begin_hold_secs"] + del button_kwargs["horizontal_scroll_end_hold_secs"] + + self.inactive_button_label = None + self.inactive_button_label_kwargs = button_kwargs.copy() + def render(self): if self.is_selected: @@ -1113,13 +1552,44 @@ def render(self): ) if self.text is not None: - self.image_draw.text( - (self.screen_x + self.text_x, self.screen_y + self.text_y - self.scroll_y), - self.text, - fill=font_color, - font=self.font, - anchor=self.text_anchor - ) + if not self.is_scrollable_text: + # Just directly render the text for the current active/inactive state + self.image_draw.text( + (self.screen_x + self.text_x, self.screen_y + self.text_y - self.scroll_y), + self.text, + fill=font_color, + font=self.font, + anchor=self.text_anchor + ) + + else: + # Use just-in-time instatiation of pre-rendered ScrollableTextLine and TextArea + if self.is_selected: + if not self.active_button_label: + # Just-in-time create the active button label + self.active_button_label = ScrollableTextLine(**self.active_button_label_kwargs) + + if self.active_button_label.needs_scroll: + self.threads.append(self.active_button_label.scroll_thread) + self.active_button_label.scroll_thread.start() + + self.active_button_label.set_scroll_y(self.scroll_y) + self.active_button_label.render() + + if self.active_button_label.needs_scroll: + # Activate the scrollable text line + self.active_button_label.scroll_thread.start_scrolling() + + else: + if self.active_button_label and self.active_button_label.needs_scroll: + self.active_button_label.scroll_thread.stop_scrolling() + + if not self.inactive_button_label: + # Just-in-time create the inactive button label + self.inactive_button_label = TextArea(**self.inactive_button_label_kwargs) + + self.inactive_button_label.set_scroll_y(self.scroll_y) + self.inactive_button_label.render() if self.icon_name: icon = self.icon @@ -1182,6 +1652,7 @@ class IconButton(Button): text: str = None is_icon_inline: bool = False is_text_centered: bool = True + is_scrollable_text: bool = False @@ -1193,6 +1664,7 @@ class LargeIconButton(IconButton): """ icon_size: int = GUIConstants.ICON_LARGE_BUTTON_SIZE icon_y_offset: int = GUIConstants.COMPONENT_PADDING + is_scrollable_text: bool = True @@ -1204,8 +1676,8 @@ class TopNav(BaseComponent): background_color: str = GUIConstants.BACKGROUND_COLOR icon_name: str = None icon_color: str = GUIConstants.BODY_FONT_COLOR - font_name: str = GUIConstants.TOP_NAV_TITLE_FONT_NAME - font_size: int = GUIConstants.TOP_NAV_TITLE_FONT_SIZE + font_name: str = GUIConstants.get_top_nav_title_font_name() + font_size: int = GUIConstants.get_top_nav_title_font_size() font_color: str = GUIConstants.BODY_FONT_COLOR show_back_button: bool = True show_power_button: bool = False @@ -1213,12 +1685,17 @@ class TopNav(BaseComponent): def __post_init__(self): + if not self.font_name: + self.font_name = GUIConstants.get_top_nav_title_font_name() + + if not self.font_size: + self.font_size = GUIConstants.get_top_nav_title_font_size() + print(f"self.font_size: {self.font_size}") + super().__post_init__() if not self.width: self.width = self.canvas_width - self.font = Fonts.get_font(self.font_name, self.font_size) - if self.show_back_button: self.left_button = IconButton( icon_name=SeedSignerIconConstants.BACK, @@ -1239,12 +1716,13 @@ def __post_init__(self): height=GUIConstants.TOP_NAV_BUTTON_SIZE, ) - min_text_x = 0 + min_text_x = GUIConstants.EDGE_PADDING if self.show_back_button: # Don't let the title intrude on the BACK button min_text_x = self.left_button.screen_x + self.left_button.width + GUIConstants.COMPONENT_PADDING if self.icon_name: + # TODO: Refactor IconTextLine to use ScrollableTextLine self.title = IconTextLine( screen_x=0, screen_y=0, @@ -1258,7 +1736,7 @@ def __post_init__(self): font_size=self.font_size, ) else: - self.title = TextArea( + self.title = ScrollableTextLine( screen_x=0, screen_y=0, min_text_x=min_text_x, @@ -1270,6 +1748,10 @@ def __post_init__(self): font_size=self.font_size, height_ignores_below_baseline=True, # Consistently vertically center text, ignoring chars that render below baseline (e.g. "pqyj") ) + if self.title.needs_scroll: + # Add the scroll thread to TopNav's self.threads so it automatically runs + # for the life of the Component. + self.threads.append(self.title.scroll_thread) @property @@ -1338,8 +1820,8 @@ def calc_bezier_curve(p1: Tuple[int,int], p2: Tuple[int,int], p3: Tuple[int,int] def reflow_text_for_width(text: str, width: int, - font_name=GUIConstants.BODY_FONT_NAME, - font_size=GUIConstants.BODY_FONT_SIZE, + font_name=GUIConstants.get_body_font_name(), + font_size=GUIConstants.get_body_font_size(), allow_text_overflow: bool=False) -> list[dict]: """ Reflows text to fit within `width` by breaking long lines up. @@ -1352,11 +1834,14 @@ def reflow_text_for_width(text: str, # We have to figure out if and where to make line breaks in the text so that it # fits in its bounding rect (plus accounting for edge padding) using its given # font. - start = time() font = Fonts.get_font(font_name=font_name, size=font_size) # Measure from left baseline ("ls") (left, top, full_text_width, bottom) = font.getbbox(text, anchor="ls") + if not ImageFont.core.HAVE_RAQM: + # Fudge factor for imprecise width calcs w/out libraqm + full_text_width = int(full_text_width * 1.05) + # Stores each line of text and its rendering starting x-coord text_lines = [] def _add_text_line(text, text_width): @@ -1379,6 +1864,10 @@ def _binary_len_search(min_index, max_index): (left, top, right, bottom) = font.getbbox(" ".join(words[0:index]), anchor="ls") line_width = right - left + if not ImageFont.core.HAVE_RAQM: + # Fudge factor for imprecise width calcs w/out libraqm + line_width = int(line_width * 1.05) + if line_width >= width: # Candidate line is still too long. Restrict search range down. if min_index + 1 == index: @@ -1422,8 +1911,8 @@ def _binary_len_search(min_index, max_index): def reflow_text_into_pages(text: str, width: int, height: int, - font_name=GUIConstants.BODY_FONT_NAME, - font_size=GUIConstants.BODY_FONT_SIZE, + font_name=GUIConstants.get_body_font_name(), + font_size=GUIConstants.get_body_font_size(), line_spacer: int = GUIConstants.BODY_LINE_SPACING, allow_text_overflow: bool=False) -> list[str]: """ diff --git a/src/seedsigner/gui/keyboard.py b/src/seedsigner/gui/keyboard.py index a094f8e18..b68230b59 100644 --- a/src/seedsigner/gui/keyboard.py +++ b/src/seedsigner/gui/keyboard.py @@ -1,6 +1,7 @@ from dataclasses import dataclass from PIL import Image, ImageDraw, ImageFont from typing import Tuple +from gettext import gettext as _ from seedsigner.gui.components import Fonts, GUIConstants from seedsigner.hardware.buttons import HardwareButtonsConstants @@ -8,6 +9,10 @@ class Keyboard: + """ + Note that it is up to the calling Screen to manage the Renderer.lock. The Keyboard + and its child Keys should NOT attempt to manage the lock. + """ WRAP_TOP = "wrap_top" WRAP_BOTTOM = "wrap_bottom" WRAP_LEFT = "wrap_left" @@ -27,63 +32,68 @@ class Keyboard: REGULAR_KEY_FONT = "regular" COMPACT_KEY_FONT = "compact" + # TRANSLATOR_NOTE: The abbreviated label for the special key on a standard keyboard. + del_label = _("del") KEY_BACKSPACE = { "code": "DEL", - "letter": "del", + "letter": del_label, "font": COMPACT_KEY_FONT, "size": 3, } KEY_BACKSPACE_2 = { "code": "DEL", - "letter": "del", + "letter": del_label, "font": COMPACT_KEY_FONT, "size": 2, } KEY_BACKSPACE_4 = { "code": "DEL", - "letter": "del", + "letter": del_label, "font": COMPACT_KEY_FONT, "size": 4, } KEY_BACKSPACE_5 = { "code": "DEL", - "letter": "del", + "letter": del_label, "font": COMPACT_KEY_FONT, "size": 5, } KEY_BACKSPACE_6 = { "code": "DEL", - "letter": "del", + "letter": del_label, "font": COMPACT_KEY_FONT, "size": 6, } + + # TRANSLATOR_NOTE: The abbreviated label for the special key on a standard keyboard. + space_label = _("space") KEY_SPACE = { "code": "SPACE", - "letter": "space", + "letter": space_label, "font": COMPACT_KEY_FONT, "size": 1, } KEY_SPACE_2 = { "code": "SPACE", - "letter": "space", + "letter": space_label, "font": COMPACT_KEY_FONT, "size": 2, } KEY_SPACE_3 = { "code": "SPACE", - "letter": "space", + "letter": space_label, "font": COMPACT_KEY_FONT, "size": 3, } KEY_SPACE_4 = { "code": "SPACE", - "letter": "space", + "letter": space_label, "font": COMPACT_KEY_FONT, "size": 4, } KEY_SPACE_5 = { "code": "SPACE", - "letter": "space", + "letter": space_label, "font": COMPACT_KEY_FONT, "size": 5, } @@ -182,7 +192,7 @@ def render_key(self): self.screen_x + int(self.keyboard.key_width * self.size / 2), self.screen_y + self.keyboard.key_height - int((self.keyboard.key_height - text_height)/2) ), - self.letter, + _(self.letter), fill=font_color, font=font, anchor="ms" @@ -232,6 +242,7 @@ def __init__(self, # Set up the rendering and state params self.active_keys = list(self.charset) + self.additonal_key_compact_font = Fonts.get_font("RobotoCondensed-Bold", 18) self.x_start = rect[0] self.y_start = rect[1] diff --git a/src/seedsigner/gui/renderer.py b/src/seedsigner/gui/renderer.py index 9534a7747..db0a4eb5b 100644 --- a/src/seedsigner/gui/renderer.py +++ b/src/seedsigner/gui/renderer.py @@ -1,7 +1,6 @@ from PIL import Image, ImageDraw from threading import Lock -from seedsigner.gui.components import Fonts, GUIConstants from seedsigner.hardware.ST7789 import ST7789 from seedsigner.models.singleton import ConfigurableSingleton diff --git a/src/seedsigner/gui/screens/psbt_screens.py b/src/seedsigner/gui/screens/psbt_screens.py index 1a6b65066..c64855c6b 100644 --- a/src/seedsigner/gui/screens/psbt_screens.py +++ b/src/seedsigner/gui/screens/psbt_screens.py @@ -1,15 +1,17 @@ -from dataclasses import dataclass import math -from PIL import Image, ImageDraw, ImageFilter -from typing import List import time +from dataclasses import dataclass +from gettext import gettext as _ +from gettext import ngettext +from PIL import Image, ImageDraw, ImageFilter + +from seedsigner.gui.components import (BtcAmount, Icon, FontAwesomeIconConstants, IconTextLine, FormattedAddress, GUIConstants, Fonts, SeedSignerIconConstants, TextArea, + calc_bezier_curve, linear_interp) from seedsigner.gui.renderer import Renderer from seedsigner.models.threads import BaseThread -from .screen import ButtonListScreen, WarningScreen -from ..components import (BtcAmount, Button, Icon, FontAwesomeIconConstants, IconTextLine, FormattedAddress, GUIConstants, Fonts, SeedSignerIconConstants, TextArea, - calc_bezier_curve, linear_interp) +from .screen import ButtonListScreen, ButtonOption @@ -21,15 +23,15 @@ class PSBTOverviewScreen(ButtonListScreen): num_inputs: int = 0 num_self_transfer_outputs: int = 0 num_change_outputs: int = 0 - destination_addresses: List[str] = None + destination_addresses: list[str] = None has_op_return: bool = False def __post_init__(self): # Customize defaults - self.title = "Review PSBT" + self.title = _("Review PSBT") self.is_bottom_list = True - self.button_data = ["Review Details"] + self.button_data = [ButtonOption("Review Details")] # This screen can take a while to load while parsing the PSBT self.show_loading_screen = True @@ -69,7 +71,7 @@ def __post_init__(self): draw = ImageDraw.Draw(image) font_size = GUIConstants.BODY_FONT_MIN_SIZE * ssf - font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, font_size) + font = Fonts.get_font(GUIConstants.get_body_font_name(), font_size) (left, top, right, bottom) = font.getbbox(text="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890[]", anchor="lt") chart_text_height = bottom @@ -86,16 +88,18 @@ def __post_init__(self): # First calculate how wide the inputs col will be inputs_column = [] if self.num_inputs == 1: - inputs_column.append("1 input") + inputs_column.append(_("1 input")) elif self.num_inputs > 5: - inputs_column.append("input 1") - inputs_column.append("input 2") - inputs_column.append("[ ... ]") - inputs_column.append(f"input {self.num_inputs-1}") - inputs_column.append(f"input {self.num_inputs}") + inputs_column.append(_("input 1")) + inputs_column.append(_("input 2")) + # TRANSLATOR_NOTE: Indicates that items have been omitted from a series: e.g. "1, 2, 3, [...], 8" + inputs_column.append(_("[ ... ]")) + # TRANSLATOR_NOTE: Input number will be inserted (e.g. "input 3") + inputs_column.append(_("input {}").format(self.num_inputs-1)) + inputs_column.append(_("input {}").format(self.num_inputs)) else: for i in range(0, self.num_inputs): - inputs_column.append(f"input {i+1}") + inputs_column.append(_("input {}").format(i+1)) max_inputs_text_width = 0 for input in inputs_column: @@ -123,11 +127,12 @@ def __post_init__(self): # Now let's maximize the actual destination col by adjusting our addr truncation def calculate_destination_col_width(truncate_at: int = 0): def truncate_destination_addr(addr): - # TODO: Properly handle the ellipsis truncation in different languages - if len(addr) <= truncate_at + len("..."): + # TRANSLATOR_NOTE: Ellipsis ("...") characters used to truncate an address (e.g. "bc1qabc...") + if len(addr) <= truncate_at + len(_("...")): # No point in truncating return addr - return f"{addr[:truncate_at]}..." + + return addr[:truncate_at] + _("...") destination_column = [] @@ -136,21 +141,25 @@ def truncate_destination_addr(addr): destination_column.append(truncate_destination_addr(addr)) for i in range(0, self.num_self_transfer_outputs): - destination_column.append(truncate_destination_addr("self-transfer")) + destination_column.append(truncate_destination_addr(_("self-transfer"))) else: # destination_column.append(f"{len(self.destination_addresses)} recipients") - destination_column.append(f"recipient 1") - destination_column.append(f"[ ... ]") - destination_column.append(f"recipient {len(self.destination_addresses) + self.num_self_transfer_outputs}") + destination_column.append(_("recipient 1")) + # TRANSLATOR_NOTE: Indicates that items have been omitted from a series: e.g. "1, 2, 3, [...], 8" + destination_column.append(_("[ ... ]")) + # TRANSLATOR_NOTE: Inserts the recipient number (e.g. the fifth one is: "recipient 5") + destination_column.append(_("recipient {}").format(len(self.destination_addresses) + self.num_self_transfer_outputs)) - destination_column.append(f"fee") + destination_column.append(_("fee")) if self.has_op_return: - destination_column.append("OP_RETURN") + # TRANSLATOR_NOTE: Technical term, should probably NOT be translated in most languages + destination_column.append(_("OP_RETURN")) if self.num_change_outputs > 0: for i in range(0, self.num_change_outputs): - destination_column.append("change") + # TRANSLATOR_NOTE: Label for a change output in the PSBT Overview flow diagram + destination_column.append(_("change")) max_destination_text_width = 0 for destination in destination_column: @@ -164,10 +173,16 @@ def truncate_destination_addr(addr): # We're not going to display any destination addrs so truncation doesn't matter (destination_text_width, destination_column) = calculate_destination_col_width() else: + destination_text_width = None + destination_column = None # Steadliy widen out the destination column until we run out of space for i in range(6, 14): (new_width, new_col_text) = calculate_destination_col_width(truncate_at=i) if new_width > max_destination_col_width: + if not destination_text_width: + destination_text_width = new_width + if not destination_column: + destination_column = new_col_text break destination_text_width = new_width destination_column = new_col_text @@ -349,7 +364,7 @@ def truncate_destination_addr(addr): destination_y += destination_y_spacing # Resize to target and sharpen final image - image = image.resize((self.canvas_width, chart_height), Image.LANCZOS) + image = image.resize((self.canvas_width, chart_height), Image.Resampling.LANCZOS) self.paste_images.append((image.filter(ImageFilter.SHARPEN), (self.chart_x, self.chart_y))) # Pass input and output curves to the animation thread @@ -463,14 +478,14 @@ class PSBTMathScreen(ButtonListScreen): def __post_init__(self): # Customize defaults - self.title = "PSBT Math" - self.button_data = ["Review Recipients"] + self.title = _("PSBT Math") + self.button_data = [ButtonOption("Review Recipients")] self.is_bottom_list = True super().__post_init__() if self.input_amount > 1e6: - denomination = "btc" + denomination = _("btc") self.input_amount /= 1e8 self.spend_amount /= 1e8 self.change_amount /= 1e8 @@ -482,7 +497,7 @@ def __post_init__(self): # lines up properly. self.fee_amount = f"{self.fee_amount:10}" else: - denomination = "sats" + denomination = _("sats") self.input_amount = f"{self.input_amount:,}" self.spend_amount = f"{self.spend_amount:,}" self.fee_amount = f"{self.fee_amount:,}" @@ -509,9 +524,9 @@ def __post_init__(self): image = Image.new("RGB", (body_width*ssf, body_height*ssf)) draw = ImageDraw.Draw(image) - body_font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, (GUIConstants.BODY_FONT_SIZE)*ssf) - fixed_width_font = Fonts.get_font(GUIConstants.FIXED_WIDTH_FONT_NAME, (GUIConstants.BODY_FONT_SIZE + 6)*ssf) - left, top, right, bottom = fixed_width_font.getbbox(self.input_amount + "Q") + body_font = Fonts.get_font(GUIConstants.get_body_font_name(), (GUIConstants.get_body_font_size())*ssf) + fixed_width_font = Fonts.get_font(GUIConstants.FIXED_WIDTH_FONT_NAME, (GUIConstants.get_body_font_size() + 6)*ssf) + left, top, right, bottom = fixed_width_font.getbbox(self.input_amount + "+") digits_width, digits_height = right - left, bottom - top # Draw each line of the equation @@ -524,7 +539,7 @@ def render_amount(cur_y, amount_str, info_text, info_text_color=GUIConstants.BOD # secondary_digit_color = GUIConstants.BODY_FONT_COLOR # tertiary_digit_color = GUIConstants.BODY_FONT_COLOR # digit_group_spacing = 0 - if denomination == 'btc': + if denomination == _('btc'): display_str = amount_str main_zone = display_str[:-6] mid_zone = display_str[-6:-3] @@ -538,13 +553,12 @@ def render_amount(cur_y, amount_str, info_text, info_text_color=GUIConstants.BOD draw.text((main_zone_width + digit_group_spacing + mid_zone_width + digit_group_spacing, cur_y), text=end_zone, font=fixed_width_font, fill=tertiary_digit_color) else: draw.text((0, cur_y), text=amount_str, font=fixed_width_font, fill=GUIConstants.BODY_FONT_COLOR) - draw.text((digits_width + 2*digit_group_spacing, cur_y), text=info_text, font=body_font, fill=info_text_color) + draw.text((digits_width + 3*digit_group_spacing, cur_y), text=info_text, font=body_font, fill=info_text_color) render_amount( cur_y, f" {self.input_amount}", - # info_text=f""" {self.num_inputs} input{"s" if self.num_inputs > 1 else ""}""", - info_text=f""" input{"s" if self.num_inputs > 1 else ""}""", + info_text=ngettext("input", "inputs", self.num_inputs), ) # spend_amount will be zero on self-transfers; only display when there's an @@ -554,15 +568,14 @@ def render_amount(cur_y, amount_str, info_text, info_text_color=GUIConstants.BOD render_amount( cur_y, f"-{self.spend_amount}", - # info_text=f""" {self.num_recipients} recipient{"s" if self.num_recipients > 1 else ""}""", - info_text=f""" recipient{"s" if self.num_recipients > 1 else ""}""", + info_text=ngettext("recipient", "recipients", self.num_recipients), ) cur_y += digits_height + GUIConstants.BODY_LINE_SPACING * ssf render_amount( cur_y, f"-{self.fee_amount}", - info_text=f""" fee""", + info_text=_("fee"), ) cur_y += digits_height + GUIConstants.BODY_LINE_SPACING * ssf @@ -572,12 +585,13 @@ def render_amount(cur_y, amount_str, info_text, info_text_color=GUIConstants.BOD render_amount( cur_y, f" {self.change_amount}", - info_text=f" {denomination} change", + # TRANSLATOR_NOTE: Denonination is inserted (e.g. your "btc change" or "sats change") + info_text=_("{} change").format(denomination), info_text_color="darkorange" # super-sampling alters the perceived color ) # Resize to target and sharpen final image - image = image.resize((body_width, body_height), Image.LANCZOS) + image = image.resize((body_width, body_height), Image.Resampling.LANCZOS) self.paste_images.append((image.filter(ImageFilter.SHARPEN), (GUIConstants.EDGE_PADDING, self.top_nav.height + GUIConstants.COMPONENT_PADDING))) @@ -635,7 +649,6 @@ def __post_init__(self): @dataclass class PSBTChangeDetailsScreen(ButtonListScreen): - title: str = "Your Change" amount: int = 0 address: str = None is_multisig: bool = False @@ -662,10 +675,20 @@ def __post_init__(self): )) screen_y = self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING + + change_type = _("Multisig") if self.is_multisig else self.fingerprint + + if self.is_change_derivation_path : + addr_type = _("Change") + else: + # TRANSLATOR_NOTE: Abbreviation for receive address + addr_type = _("Addr") + + value_text = "{}: {} #{}".format(change_type, addr_type, self.derivation_path_addr_index) self.components.append(IconTextLine( + value_text=value_text, icon_name=SeedSignerIconConstants.FINGERPRINT, icon_color=GUIConstants.INFO_COLOR, - value_text=f"""{"Multisig" if self.is_multisig else self.fingerprint}: {"Change" if self.is_change_derivation_path else "Addr"} #{self.derivation_path_addr_index}""", is_text_centered=False, screen_x=GUIConstants.EDGE_PADDING, screen_y=screen_y, @@ -675,7 +698,7 @@ def __post_init__(self): self.components.append(IconTextLine( icon_name=SeedSignerIconConstants.SUCCESS, icon_color=GUIConstants.SUCCESS_COLOR, - value_text="Address verified!", + value_text=_("Address verified!"), is_text_centered=False, screen_x=GUIConstants.EDGE_PADDING, screen_y=self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING, @@ -697,7 +720,7 @@ def __post_init__(self): # Simple case: display human-readable text self.components.append(TextArea( text=self.op_return_data.decode(errors="strict"), # "strict" is a good enough heuristic to decide if it's human readable - font_size=GUIConstants.TOP_NAV_TITLE_FONT_SIZE, + font_size=GUIConstants.get_top_nav_title_font_size(), is_text_centered=True, allow_text_overflow=True, screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING, @@ -707,7 +730,7 @@ def __post_init__(self): except UnicodeDecodeError: # Contains data that can't be converted to UTF-8; probably encoded and not # meant to be human readable. - font = Fonts.get_font(GUIConstants.FIXED_WIDTH_FONT_NAME, size=GUIConstants.BODY_FONT_SIZE) + font = Fonts.get_font(GUIConstants.FIXED_WIDTH_FONT_NAME, size=GUIConstants.get_body_font_size()) (left, top, right, bottom) = font.getbbox("X", anchor="ls") chars_per_line = int((self.canvas_width - 2*GUIConstants.EDGE_PADDING) / (right - left)) decoded_str = self.op_return_data.hex() @@ -717,8 +740,10 @@ def __post_init__(self): text += (decoded_str[i*chars_per_line:(i+1)*chars_per_line]) + "\n" text = text[:-1] + # TRANSLATOR_NOTE: Shown when displaying OP_RETURN as non-human-readable hexadecimal data + hex_label = _("raw hex data") label = TextArea( - text="raw hex data", + text=hex_label, font_color=GUIConstants.LABEL_FONT_COLOR, font_size=GUIConstants.LABEL_FONT_SIZE, screen_y=self.top_nav.height, @@ -728,7 +753,7 @@ def __post_init__(self): self.components.append(TextArea( text=text, font_name=GUIConstants.FIXED_WIDTH_FONT_NAME, - font_size=GUIConstants.BODY_FONT_SIZE, + font_size=GUIConstants.get_body_font_size(), screen_y=label.screen_y + label.height + GUIConstants.COMPONENT_PADDING, )) @@ -738,7 +763,7 @@ def __post_init__(self): class PSBTFinalizeScreen(ButtonListScreen): def __post_init__(self): # Customize defaults - self.title = "Sign PSBT" + self.title = _("Sign PSBT") self.is_bottom_list = True super().__post_init__() @@ -752,6 +777,6 @@ def __post_init__(self): self.components.append(icon) self.components.append(TextArea( - text="Click to authorize this transaction", - screen_y=icon.screen_y + icon.height + GUIConstants.COMPONENT_PADDING + text=_("Click to approve this transaction"), + screen_y=icon.screen_y + icon.height + 2*GUIConstants.COMPONENT_PADDING )) diff --git a/src/seedsigner/gui/screens/scan_screens.py b/src/seedsigner/gui/screens/scan_screens.py index e1af8e157..c030fef47 100644 --- a/src/seedsigner/gui/screens/scan_screens.py +++ b/src/seedsigner/gui/screens/scan_screens.py @@ -1,16 +1,15 @@ import time from dataclasses import dataclass +from gettext import gettext as _ from PIL import Image, ImageDraw from seedsigner.gui import renderer -from seedsigner.hardware.buttons import HardwareButtonsConstants -from seedsigner.hardware.camera import Camera -from seedsigner.models.decode_qr import DecodeQR, DecodeQRStatus +from seedsigner.gui.components import GUIConstants, Fonts +from seedsigner.models.decode_qr import DecodeQR from seedsigner.models.threads import BaseThread, ThreadsafeCounter from .screen import BaseScreen -from ..components import GUIConstants, Fonts, SeedSignerIconConstants @@ -56,7 +55,8 @@ def __post_init__(self): # Initialize the base class super().__post_init__() - self.instructions_text = "< back | " + self.instructions_text + # TODO: Arrange this with UI elements rather than text + self.instructions_text = "< " + _("back") + " | " + _(self.instructions_text) self.camera = Camera.get_instance() self.camera.start_video_stream_mode(resolution=self.resolution, framerate=self.framerate, format="rgb") @@ -65,7 +65,6 @@ def __post_init__(self): self.frames_decoded_counter = ThreadsafeCounter() self.threads.append(ScanScreen.LivePreviewThread( - camera=self.camera, decoder=self.decoder, renderer=self.renderer, instructions_text=self.instructions_text, @@ -76,8 +75,10 @@ def __post_init__(self): class LivePreviewThread(BaseThread): - def __init__(self, camera: Camera, decoder: DecodeQR, renderer: renderer.Renderer, instructions_text: str, render_rect: tuple[int,int,int,int], frame_decode_status: ThreadsafeCounter, frames_decoded_counter: ThreadsafeCounter): - self.camera = camera + def __init__(self, decoder: DecodeQR, renderer: renderer.Renderer, instructions_text: str, render_rect: tuple[int,int,int,int], frame_decode_status: ThreadsafeCounter, frames_decoded_counter: ThreadsafeCounter): + from seedsigner.hardware.camera import Camera + + self.camera = Camera.get_instance() self.decoder = decoder self.renderer = renderer self.instructions_text = instructions_text @@ -96,10 +97,12 @@ def __init__(self, camera: Camera, decoder: DecodeQR, renderer: renderer.Rendere def run(self): - instructions_font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.BUTTON_FONT_SIZE) + from timeit import default_timer as timer + + instructions_font = Fonts.get_font(GUIConstants.get_body_font_name(), GUIConstants.get_button_font_size()) # pre-calculate how big the animated QR percent display can be - left, _, right, _ = instructions_font.getbbox("100%") + left, top, right, bottom = instructions_font.getbbox("100%") progress_text_width = right - left start_time = time.time() @@ -201,11 +204,12 @@ def run(self): radius=8 ) + # TRANSLATOR_NOTE: Inserts the percentage value of the animated QR scan progress + text = _("{}%").format(progress_percentage) draw.text( xy=(rectangle.width - GUIConstants.EDGE_PADDING, int(rectangle.height / 2)), - text=f"{progress_percentage}%", - # text=f"100%", + text=text, fill=GUIConstants.BODY_FONT_COLOR, font=instructions_font, anchor="rm", # right-justified, middle @@ -248,6 +252,9 @@ def _run(self): Screen. Once interaction starts, the display updates have to be managed in _run(). The live preview is an extra-complex case. """ + from seedsigner.hardware.buttons import HardwareButtonsConstants + from seedsigner.models.decode_qr import DecodeQRStatus + num_frames = 0 start_time = time.time() while True: diff --git a/src/seedsigner/gui/screens/screen.py b/src/seedsigner/gui/screens/screen.py index fc1d5e8ea..60cd5087f 100644 --- a/src/seedsigner/gui/screens/screen.py +++ b/src/seedsigner/gui/screens/screen.py @@ -1,14 +1,15 @@ import time -from dataclasses import dataclass +from dataclasses import dataclass, field +from gettext import gettext as _ from PIL import Image, ImageDraw, ImageColor from typing import Any, List, Tuple +from seedsigner.helpers.l10n import mark_for_translation as _mft from seedsigner.gui.components import (GUIConstants, BaseComponent, Button, Icon, IconButton, LargeIconButton, SeedSignerIconConstants, TopNav, TextArea, load_image) from seedsigner.gui.keyboard import Keyboard, TextEntryDisplay -from seedsigner.gui.renderer import Renderer from seedsigner.hardware.buttons import HardwareButtonsConstants, HardwareButtons from seedsigner.models.encode_qr import BaseQrEncoder from seedsigner.models.settings import SettingsConstants @@ -43,6 +44,13 @@ def __post_init__(self): # Tracks position on scrollable pages, determines which elements are visible. self.scroll_y = 0 + + + def get_threads(self) -> List[BaseThread]: + threads = self.threads.copy() + for component in self.components: + threads += component.threads + return threads def display(self) -> Any: @@ -51,15 +59,16 @@ def display(self) -> Any: self._render() self.renderer.show_image() - for t in self.threads: - t.start() + for t in self.get_threads(): + if not t.is_alive(): + t.start() return self._run() except Exception as e: repr(e) raise e finally: - for t in self.threads: + for t in self.get_threads(): t.stop() @@ -99,19 +108,17 @@ def _run(self): For example: A basic menu screen where the user can key up and down. The Screen can handle the UI updates to light up the currently selected menu item - on its own. Only when the user clicks to make a selection would run() exit - and returns the selected option. - - But an alternate use case returns immediately after each user input so the - View can update its controlling logic accordingly (e.g. as the user joysticks - over different letters in the keyboard UI, we need to make matching changes - to the list of mnemonic seed words that match the new letter). - - In this case, it would be called repeatedly in a loop: - * run() and wait for it to handle user input - * run() exits and returns the user input (e.g. KEY_UP) - * View updates its state of the world accordingly - * loop and call run() again + on its own. Only when the user clicks to make a selection would _run() exit + and return the selected option. + + In general, _run() will be implemented as a continuous loop waiting for user + input and redrawing the screen as needed. When it redraws, it must claim + the `Renderer.lock` to ensure that its updates don't conflict with any other + threads that might be updating the screen at the same time (e.g. flashing + warning edges, auto-scrolling long titles or buttons, etc). + + Just note that this loop cannot hold the lock indefinitely! Each iteration + through the loop should claim the lock, render, and then release it. """ raise Exception("Must implement in a child class") @@ -124,6 +131,7 @@ def __init__(self, text: str = None): def run(self): + from seedsigner.gui.renderer import Renderer renderer: Renderer = Renderer.get_instance() center_image = load_image("btc_logo_60x60.png") @@ -147,7 +155,7 @@ def run(self): if self.text: TextArea( text=self.text, - font_size=GUIConstants.TOP_NAV_TITLE_FONT_SIZE, + font_size=GUIConstants.get_top_nav_title_font_size(), screen_y=int((renderer.canvas_height - bounding_box[3])/2), ).render() @@ -190,7 +198,7 @@ class BaseTopNavScreen(BaseScreen): top_nav_icon_name: str = None top_nav_icon_color: str = None title: str = "Screen Title" - title_font_size: int = GUIConstants.TOP_NAV_TITLE_FONT_SIZE + title_font_size: int = GUIConstants.get_top_nav_title_font_size() show_back_button: bool = True show_power_button: bool = False @@ -199,7 +207,7 @@ def __post_init__(self): self.top_nav = TopNav( icon_name=self.top_nav_icon_name, icon_color=self.top_nav_icon_color, - text=self.title, + text=_(self.title), # Wrap here for just-in-time translations font_size=self.title_font_size, width=self.canvas_width, height=GUIConstants.TOP_NAV_HEIGHT, @@ -251,14 +259,29 @@ def _run(self): +@dataclass +class ButtonOption: + """ + Note: The babel config in setup.cfg will extract the `button_label` string for translation + """ + button_label: str + icon_name: str = None + icon_color: str = None + right_icon_name: str = None + button_label_color: str = None + return_data: Any = None + active_button_label: str = None # Changes displayed button label when button is active + + + @dataclass class ButtonListScreen(BaseTopNavScreen): - button_data: list = None # list can be a mix of str or tuple(label: str, icon_name: str) + button_data: list[ButtonOption] = None selected_button: int = 0 is_button_text_centered: bool = True is_bottom_list: bool = False - button_font_name: str = GUIConstants.BUTTON_FONT_NAME - button_font_size: int = GUIConstants.BUTTON_FONT_SIZE + button_font_name: str = None + button_font_size: int = None button_selected_color: str = GUIConstants.ACCENT_COLOR # Params for version of list used for Settings @@ -270,7 +293,12 @@ class ButtonListScreen(BaseTopNavScreen): def __post_init__(self): + if not self.button_font_name: + self.button_font_name = GUIConstants.get_button_font_name() + if not self.button_font_size: + self.button_font_size = GUIConstants.get_button_font_size() super().__post_init__() + button_height = GUIConstants.BUTTON_HEIGHT if len(self.button_data) == 1: button_list_height = button_height @@ -289,29 +317,26 @@ def __post_init__(self): self.has_scroll_arrows = True self.buttons: List[Button] = [] - for i, button_label in enumerate(self.button_data): + for i, button_option in enumerate(self.button_data): icon_name = None icon_color = None right_icon_name = None button_label_color = None - # TODO: Define an actual class for button_data? - if type(button_label) == tuple: - if len(button_label) == 2: - (button_label, icon_name) = button_label - icon_color = GUIConstants.BUTTON_FONT_COLOR - - elif len(button_label) == 3: - (button_label, icon_name, icon_color) = button_label - - elif len(button_label) == 4: - (button_label, icon_name, icon_color, button_label_color) = button_label - - elif len(button_label) == 5: - (button_label, icon_name, icon_color, button_label_color, right_icon_name) = button_label + if type(button_option) == ButtonOption: + button_label = button_option.button_label + icon_name = button_option.icon_name + icon_color = button_option.icon_color + right_icon_name = button_option.right_icon_name + button_label_color = button_option.button_label_color + active_button_label = button_option.active_button_label + + else: + raise Exception("Refactor needed!") button_kwargs = dict( - text=button_label, + text=_(button_label), # Wrap here for just-in-time translations + active_text=_(active_button_label), # Wrap here for just-in-time translations icon_name=icon_name, icon_color=icon_color if icon_color else GUIConstants.BUTTON_FONT_COLOR, is_icon_inline=True, @@ -325,13 +350,14 @@ def __post_init__(self): font_name=self.button_font_name, font_size=self.button_font_size, font_color=button_label_color if button_label_color else GUIConstants.BUTTON_FONT_COLOR, - selected_color=self.button_selected_color + selected_color=self.button_selected_color, + is_scrollable_text=True, # We need to use the ScrollableText class for long button labels ) if self.checked_buttons and i in self.checked_buttons: button_kwargs["is_checked"] = True button = self.Button_cls(**button_kwargs) self.buttons.append(button) - + if self.has_scroll_arrows: self.arrow_half_width = 10 self.cur_scroll_y = self.scroll_y_initial_offset if self.scroll_y_initial_offset is not None else 0 @@ -352,10 +378,21 @@ def __post_init__(self): cur_selected_button.is_selected = True + def get_threads(self) -> List[BaseThread]: + threads = super().get_threads() + for button in self.buttons: + if button.is_scrollable_text: + threads += button.threads + return threads + + def _render(self): super()._render() self._render_visible_buttons() + # Write the screen updates + self.renderer.show_image() + def _render_visible_buttons(self): if self.has_scroll_arrows: @@ -510,13 +547,18 @@ def _run(self): @dataclass class LargeButtonScreen(BaseTopNavScreen): - button_data: list = None # list can be a mix of str or tuple(label: str, icon_name: str) - button_font_name: str = GUIConstants.BUTTON_FONT_NAME - button_font_size: int = 20 + button_data: list = None + button_font_name: str = None + button_font_size: int = None button_selected_color: str = GUIConstants.ACCENT_COLOR selected_button: int = 0 def __post_init__(self): + if not self.button_font_name: + self.button_font_name = GUIConstants.get_button_font_name() + if not self.button_font_size: + self.button_font_size = GUIConstants.get_button_font_size() + 2 + super().__post_init__() if len(self.button_data) not in [2, 4]: @@ -533,11 +575,20 @@ def __post_init__(self): button_start_y = self.top_nav.height + int((self.canvas_height - (self.top_nav.height + GUIConstants.COMPONENT_PADDING) - (2 * button_height) - GUIConstants.COMPONENT_PADDING) / 2) self.buttons = [] - for i, button_label in enumerate(self.button_data): - if type(button_label) == tuple: - (button_label, icon_name) = button_label + for i, button_option in enumerate(self.button_data): + if type(button_option) == ButtonOption: + button_label = button_option.button_label + icon_name = button_option.icon_name else: - icon_name = None + raise Exception("Refactor needed!") + + # elif type(button_option) == str: + # button_label = button_option + # icon_name = None + # elif type(button_option) == tuple: + # (button_label, icon_name) = button_option + # else: + # print(type(button_option)) if i % 2 == 0: button_start_x = GUIConstants.EDGE_PADDING @@ -545,7 +596,7 @@ def __post_init__(self): button_start_x = GUIConstants.EDGE_PADDING + button_width + GUIConstants.COMPONENT_PADDING button_args = { - "text": button_label, + "text": _(button_label), # Wrap here for just-in-time translations "screen_x": button_start_x, "screen_y": button_start_y, "width": button_width, @@ -661,12 +712,12 @@ class QRDisplayScreen(BaseScreen): qr_encoder: BaseQrEncoder = None class QRDisplayThread(BaseThread): - def __init__(self, qr_encoder: BaseQrEncoder, qr_brightness: ThreadsafeCounter, renderer: Renderer, - tips_start_time: ThreadsafeCounter): + def __init__(self, qr_encoder: BaseQrEncoder, qr_brightness: ThreadsafeCounter, tips_start_time: ThreadsafeCounter): + from seedsigner.gui.renderer import Renderer super().__init__() self.qr_encoder = qr_encoder self.qr_brightness = qr_brightness - self.renderer = renderer + self.renderer = Renderer.get_instance() self.tips_start_time = tips_start_time @@ -676,7 +727,7 @@ def render_brightness_tip(self, image: Image.Image) -> None: # Instantiate a temp Image and ImageDraw object to draw on rectangle_width = image.width - rectangle_height = GUIConstants.COMPONENT_PADDING * 2 + GUIConstants.BODY_FONT_SIZE * 2 + GUIConstants.BODY_LINE_SPACING + rectangle_height = GUIConstants.COMPONENT_PADDING * 2 + GUIConstants.get_body_font_size() * 2 + GUIConstants.BODY_LINE_SPACING rectangle = Image.new('RGBA', (rectangle_width, rectangle_height), (0, 0, 0, 0)) img_draw = ImageDraw.Draw(rectangle) @@ -691,7 +742,7 @@ def render_brightness_tip(self, image: Image.Image) -> None: screen_x=GUIConstants.EDGE_PADDING*2 + 1, screen_y=GUIConstants.COMPONENT_PADDING + 4, # +4 fudge factor to account for where the chevron is drawn relative to baseline icon_name=SeedSignerIconConstants.CHEVRON_UP, - icon_size=GUIConstants.BODY_FONT_SIZE, + icon_size=GUIConstants.get_body_font_size(), ) chevron_up_icon.render() @@ -705,12 +756,14 @@ def render_brightness_tip(self, image: Image.Image) -> None: ) chevron_down_icon.render() + # TRANSLATOR_NOTE: Increase QR code screen brightness + text = _("Brighter") TextArea( image_draw=img_draw, canvas=rectangle, - text="Brighter", - font_size=GUIConstants.BODY_FONT_SIZE, - font_name=GUIConstants.BUTTON_FONT_NAME, + text=text, + font_size=GUIConstants.get_body_font_size(), + font_name=GUIConstants.get_button_font_name(), background_color=(0, 0, 0, overlay_opacity), edge_padding=0, is_text_centered=False, @@ -721,12 +774,14 @@ def render_brightness_tip(self, image: Image.Image) -> None: allow_text_overflow=False ).render() + # TRANSLATOR_NOTE: Decrease QR code screen brightness + text = _("Darker") TextArea( image_draw=img_draw, canvas=rectangle, - text="Darker", - font_size=GUIConstants.BODY_FONT_SIZE, - font_name=GUIConstants.BUTTON_FONT_NAME, + text=text, + font_size=GUIConstants.get_body_font_size(), + font_name=GUIConstants.get_button_font_name(), background_color=(0, 0, 0, overlay_opacity), edge_padding=0, is_text_centered=False, @@ -789,7 +844,6 @@ def __post_init__(self): self.threads.append(QRDisplayScreen.QRDisplayThread( qr_encoder=self.qr_encoder, qr_brightness=self.qr_brightness, - renderer=self.renderer, tips_start_time=self.tips_start_time )) @@ -831,11 +885,11 @@ def _run(self): @dataclass class LargeIconStatusScreen(ButtonListScreen): - title: str = "Success!" + title: str = _mft("Success!") status_icon_name: str = SeedSignerIconConstants.SUCCESS status_icon_size: int = GUIConstants.ICON_PRIMARY_SCREEN_SIZE status_color: str = GUIConstants.SUCCESS_COLOR - status_headline: str = "Success!" # The colored text under the large icon + status_headline: str = None text: str = "" # The body text of the screen text_edge_padding: int = GUIConstants.EDGE_PADDING button_data: list = None @@ -843,9 +897,9 @@ class LargeIconStatusScreen(ButtonListScreen): def __post_init__(self): - self.is_bottom_list: bool = True if not self.button_data: - self.button_data = ["OK"] + self.button_data = [ButtonOption("OK")] + self.is_bottom_list = True super().__post_init__() self.status_icon = Icon( @@ -860,22 +914,21 @@ def __post_init__(self): next_y = self.status_icon.screen_y + self.status_icon.height + int(GUIConstants.COMPONENT_PADDING/2) if self.status_headline: self.warning_headline_textarea = TextArea( - text=self.status_headline, + text=_(self.status_headline), # Wrap here for just-in-time translations width=self.canvas_width, screen_y=next_y, font_color=self.status_color, - allow_text_overflow=self.allow_text_overflow, + auto_line_break=False, # Force headline to be on one line ) self.components.append(self.warning_headline_textarea) next_y = next_y + self.warning_headline_textarea.height self.components.append(TextArea( height=self.buttons[0].screen_y - next_y, - text=self.text, + text=_(self.text), width=self.canvas_width, edge_padding=self.text_edge_padding, # Don't render all the way up to the far left/right edges screen_y=next_y, - allow_text_overflow=self.allow_text_overflow, )) @@ -958,22 +1011,17 @@ def __post_init__(self): @dataclass class WarningScreen(WarningEdgesMixin, LargeIconStatusScreen): - title: str = "Caution" + title: str = _mft("Caution") status_icon_name: str = SeedSignerIconConstants.WARNING status_color: str = "yellow" - status_headline: str = "Privacy Leak!" # The colored text under the alert icon - - def __post_init__(self): - if not self.button_data: - self.button_data = ["I Understand"] - - super().__post_init__() + status_headline: str = _mft("Privacy Leak!") # The colored text under the alert icon + button_data: list = field(default_factory=lambda: [ButtonOption("I Understand")]) @dataclass class DireWarningScreen(WarningScreen): - status_headline: str = "Classified Info!" # The colored text under the alert icon + status_headline: str = _mft("Classified Info!") # The colored text under the alert icon status_color: str = GUIConstants.DIRE_WARNING_COLOR @@ -981,12 +1029,12 @@ class DireWarningScreen(WarningScreen): @dataclass class ResetScreen(BaseTopNavScreen): def __post_init__(self): - self.title = "Restarting" + self.title = _("Restarting") self.show_back_button = False super().__post_init__() self.components.append(TextArea( - text="SeedSigner is restarting.\n\nAll in-memory data will be wiped.", + text=_("SeedSigner is restarting.\n\nAll in-memory data will be wiped."), screen_y=self.top_nav.height, height=self.canvas_height - self.top_nav.height, )) @@ -996,12 +1044,12 @@ def __post_init__(self): @dataclass class PowerOffScreen(BaseTopNavScreen): def __post_init__(self): - self.title = "Powering Off" + self.title = _("Powering Off") self.show_back_button = False super().__post_init__() self.components.append(TextArea( - text="Please wait about 30 seconds before disconnecting power.", + text=_("Please wait about 30 seconds before disconnecting power."), screen_y=self.top_nav.height, height=self.canvas_height - self.top_nav.height, )) @@ -1011,12 +1059,12 @@ def __post_init__(self): @dataclass class PowerOffNotRequiredScreen(BaseTopNavScreen): def __post_init__(self): - self.title = "Just Unplug It" + self.title = _("Just Unplug It") self.show_back_button = True super().__post_init__() self.components.append(TextArea( - text="It is safe to disconnect power at any time.", + text=_("It is safe to disconnect power at any time."), screen_y=self.top_nav.height, height=self.canvas_height - self.top_nav.height, )) @@ -1044,7 +1092,7 @@ class KeyboardScreen(BaseTopNavScreen): rows: int = None cols: int = None keyboard_font_name: str = GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME - keyboard_font_size: int = GUIConstants.TOP_NAV_TITLE_FONT_SIZE + 2 + keyboard_font_size: int = None key_height: int = None keys_charset: str = None keys_to_values: dict = None @@ -1055,6 +1103,9 @@ class KeyboardScreen(BaseTopNavScreen): custom_additional_keys: dict = field(default_factory=lambda: Keyboard.ADDITIONAL_KEYS) def __post_init__(self): + if self.keyboard_font_size is None: + self.keyboard_font_size = GUIConstants.get_top_nav_title_font_size() + 2 + super().__post_init__() if self.initial_value: @@ -1135,7 +1186,7 @@ def _render(self): self.text_entry_display.render() self.renderer.show_image() - + def _run(self): self.cursor_position = len(self.user_input) @@ -1147,84 +1198,85 @@ def _run(self): check_release=True, release_keys=[HardwareButtonsConstants.KEY_PRESS, HardwareButtonsConstants.KEY3] ) - - # Check possible exit conditions - if self.top_nav.is_selected and input == HardwareButtonsConstants.KEY_PRESS: - return RET_CODE__BACK_BUTTON - - elif self.show_save_button and input == HardwareButtonsConstants.KEY3: - # Save! - if len(self.user_input) == 0: - # Don't try to submit zero input + + with self.renderer.lock: + # Check possible exit conditions + if self.top_nav.is_selected and input == HardwareButtonsConstants.KEY_PRESS: + return RET_CODE__BACK_BUTTON + + elif self.show_save_button and input == HardwareButtonsConstants.KEY3: + # Save! + if len(self.user_input) == 0: + # Don't try to submit zero input + continue + + # First show the save button reacting to the click + self.save_button.is_selected = True + self.save_button.render() + self.renderer.show_image() + + # Then return the input to the View + return self.user_input.strip() + + # Process normal input + if input in [HardwareButtonsConstants.KEY_UP, HardwareButtonsConstants.KEY_DOWN] and self.top_nav.is_selected: + # We're navigating off the previous button + self.top_nav.is_selected = False + self.top_nav.render_buttons() + + # Override the actual input w/an ENTER signal for the Keyboard + if input == HardwareButtonsConstants.KEY_DOWN: + input = Keyboard.ENTER_TOP + else: + input = Keyboard.ENTER_BOTTOM + elif input in [HardwareButtonsConstants.KEY_LEFT, HardwareButtonsConstants.KEY_RIGHT] and self.top_nav.is_selected: + # ignore continue - # First show the save button reacting to the click - self.save_button.is_selected = True - self.save_button.render() - self.renderer.show_image() + ret_val = self.keyboard.update_from_input(input) - # Then return the input to the View - return self.user_input.strip() - - # Process normal input - if input in [HardwareButtonsConstants.KEY_UP, HardwareButtonsConstants.KEY_DOWN] and self.top_nav.is_selected: - # We're navigating off the previous button - self.top_nav.is_selected = False - self.top_nav.render_buttons() - - # Override the actual input w/an ENTER signal for the Keyboard - if input == HardwareButtonsConstants.KEY_DOWN: - input = Keyboard.ENTER_TOP - else: - input = Keyboard.ENTER_BOTTOM - elif input in [HardwareButtonsConstants.KEY_LEFT, HardwareButtonsConstants.KEY_RIGHT] and self.top_nav.is_selected: - # ignore - continue - - ret_val = self.keyboard.update_from_input(input) - - # Now process the result from the keyboard - if ret_val in Keyboard.EXIT_DIRECTIONS: - self.top_nav.is_selected = True - self.top_nav.render_buttons() - - elif ret_val in Keyboard.ADDITIONAL_KEYS and input == HardwareButtonsConstants.KEY_PRESS: - if ret_val == Keyboard.KEY_BACKSPACE["code"]: - if len(self.user_input) > 0: - self.user_input = self.user_input[:-1] - self.cursor_position -= 1 - - elif input == HardwareButtonsConstants.KEY_PRESS and ret_val not in Keyboard.ADDITIONAL_KEYS: - # User has locked in the current letter - if self.keys_to_values: - # Map the Key display char to its output value (e.g. dice icon to digit) - ret_val = self.keys_to_values[ret_val] - self.user_input += ret_val - self.cursor_position += 1 - - if self.cursor_position == self.return_after_n_chars: - return self.user_input - - # Render a new TextArea over the TopNav title bar - if self.update_title(): - TextArea( - text=self.title, - font_name=GUIConstants.TOP_NAV_TITLE_FONT_NAME, - font_size=GUIConstants.TOP_NAV_TITLE_FONT_SIZE, - height=self.top_nav.height, - ).render() + # Now process the result from the keyboard + if ret_val in Keyboard.EXIT_DIRECTIONS: + self.top_nav.is_selected = True self.top_nav.render_buttons() - - elif input in HardwareButtonsConstants.KEYS__LEFT_RIGHT_UP_DOWN: - # Live joystick movement; haven't locked this new letter in yet. - # Leave current spot blank for now. Only update the active keyboard keys - # when a selection has been locked in (KEY_PRESS) or removed ("del"). - pass - - # Render the text entry display and cursor block - self.text_entry_display.render(self.user_input) - - self.renderer.show_image() + + elif ret_val in Keyboard.ADDITIONAL_KEYS and input == HardwareButtonsConstants.KEY_PRESS: + if ret_val == Keyboard.KEY_BACKSPACE["code"]: + if len(self.user_input) > 0: + self.user_input = self.user_input[:-1] + self.cursor_position -= 1 + + elif input == HardwareButtonsConstants.KEY_PRESS and ret_val not in Keyboard.ADDITIONAL_KEYS: + # User has locked in the current letter + if self.keys_to_values: + # Map the Key display char to its output value (e.g. dice icon to digit) + ret_val = self.keys_to_values[ret_val] + self.user_input += ret_val + self.cursor_position += 1 + + if self.cursor_position == self.return_after_n_chars: + return self.user_input + + # Render a new TextArea over the TopNav title bar + if self.update_title(): + TextArea( + text=self.title, + font_name=GUIConstants.get_top_nav_title_font_name(), + font_size=GUIConstants.get_top_nav_title_font_size(), + height=self.top_nav.height, + ).render() + self.top_nav.render_buttons() + + elif input in HardwareButtonsConstants.KEYS__LEFT_RIGHT_UP_DOWN: + # Live joystick movement; haven't locked this new letter in yet. + # Leave current spot blank for now. Only update the active keyboard keys + # when a selection has been locked in (KEY_PRESS) or removed ("del"). + pass + + # Render the text entry display and cursor block + self.text_entry_display.render(self.user_input) + + self.renderer.show_image() def update_title(self) -> bool: @@ -1232,7 +1284,7 @@ def update_title(self) -> bool: Optionally update the self.title after each completed key input. e.g. to increment the dice roll count: - self.title = f"Roll {self.cursor_position + 1}" + self.title = _("Roll {}".format(self.cursor_position + 1)) """ return False diff --git a/src/seedsigner/gui/screens/seed_screens.py b/src/seedsigner/gui/screens/seed_screens.py index 2fc669873..fa323a1ff 100644 --- a/src/seedsigner/gui/screens/seed_screens.py +++ b/src/seedsigner/gui/screens/seed_screens.py @@ -3,6 +3,7 @@ import time from dataclasses import dataclass +from gettext import gettext as _ from typing import List from PIL import Image, ImageDraw, ImageFilter @@ -10,7 +11,7 @@ from seedsigner.helpers.qr import QR from seedsigner.models.threads import BaseThread, ThreadsafeCounter -from .screen import RET_CODE__BACK_BUTTON, BaseScreen, BaseTopNavScreen, ButtonListScreen, KeyboardScreen, WarningEdgesMixin +from .screen import RET_CODE__BACK_BUTTON, BaseScreen, BaseTopNavScreen, ButtonListScreen, ButtonOption, KeyboardScreen, WarningEdgesMixin from ..components import (Button, FontAwesomeIconConstants, Fonts, FormattedAddress, IconButton, IconTextLine, SeedSignerIconConstants, TextArea, GUIConstants, reflow_text_into_pages) @@ -86,11 +87,12 @@ def __post_init__(self): text="abcdefghijklmnopqrstuvwxyz", is_text_centered=False, font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, - font_size=GUIConstants.BUTTON_FONT_SIZE+4, + font_size=GUIConstants.get_button_font_size() + 4, screen_x=self.matches_list_x, screen_y=self.highlighted_row_y, width=self.canvas_width - self.matches_list_x + GUIConstants.COMPONENT_PADDING, height=int(0.75*GUIConstants.BUTTON_HEIGHT), + is_scrollable_text=False, ) arrow_button_width = GUIConstants.BUTTON_HEIGHT + GUIConstants.EDGE_PADDING @@ -115,7 +117,7 @@ def __post_init__(self): height=arrow_button_height, ) - self.word_font = Fonts.get_font(GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, GUIConstants.BUTTON_FONT_SIZE+4) + self.word_font = Fonts.get_font(GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, GUIConstants.get_button_font_size() + 4) (left, top, right, bottom) = self.word_font.getbbox("abcdefghijklmnopqrstuvwxyz", anchor="ls") self.word_font_height = -1 * top self.matches_list_row_height = self.word_font_height + GUIConstants.COMPONENT_PADDING @@ -148,7 +150,7 @@ def render_possible_matches(self, highlight_word=None): """ Internal helper method to render the KEY 1, 2, 3 word candidates. (has access to all vars in the parent's context) """ - # Render the possibler matches to a temp ImageDraw surface and paste it in + # Render the possible matches to a temp ImageDraw surface and paste it in # BUT render the currently highlighted match as a normal Button element if not self.possible_words: @@ -196,14 +198,14 @@ def render_possible_matches(self, highlight_word=None): break if row < highlighted_row: - cur_y = self.highlighted_row_y - GUIConstants.COMPONENT_PADDING - (highlighted_row - row - 1) * self.matches_list_row_height + self.cur_y = self.highlighted_row_y - GUIConstants.COMPONENT_PADDING - (highlighted_row - row - 1) * self.matches_list_row_height elif row > highlighted_row: - cur_y = self.highlighted_row_y + self.matches_list_highlight_button.height + (row - highlighted_row) * self.matches_list_row_height + self.cur_y = self.highlighted_row_y + self.matches_list_highlight_button.height + (row - highlighted_row) * self.matches_list_row_height # else draw the nth row draw.text( - (word_indent, cur_y), + (word_indent, self.cur_y), self.possible_words[i], fill="#ddd", font=self.word_font, @@ -248,183 +250,183 @@ def _run(self): release_keys=[HardwareButtonsConstants.KEY_PRESS, HardwareButtonsConstants.KEY2] ) - if self.is_input_in_top_nav: - if input == HardwareButtonsConstants.KEY_PRESS: - # User clicked the "back" arrow - return RET_CODE__BACK_BUTTON + with self.renderer.lock: + if self.is_input_in_top_nav: + if input == HardwareButtonsConstants.KEY_PRESS: + # User clicked the "back" arrow + return RET_CODE__BACK_BUTTON - elif input == HardwareButtonsConstants.KEY_UP: - input = Keyboard.ENTER_BOTTOM - self.is_input_in_top_nav = False - # Re-render it without the highlight - self.top_nav.left_button.is_selected = False - self.top_nav.left_button.render() + elif input == HardwareButtonsConstants.KEY_UP: + input = Keyboard.ENTER_BOTTOM + self.is_input_in_top_nav = False + # Re-render it without the highlight + self.top_nav.left_button.is_selected = False + self.top_nav.left_button.render() - elif input == HardwareButtonsConstants.KEY_DOWN: - input = Keyboard.ENTER_TOP - self.is_input_in_top_nav = False - # Re-render it without the highlight - self.top_nav.left_button.is_selected = False - self.top_nav.left_button.render() + elif input == HardwareButtonsConstants.KEY_DOWN: + input = Keyboard.ENTER_TOP + self.is_input_in_top_nav = False + # Re-render it without the highlight + self.top_nav.left_button.is_selected = False + self.top_nav.left_button.render() - elif input in [HardwareButtonsConstants.KEY_RIGHT, HardwareButtonsConstants.KEY_LEFT]: - # no action in this context - continue + elif input in [HardwareButtonsConstants.KEY_RIGHT, HardwareButtonsConstants.KEY_LEFT]: + # no action in this context + continue - ret_val = self.keyboard.update_from_input(input) + ret_val = self.keyboard.update_from_input(input) - if ret_val in Keyboard.EXIT_DIRECTIONS: - self.is_input_in_top_nav = True - self.top_nav.left_button.is_selected = True - self.top_nav.left_button.render() + if ret_val in Keyboard.EXIT_DIRECTIONS: + self.is_input_in_top_nav = True + self.top_nav.left_button.is_selected = True + self.top_nav.left_button.render() - elif ret_val in Keyboard.ADDITIONAL_KEYS: - if input == HardwareButtonsConstants.KEY_PRESS and ret_val == Keyboard.KEY_BACKSPACE["code"]: - self.letters = self.letters[:-2] - self.letters.append(" ") + elif ret_val in Keyboard.ADDITIONAL_KEYS: + if input == HardwareButtonsConstants.KEY_PRESS and ret_val == Keyboard.KEY_BACKSPACE["code"]: + self.letters = self.letters[:-2] + self.letters.append(" ") + + # Reactivate keys after deleting last letter + self.calc_possible_alphabet() + self.keyboard.update_active_keys(active_keys=self.possible_alphabet) + self.keyboard.render_keys() + + # Update the right-hand possible matches area + self.render_possible_matches() + + elif ret_val == Keyboard.KEY_BACKSPACE["code"]: + # We're just hovering over DEL but haven't clicked. Show blank (" ") + # in the live text entry display at the top. + self.letters = self.letters[:-1] + self.letters.append(" ") + + # Has the user made a final selection of a candidate word? + final_selection = None + if input == HardwareButtonsConstants.KEY1 and self.possible_words: + # Scroll the list up + self.selected_possible_words_index -= 1 + if self.selected_possible_words_index < 0: + self.selected_possible_words_index = 0 + + if not self.arrow_up_is_active: + # Flash the up arrow as selected + self.arrow_up_is_active = True + self.matches_list_up_button.is_selected = True + + elif input == HardwareButtonsConstants.KEY2: + if self.possible_words: + final_selection = self.possible_words[self.selected_possible_words_index] + + elif input == HardwareButtonsConstants.KEY3 and self.possible_words: + # Scroll the list down + self.selected_possible_words_index += 1 + if self.selected_possible_words_index >= len(self.possible_words): + self.selected_possible_words_index = len(self.possible_words) - 1 + + if not self.arrow_down_is_active: + # Flash the down arrow as selected + self.arrow_down_is_active = True + self.matches_list_down_button.is_selected = True + + if input is not HardwareButtonsConstants.KEY1 and self.arrow_up_is_active: + # Deactivate the UP arrow and redraw + self.arrow_up_is_active = False + self.matches_list_up_button.is_selected = False + + if input is not HardwareButtonsConstants.KEY3 and self.arrow_down_is_active: + # Deactivate the DOWN arrow and redraw + self.arrow_down_is_active = False + self.matches_list_down_button.is_selected = False + + if final_selection: + # Animate the selection storage, then return the word to the caller + self.letters = list(final_selection + " ") + self.render_possible_matches(highlight_word=final_selection) + self.text_entry_display.cur_text = ''.join(self.letters) + self.text_entry_display.render() + self.renderer.show_image() - # Reactivate keys after deleting last letter + return final_selection + + elif input == HardwareButtonsConstants.KEY_PRESS and ret_val in self.possible_alphabet: + # User has locked in the current letter + if self.letters[-1] != " ": + # We'll save that locked in letter next but for now update the + # live text entry display with blank (" ") so that we don't try + # to autocalc matches against a second copy of the letter they + # just selected. e.g. They KEY_PRESS on "s" to build "mus". If + # we advance the live block cursor AND display "s" in it, the + # current word would then be "muss" with no matches. If "mus" + # can get us to our match, we don't want it to disappear right + # as we KEY_PRESS. + self.letters.append(" ") + else: + # clicked same letter twice in a row. Because of the above, an + # immediate second click of the same letter would lock in "ap " + # (note the space) instead of "app". So we replace that trailing + # space with the correct repeated letter and then, as above, + # append a trailing blank. + self.letters = self.letters[:-1] + self.letters.append(ret_val) + self.letters.append(" ") + + # Recalc and deactivate keys after advancing self.calc_possible_alphabet() self.keyboard.update_active_keys(active_keys=self.possible_alphabet) + + if len(self.possible_alphabet) == 1: + # If there's only one possible letter left, select it + self.keyboard.set_selected_key(self.possible_alphabet[0]) + self.keyboard.render_keys() - - # Update the right-hand possible matches area - self.render_possible_matches() - - elif ret_val == Keyboard.KEY_BACKSPACE["code"]: - # We're just hovering over DEL but haven't clicked. Show blank (" ") - # in the live text entry display at the top. - self.letters = self.letters[:-1] - self.letters.append(" ") - - # Has the user made a final selection of a candidate word? - final_selection = None - if input == HardwareButtonsConstants.KEY1 and self.possible_words: - # Scroll the list up - self.selected_possible_words_index -= 1 - if self.selected_possible_words_index < 0: - self.selected_possible_words_index = 0 - - if not self.arrow_up_is_active: - # Flash the up arrow as selected - self.arrow_up_is_active = True - self.matches_list_up_button.is_selected = True - - elif input == HardwareButtonsConstants.KEY2: - if self.possible_words: - final_selection = self.possible_words[self.selected_possible_words_index] - - elif input == HardwareButtonsConstants.KEY3 and self.possible_words: - # Scroll the list down - self.selected_possible_words_index += 1 - if self.selected_possible_words_index >= len(self.possible_words): - self.selected_possible_words_index = len(self.possible_words) - 1 - - if not self.arrow_down_is_active: - # Flash the down arrow as selected - self.arrow_down_is_active = True - self.matches_list_down_button.is_selected = True - - if input is not HardwareButtonsConstants.KEY1 and self.arrow_up_is_active: - # Deactivate the UP arrow and redraw - self.arrow_up_is_active = False - self.matches_list_up_button.is_selected = False - - if input is not HardwareButtonsConstants.KEY3 and self.arrow_down_is_active: - # Deactivate the DOWN arrow and redraw - self.arrow_down_is_active = False - self.matches_list_down_button.is_selected = False - - if final_selection: - # Animate the selection storage, then return the word to the caller - self.letters = list(final_selection + " ") - self.render_possible_matches(highlight_word=final_selection) - self.text_entry_display.cur_text = ''.join(self.letters) - self.text_entry_display.render() - self.renderer.show_image() - return final_selection - - elif input == HardwareButtonsConstants.KEY_PRESS and ret_val in self.possible_alphabet: - # User has locked in the current letter - if self.letters[-1] != " ": - # We'll save that locked in letter next but for now update the - # live text entry display with blank (" ") so that we don't try - # to autocalc matches against a second copy of the letter they - # just selected. e.g. They KEY_PRESS on "s" to build "mus". If - # we advance the live block cursor AND display "s" in it, the - # current word would then be "muss" with no matches. If "mus" - # can get us to our match, we don't want it to disappear right - # as we KEY_PRESS. - self.letters.append(" ") - else: - # clicked same letter twice in a row. Because of the above, an - # immediate second click of the same letter would lock in "ap " - # (note the space) instead of "app". So we replace that trailing - # space with the correct repeated letter and then, as above, - # append a trailing blank. - self.letters = self.letters[:-1] - self.letters.append(ret_val) - self.letters.append(" ") - - # Recalc and deactivate keys after advancing - self.calc_possible_alphabet() - self.keyboard.update_active_keys(active_keys=self.possible_alphabet) - - if len(self.possible_alphabet) == 1: - # If there's only one possible letter left, select it - self.keyboard.set_selected_key(self.possible_alphabet[0]) - - self.keyboard.render_keys() - - elif input in HardwareButtonsConstants.KEYS__LEFT_RIGHT_UP_DOWN \ - or input in (Keyboard.ENTER_TOP, Keyboard.ENTER_BOTTOM): - if ret_val in self.possible_alphabet: - # Live joystick movement; haven't locked this new letter in yet. - # Replace the last letter w/the currently selected one. But don't - # call `calc_possible_alphabet()` because we want to still be able - # to freely float to a different letter; only update the active - # keyboard keys when a selection has been locked in (KEY_PRESS) or - # removed ("del"). - self.letters = self.letters[:-1] - self.letters.append(ret_val) - self.calc_possible_words() # live update our matches as we move - - else: - # We've navigated to a deactivated letter - pass + elif input in HardwareButtonsConstants.KEYS__LEFT_RIGHT_UP_DOWN \ + or input in (Keyboard.ENTER_TOP, Keyboard.ENTER_BOTTOM): + if ret_val in self.possible_alphabet: + # Live joystick movement; haven't locked this new letter in yet. + # Replace the last letter w/the currently selected one. But don't + # call `calc_possible_alphabet()` because we want to still be able + # to freely float to a different letter; only update the active + # keyboard keys when a selection has been locked in (KEY_PRESS) or + # removed ("del"). + self.letters = self.letters[:-1] + self.letters.append(ret_val) + self.calc_possible_words() # live update our matches as we move + + else: + # We've navigated to a deactivated letter + pass - # Render the text entry display and cursor block - self.text_entry_display.cur_text = ''.join(self.letters) - self.text_entry_display.render() + # Render the text entry display and cursor block + self.text_entry_display.cur_text = ''.join(self.letters) + self.text_entry_display.render() - # Update the right-hand possible matches area - self.render_possible_matches() + # Update the right-hand possible matches area + self.render_possible_matches() - # Now issue one call to send the pixels to the screen - self.renderer.show_image() + # Now issue one call to send the pixels to the screen + self.renderer.show_image() @dataclass class SeedFinalizeScreen(ButtonListScreen): fingerprint: str = None - title: str = "Finalize Seed" is_bottom_list: bool = True button_data: list = None def __post_init__(self): self.show_back_button = False - + self.title = _("Finalize Seed") super().__post_init__() self.fingerprint_icontl = IconTextLine( icon_name=SeedSignerIconConstants.FINGERPRINT, icon_color=GUIConstants.INFO_COLOR, icon_size=GUIConstants.ICON_FONT_SIZE + 12, - label_text="fingerprint", + label_text=_("fingerprint"), value_text=self.fingerprint, - font_size=GUIConstants.BODY_FONT_SIZE + 2, + font_size=GUIConstants.get_body_font_size() + 2, is_text_centered=True, screen_y=self.top_nav.height + int((self.buttons[0].screen_y - self.top_nav.height) / 2) - 30 ) @@ -434,7 +436,6 @@ def __post_init__(self): @dataclass class SeedOptionsScreen(ButtonListScreen): - # Customize defaults fingerprint: str = None has_passphrase: bool = False @@ -449,6 +450,24 @@ def __post_init__(self): +@dataclass +class SeedBackupScreen(ButtonListScreen): + has_passphrase: bool = False + + def __post_init__(self): + self.title = _("Backup Seed") + self.is_bottom_list = True + super().__post_init__() + + if self.has_passphrase: + self.components.append(TextArea( + # TRANSLATOR_NOTE: Additional explainer for the two seed backup options (mnemonic phrase and SeedQR). + text=_("Backups do not include your passphrase."), + screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING, + )) + + + @dataclass class SeedWordsScreen(WarningEdgesMixin, ButtonListScreen): words: List[str] = None @@ -459,6 +478,8 @@ class SeedWordsScreen(WarningEdgesMixin, ButtonListScreen): def __post_init__(self): + # TRANSLATOR_NOTE: Displays the page number and total: (e.g. page 1 of 6) + self.title = _("Seed Words: {}/{}").format(self.page_index + 1, self.num_pages) super().__post_init__() words_per_page = len(self.words) @@ -469,7 +490,7 @@ def __post_init__(self): # Have to supersample the whole body since it's all at the small font size supersampling_factor = 1 - font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, (GUIConstants.TOP_NAV_TITLE_FONT_SIZE + 2) * supersampling_factor) + font = Fonts.get_font(GUIConstants.get_body_font_name(), (GUIConstants.get_top_nav_title_font_size() + 2) * supersampling_factor) # Calc horizontal center based on longest word max_word_width = 0 @@ -479,7 +500,7 @@ def __post_init__(self): max_word_width = right # Measure the max digit height for the numbering boxes, from baseline - number_font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.BUTTON_FONT_SIZE * supersampling_factor) + number_font = Fonts.get_font(GUIConstants.get_body_font_name(), GUIConstants.get_button_font_size() * supersampling_factor) (left, top, right, bottom) = number_font.getbbox("24", anchor="ls") number_height = -1 * top number_width = right @@ -524,7 +545,7 @@ def __post_init__(self): number_box_y += number_box_height + (int(1.5*GUIConstants.COMPONENT_PADDING) * supersampling_factor) # Resize to target and sharpen final image - self.body_img = self.body_img.resize((self.canvas_width, self.body_height), Image.LANCZOS) + self.body_img = self.body_img.resize((self.canvas_width, self.body_height), Image.Resampling.LANCZOS) self.body_img = self.body_img.filter(ImageFilter.SHARPEN) self.paste_images.append((self.body_img, (self.body_x, self.body_y))) @@ -533,7 +554,7 @@ def __post_init__(self): @dataclass class SeedBIP85SelectChildIndexScreen(KeyboardScreen): def __post_init__(self): - self.title = "BIP-85 Index" + self.title = _("BIP-85 Index") self.user_input = "" # Specify the keys in the keyboard @@ -550,13 +571,13 @@ def __post_init__(self): @dataclass class SeedWordsBackupTestPromptScreen(ButtonListScreen): def __post_init__(self): - self.title = "Verify Backup?" + self.title = _("Verify Backup?") self.show_back_button = False self.is_bottom_list = True super().__post_init__() self.components.append(TextArea( - text="Optionally verify that your mnemonic backup is correct.", + text=_("Optionally verify that your mnemonic backup is correct."), screen_y=self.top_nav.height, is_text_centered=True, )) @@ -566,7 +587,7 @@ def __post_init__(self): @dataclass class SeedExportXpubCustomDerivationScreen(KeyboardScreen): def __post_init__(self): - self.title = "Derivation Path" + self.title = _("Derivation Path") self.user_input = "m/" # Specify the keys in the keyboard @@ -583,17 +604,16 @@ def __post_init__(self): @dataclass class SeedExportXpubDetailsScreen(WarningEdgesMixin, ButtonListScreen): # Customize defaults - title: str = "Xpub Details" is_bottom_list: bool = True fingerprint: str = None has_passphrase: bool = False derivation_path: str = "m/84'/0'/0'" xpub: str = "zpub6r..." - button_data=["Export Xpub"] def __post_init__(self): # Programmatically set up other args - self.button_data = ["Export Xpub"] + self.button_data = [ButtonOption("Export Xpub")] + self.title = _("Xpub Details") # Initialize the base class super().__post_init__() @@ -602,7 +622,8 @@ def __post_init__(self): self.fingerprint_line = IconTextLine( icon_name=SeedSignerIconConstants.FINGERPRINT, icon_color=GUIConstants.INFO_COLOR, - label_text="Fingerprint", + # TRANSLATOR_NOTE: Short for "BIP32 Master Fingerprint" + label_text=_("Fingerprint"), value_text=self.fingerprint, screen_x=GUIConstants.COMPONENT_PADDING, screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING, @@ -612,7 +633,8 @@ def __post_init__(self): self.derivation_line = IconTextLine( icon_name=SeedSignerIconConstants.DERIVATION, icon_color=GUIConstants.INFO_COLOR, - label_text="Derivation", + # TRANSLATOR_NOTE: Short for "Derivation Path" + label_text=_("Derivation"), value_text=self.derivation_path, screen_x=GUIConstants.COMPONENT_PADDING, screen_y=self.components[-1].screen_y + self.components[-1].height + int(1.5*GUIConstants.COMPONENT_PADDING), @@ -622,10 +644,11 @@ def __post_init__(self): self.xpub_line = IconTextLine( icon_name=FontAwesomeIconConstants.X, icon_color=GUIConstants.INFO_COLOR, - label_text="Xpub", + # TRANSLATOR_NOTE: Short for "BIP32 Extended Public Key" + label_text=_("Xpub"), value_text=f"{self.xpub[:18]}...", font_name=GUIConstants.FIXED_WIDTH_FONT_NAME, - font_size=GUIConstants.BODY_FONT_SIZE + 2, + font_size=GUIConstants.get_body_font_size() + 2, screen_x=GUIConstants.COMPONENT_PADDING, screen_y=self.components[-1].screen_y + self.components[-1].height + int(1.5*GUIConstants.COMPONENT_PADDING), ) @@ -635,9 +658,11 @@ def __post_init__(self): @dataclass class SeedAddPassphraseScreen(BaseTopNavScreen): - title: str = "BIP-39 Passphrase" passphrase: str = "" + # Only used by the screenshot generator + initial_keyboard: str = None + KEYBOARD__LOWERCASE_BUTTON_TEXT = "abc" KEYBOARD__UPPERCASE_BUTTON_TEXT = "ABC" KEYBOARD__DIGITS_BUTTON_TEXT = "123" @@ -646,6 +671,7 @@ class SeedAddPassphraseScreen(BaseTopNavScreen): def __post_init__(self): + self.title = _("BIP-39 Passphrase") super().__post_init__() keys_lower = "abcdefghijklmnopqrstuvwxyz" @@ -794,20 +820,22 @@ def __post_init__(self): text=self.KEYBOARD__UPPERCASE_BUTTON_TEXT, is_text_centered=False, font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, - font_size=GUIConstants.BUTTON_FONT_SIZE + 4, + font_size=GUIConstants.get_button_font_size() + 4, width=self.right_panel_buttons_width, screen_x=hw_button_x, screen_y=hw_button_y - 3*GUIConstants.COMPONENT_PADDING - GUIConstants.BUTTON_HEIGHT, + is_scrollable_text=False, ) self.hw_button2 = Button( text=self.KEYBOARD__DIGITS_BUTTON_TEXT, is_text_centered=False, font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, - font_size=GUIConstants.BUTTON_FONT_SIZE + 4, + font_size=GUIConstants.get_button_font_size() + 4, width=self.right_panel_buttons_width, screen_x=hw_button_x, screen_y=hw_button_y, + is_scrollable_text=False, ) self.hw_button3 = IconButton( @@ -816,24 +844,44 @@ def __post_init__(self): width=self.right_panel_buttons_width, screen_x=hw_button_x, screen_y=hw_button_y + 3*GUIConstants.COMPONENT_PADDING + GUIConstants.BUTTON_HEIGHT, + is_scrollable_text=False, ) def _render(self): super()._render() + # Change from the default lowercase keyboard for the screenshot generator + if self.initial_keyboard == self.KEYBOARD__UPPERCASE_BUTTON_TEXT: + cur_keyboard = self.keyboard_ABC + self.hw_button1.text = self.KEYBOARD__LOWERCASE_BUTTON_TEXT + + elif self.initial_keyboard == self.KEYBOARD__DIGITS_BUTTON_TEXT: + cur_keyboard = self.keyboard_digits + self.hw_button2.text = self.KEYBOARD__SYMBOLS_1_BUTTON_TEXT + + elif self.initial_keyboard == self.KEYBOARD__SYMBOLS_1_BUTTON_TEXT: + cur_keyboard = self.keyboard_symbols_1 + self.hw_button2.text = self.KEYBOARD__SYMBOLS_2_BUTTON_TEXT + + elif self.initial_keyboard == self.KEYBOARD__SYMBOLS_2_BUTTON_TEXT: + cur_keyboard = self.keyboard_symbols_2 + self.hw_button2.text = self.KEYBOARD__DIGITS_BUTTON_TEXT + + else: + cur_keyboard = self.keyboard_abc + self.text_entry_display.render() self.hw_button1.render() self.hw_button2.render() self.hw_button3.render() - self.keyboard_abc.render_keys() + cur_keyboard.render_keys() self.renderer.show_image() def _run(self): cursor_position = len(self.passphrase) - cur_keyboard = self.keyboard_abc cur_button1_text = self.KEYBOARD__UPPERCASE_BUTTON_TEXT cur_button2_text = self.KEYBOARD__DIGITS_BUTTON_TEXT @@ -848,169 +896,171 @@ def _run(self): keyboard_swap = False - # Check our two possible exit conditions - # TODO: note the unusual return value, consider refactoring to a Response object in the future - if input == HardwareButtonsConstants.KEY3: - # Save! - # First light up key3 - self.hw_button3.is_selected = True - self.hw_button3.render() - self.renderer.show_image() - return dict(passphrase=self.passphrase) - - elif input == HardwareButtonsConstants.KEY_PRESS and self.top_nav.is_selected: - # Back button clicked - return dict(passphrase=self.passphrase, is_back_button=True) - - # Check for keyboard swaps - if input == HardwareButtonsConstants.KEY1: - # First light up key1 - self.hw_button1.is_selected = True - self.hw_button1.render() - - # Return to the same button2 keyboard, if applicable - if cur_keyboard == self.keyboard_digits: - cur_button2_text = self.KEYBOARD__DIGITS_BUTTON_TEXT - elif cur_keyboard == self.keyboard_symbols_1: - cur_button2_text = self.KEYBOARD__SYMBOLS_1_BUTTON_TEXT - elif cur_keyboard == self.keyboard_symbols_2: - cur_button2_text = self.KEYBOARD__SYMBOLS_2_BUTTON_TEXT - - if cur_button1_text == self.KEYBOARD__LOWERCASE_BUTTON_TEXT: - self.keyboard_abc.set_selected_key_indices(x=cur_keyboard.selected_key["x"], y=cur_keyboard.selected_key["y"]) - cur_keyboard = self.keyboard_abc - cur_button1_text = self.KEYBOARD__UPPERCASE_BUTTON_TEXT - else: - self.keyboard_ABC.set_selected_key_indices(x=cur_keyboard.selected_key["x"], y=cur_keyboard.selected_key["y"]) - cur_keyboard = self.keyboard_ABC - cur_button1_text = self.KEYBOARD__LOWERCASE_BUTTON_TEXT - cur_keyboard.render_keys() - - # Show the changes; this loop will have two renders - self.renderer.show_image() - - keyboard_swap = True - ret_val = None + with self.renderer.lock: + # Check our two possible exit conditions + # TODO: note the unusual return value, consider refactoring to a Response object in the future + if input == HardwareButtonsConstants.KEY3: + # Save! + # First light up key3 + self.hw_button3.is_selected = True + self.hw_button3.render() + self.renderer.show_image() + return dict(passphrase=self.passphrase) + + elif input == HardwareButtonsConstants.KEY_PRESS and self.top_nav.is_selected: + # Back button clicked + return dict(passphrase=self.passphrase, is_back_button=True) + + # Check for keyboard swaps + if input == HardwareButtonsConstants.KEY1: + # First light up key1 + self.hw_button1.is_selected = True + self.hw_button1.render() + + # Return to the same button2 keyboard, if applicable + if cur_keyboard == self.keyboard_digits: + cur_button2_text = self.KEYBOARD__DIGITS_BUTTON_TEXT + elif cur_keyboard == self.keyboard_symbols_1: + cur_button2_text = self.KEYBOARD__SYMBOLS_1_BUTTON_TEXT + elif cur_keyboard == self.keyboard_symbols_2: + cur_button2_text = self.KEYBOARD__SYMBOLS_2_BUTTON_TEXT + + if cur_button1_text == self.KEYBOARD__LOWERCASE_BUTTON_TEXT: + self.keyboard_abc.set_selected_key_indices(x=cur_keyboard.selected_key["x"], y=cur_keyboard.selected_key["y"]) + cur_keyboard = self.keyboard_abc + cur_button1_text = self.KEYBOARD__UPPERCASE_BUTTON_TEXT + else: + self.keyboard_ABC.set_selected_key_indices(x=cur_keyboard.selected_key["x"], y=cur_keyboard.selected_key["y"]) + cur_keyboard = self.keyboard_ABC + cur_button1_text = self.KEYBOARD__LOWERCASE_BUTTON_TEXT + cur_keyboard.render_keys() - elif input == HardwareButtonsConstants.KEY2: - # First light up key2 - self.hw_button2.is_selected = True - self.hw_button2.render() - self.renderer.show_image() + # Show the changes; this loop will have two renders + self.renderer.show_image() - # And reset for next redraw - self.hw_button2.is_selected = False + keyboard_swap = True + ret_val = None - # Return to the same button1 keyboard, if applicable - if cur_keyboard == self.keyboard_abc: - cur_button1_text = self.KEYBOARD__LOWERCASE_BUTTON_TEXT - elif cur_keyboard == self.keyboard_ABC: - cur_button1_text = self.KEYBOARD__UPPERCASE_BUTTON_TEXT + elif input == HardwareButtonsConstants.KEY2: + # First light up key2 + self.hw_button2.is_selected = True + self.hw_button2.render() + self.renderer.show_image() - if cur_button2_text == self.KEYBOARD__DIGITS_BUTTON_TEXT: - self.keyboard_digits.set_selected_key_indices(x=cur_keyboard.selected_key["x"], y=cur_keyboard.selected_key["y"]) - cur_keyboard = self.keyboard_digits - cur_keyboard.render_keys() - cur_button2_text = self.KEYBOARD__SYMBOLS_1_BUTTON_TEXT - elif cur_button2_text == self.KEYBOARD__SYMBOLS_1_BUTTON_TEXT: - self.keyboard_symbols_1.set_selected_key_indices(x=cur_keyboard.selected_key["x"], y=cur_keyboard.selected_key["y"]) - cur_keyboard = self.keyboard_symbols_1 + # And reset for next redraw + self.hw_button2.is_selected = False + + # Return to the same button1 keyboard, if applicable + if cur_keyboard == self.keyboard_abc: + cur_button1_text = self.KEYBOARD__LOWERCASE_BUTTON_TEXT + elif cur_keyboard == self.keyboard_ABC: + cur_button1_text = self.KEYBOARD__UPPERCASE_BUTTON_TEXT + + if cur_button2_text == self.KEYBOARD__DIGITS_BUTTON_TEXT: + self.keyboard_digits.set_selected_key_indices(x=cur_keyboard.selected_key["x"], y=cur_keyboard.selected_key["y"]) + cur_keyboard = self.keyboard_digits + cur_keyboard.render_keys() + cur_button2_text = self.KEYBOARD__SYMBOLS_1_BUTTON_TEXT + elif cur_button2_text == self.KEYBOARD__SYMBOLS_1_BUTTON_TEXT: + self.keyboard_symbols_1.set_selected_key_indices(x=cur_keyboard.selected_key["x"], y=cur_keyboard.selected_key["y"]) + cur_keyboard = self.keyboard_symbols_1 + cur_keyboard.render_keys() + cur_button2_text = self.KEYBOARD__SYMBOLS_2_BUTTON_TEXT + elif cur_button2_text == self.KEYBOARD__SYMBOLS_2_BUTTON_TEXT: + self.keyboard_symbols_2.set_selected_key_indices(x=cur_keyboard.selected_key["x"], y=cur_keyboard.selected_key["y"]) + cur_keyboard = self.keyboard_symbols_2 + cur_keyboard.render_keys() + cur_button2_text = self.KEYBOARD__DIGITS_BUTTON_TEXT cur_keyboard.render_keys() - cur_button2_text = self.KEYBOARD__SYMBOLS_2_BUTTON_TEXT - elif cur_button2_text == self.KEYBOARD__SYMBOLS_2_BUTTON_TEXT: - self.keyboard_symbols_2.set_selected_key_indices(x=cur_keyboard.selected_key["x"], y=cur_keyboard.selected_key["y"]) - cur_keyboard = self.keyboard_symbols_2 - cur_keyboard.render_keys() - cur_button2_text = self.KEYBOARD__DIGITS_BUTTON_TEXT - cur_keyboard.render_keys() - # Show the changes; this loop will have two renders - self.renderer.show_image() + # Show the changes; this loop will have two renders + self.renderer.show_image() - keyboard_swap = True - ret_val = None + keyboard_swap = True + ret_val = None - else: - # Process normal input - if input in [HardwareButtonsConstants.KEY_UP, HardwareButtonsConstants.KEY_DOWN] and self.top_nav.is_selected: - # We're navigating off the previous button - self.top_nav.is_selected = False + else: + # Process normal input + if input in [HardwareButtonsConstants.KEY_UP, HardwareButtonsConstants.KEY_DOWN] and self.top_nav.is_selected: + # We're navigating off the previous button + self.top_nav.is_selected = False + self.top_nav.render_buttons() + + # Override the actual input w/an ENTER signal for the Keyboard + if input == HardwareButtonsConstants.KEY_DOWN: + input = Keyboard.ENTER_TOP + else: + input = Keyboard.ENTER_BOTTOM + elif input in [HardwareButtonsConstants.KEY_LEFT, HardwareButtonsConstants.KEY_RIGHT] and self.top_nav.is_selected: + # ignore + continue + + ret_val = cur_keyboard.update_from_input(input) + + # Now process the result from the keyboard + if ret_val in Keyboard.EXIT_DIRECTIONS: + self.top_nav.is_selected = True self.top_nav.render_buttons() - # Override the actual input w/an ENTER signal for the Keyboard - if input == HardwareButtonsConstants.KEY_DOWN: - input = Keyboard.ENTER_TOP - else: - input = Keyboard.ENTER_BOTTOM - elif input in [HardwareButtonsConstants.KEY_LEFT, HardwareButtonsConstants.KEY_RIGHT] and self.top_nav.is_selected: - # ignore - continue - - ret_val = cur_keyboard.update_from_input(input) - - # Now process the result from the keyboard - if ret_val in Keyboard.EXIT_DIRECTIONS: - self.top_nav.is_selected = True - self.top_nav.render_buttons() - - elif ret_val in Keyboard.ADDITIONAL_KEYS and input == HardwareButtonsConstants.KEY_PRESS: - if ret_val == Keyboard.KEY_BACKSPACE["code"]: - if cursor_position == 0: - pass - elif cursor_position == len(self.passphrase): - self.passphrase = self.passphrase[:-1] - else: - self.passphrase = self.passphrase[:cursor_position - 1] + self.passphrase[cursor_position:] - - cursor_position -= 1 - - elif ret_val == Keyboard.KEY_CURSOR_LEFT["code"]: - cursor_position -= 1 - if cursor_position < 0: - cursor_position = 0 - - elif ret_val == Keyboard.KEY_CURSOR_RIGHT["code"]: - cursor_position += 1 - if cursor_position > len(self.passphrase): - cursor_position = len(self.passphrase) - - elif ret_val == Keyboard.KEY_SPACE["code"]: + elif ret_val in Keyboard.ADDITIONAL_KEYS and input == HardwareButtonsConstants.KEY_PRESS: + if ret_val == Keyboard.KEY_BACKSPACE["code"]: + if cursor_position == 0: + pass + elif cursor_position == len(self.passphrase): + self.passphrase = self.passphrase[:-1] + else: + self.passphrase = self.passphrase[:cursor_position - 1] + self.passphrase[cursor_position:] + + cursor_position -= 1 + + elif ret_val == Keyboard.KEY_CURSOR_LEFT["code"]: + cursor_position -= 1 + if cursor_position < 0: + cursor_position = 0 + + elif ret_val == Keyboard.KEY_CURSOR_RIGHT["code"]: + cursor_position += 1 + if cursor_position > len(self.passphrase): + cursor_position = len(self.passphrase) + + elif ret_val == Keyboard.KEY_SPACE["code"]: + if cursor_position == len(self.passphrase): + self.passphrase += " " + else: + self.passphrase = self.passphrase[:cursor_position] + " " + self.passphrase[cursor_position:] + cursor_position += 1 + + # Update the text entry display and cursor + self.text_entry_display.render(self.passphrase, cursor_position) + + elif input == HardwareButtonsConstants.KEY_PRESS and ret_val not in Keyboard.ADDITIONAL_KEYS: + # User has locked in the current letter if cursor_position == len(self.passphrase): - self.passphrase += " " + self.passphrase += ret_val else: - self.passphrase = self.passphrase[:cursor_position] + " " + self.passphrase[cursor_position:] + self.passphrase = self.passphrase[:cursor_position] + ret_val + self.passphrase[cursor_position:] cursor_position += 1 - # Update the text entry display and cursor - self.text_entry_display.render(self.passphrase, cursor_position) + # Update the text entry display and cursor + self.text_entry_display.render(self.passphrase, cursor_position) - elif input == HardwareButtonsConstants.KEY_PRESS and ret_val not in Keyboard.ADDITIONAL_KEYS: - # User has locked in the current letter - if cursor_position == len(self.passphrase): - self.passphrase += ret_val - else: - self.passphrase = self.passphrase[:cursor_position] + ret_val + self.passphrase[cursor_position:] - cursor_position += 1 - - # Update the text entry display and cursor - self.text_entry_display.render(self.passphrase, cursor_position) - - elif input in HardwareButtonsConstants.KEYS__LEFT_RIGHT_UP_DOWN or keyboard_swap: - # Live joystick movement; haven't locked this new letter in yet. - # Leave current spot blank for now. Only update the active keyboard keys - # when a selection has been locked in (KEY_PRESS) or removed ("del"). - pass - - if keyboard_swap: - # Show the hw buttons' updated text and not active state - self.hw_button1.text = cur_button1_text - self.hw_button2.text = cur_button2_text - self.hw_button1.is_selected = False - self.hw_button2.is_selected = False - self.hw_button1.render() - self.hw_button2.render() + elif input in HardwareButtonsConstants.KEYS__LEFT_RIGHT_UP_DOWN or keyboard_swap: + # Live joystick movement; haven't locked this new letter in yet. + # Leave current spot blank for now. Only update the active keyboard keys + # when a selection has been locked in (KEY_PRESS) or removed ("del"). + pass + + if keyboard_swap: + # Show the hw buttons' updated text and not active state + self.hw_button1.text = cur_button1_text + self.hw_button2.text = cur_button2_text + self.hw_button1.is_selected = False + self.hw_button2.is_selected = False + self.hw_button1.render() + self.hw_button2.render() - self.renderer.show_image() + self.renderer.show_image() + @@ -1022,7 +1072,7 @@ class SeedReviewPassphraseScreen(ButtonListScreen): def __post_init__(self): # Customize defaults - self.title = "Verify Passphrase" + self.title = _("Verify Passphrase") self.is_bottom_list = True super().__post_init__() @@ -1030,17 +1080,18 @@ def __post_init__(self): self.components.append(IconTextLine( icon_name=SeedSignerIconConstants.FINGERPRINT, icon_color=GUIConstants.INFO_COLOR, - label_text="changes fingerprint", + # TRANSLATOR_NOTE: Describes the effect of applying a BIP-39 passphrase; it changes the seed's fingerprint + label_text=_("changes fingerprint"), value_text=f"{self.fingerprint_without} >> {self.fingerprint_with}", is_text_centered=True, - screen_y = self.buttons[0].screen_y - GUIConstants.COMPONENT_PADDING - int(GUIConstants.BODY_FONT_SIZE*2.5) + screen_y = self.buttons[0].screen_y - GUIConstants.COMPONENT_PADDING - int(GUIConstants.get_body_font_size()*2.5) )) if self.passphrase != self.passphrase.strip() or " " in self.passphrase: self.passphrase = self.passphrase.replace(" ", "\u2589") available_height = self.components[-1].screen_y - self.top_nav.height + GUIConstants.COMPONENT_PADDING - max_font_size = GUIConstants.TOP_NAV_TITLE_FONT_SIZE + 8 - min_font_size = GUIConstants.TOP_NAV_TITLE_FONT_SIZE - 4 + max_font_size = GUIConstants.get_top_nav_title_font_size() + 8 + min_font_size = GUIConstants.get_top_nav_title_font_size() - 4 font_size = max_font_size max_lines = 3 passphrase = [self.passphrase] @@ -1073,6 +1124,7 @@ def __post_init__(self): text=line, font_name=GUIConstants.FIXED_WIDTH_FONT_NAME, font_size=font_size, + font_color="orange", is_text_centered=True, screen_y=screen_y, allow_text_overflow=True @@ -1088,16 +1140,20 @@ def __post_init__(self): super().__post_init__() self.components.append(IconTextLine( - label_text="Standard", - value_text="BIP-39 wordlist indices", + # TRANSLATOR_NOTE: Refers to the SeedQR type: Standard or Compact + label_text=_("Standard"), + # TRANSLATOR_NOTE: Briefly explains the Standard SeedQR data format + value_text=_("BIP-39 wordlist indices"), is_text_centered=False, auto_line_break=True, screen_x=GUIConstants.EDGE_PADDING, screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING, )) self.components.append(IconTextLine( - label_text="Compact", - value_text="Raw entropy bits", + # TRANSLATOR_NOTE: Refers to the SeedQR type: Standard or Compact + label_text=_("Compact"), + # TRANSLATOR_NOTE: Briefly explains the Compact SeedQR data format + value_text=_("Raw entropy bits"), is_text_centered=False, screen_x=GUIConstants.EDGE_PADDING, screen_y=self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING, @@ -1111,8 +1167,10 @@ class SeedTranscribeSeedQRWholeQRScreen(WarningEdgesMixin, ButtonListScreen): num_modules: int = None def __post_init__(self): - self.title = "Transcribe SeedQR" - self.button_data = [f"Begin {self.num_modules}x{self.num_modules}"] + self.title = _("Transcribe SeedQR") + # TRANSLATOR_NOTE: Refers to the QR code size: 21x21, 25x25, or 29x29 + button_label = _("Begin {}x{}").format(self.num_modules, self.num_modules) + self.button_data = [ButtonOption(button_label)] self.is_bottom_list = True self.status_color = GUIConstants.DIRE_WARNING_COLOR super().__post_init__() @@ -1137,6 +1195,8 @@ def __post_init__(self): class SeedTranscribeSeedQRZoomedInScreen(BaseScreen): qr_data: str = None num_modules: int = None + initial_block_x: int = 0 + initial_block_y: int = 0 def __post_init__(self): super().__post_init__() @@ -1189,39 +1249,31 @@ def __post_init__(self): draw.line((self.mask_width, self.mask_height, self.canvas_width - self.mask_width, self.mask_height), fill=GUIConstants.ACCENT_COLOR) draw.line((self.mask_width, self.canvas_height - self.mask_height, self.canvas_width - self.mask_width, self.canvas_height - self.mask_height), fill=GUIConstants.ACCENT_COLOR) - msg = "click to exit" - font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.BODY_FONT_SIZE) + msg = _("click to exit") + font = Fonts.get_font(GUIConstants.get_body_font_name(), GUIConstants.get_body_font_size()) (left, top, right, bottom) = font.getbbox(msg, anchor="ls") - msg_height = -1 * top - msg_width = right - # draw.rectangle( - # ( - # int((self.canvas_width - msg_width)/2 - GUIConstants.COMPONENT_PADDING), - # self.canvas_height - msg_height - GUIConstants.COMPONENT_PADDING, - # int((self.canvas_width + msg_width)/2 + GUIConstants.COMPONENT_PADDING), - # self.canvas_height - # ), - # fill=GUIConstants.BACKGROUND_COLOR, - # ) - # draw.text( - # (int(self.canvas_width/2), self.canvas_height - int(GUIConstants.COMPONENT_PADDING/2)), - # msg, - # fill=GUIConstants.BODY_FONT_COLOR, - # font=font, - # anchor="ms" # Middle, baSeline - # ) - TextArea( - canvas=self.block_mask, - image_draw=draw, - text=msg, - background_color=GUIConstants.BACKGROUND_COLOR, - is_text_centered=True, - screen_y=self.canvas_height - GUIConstants.BODY_FONT_SIZE - GUIConstants.COMPONENT_PADDING, - height=GUIConstants.BODY_FONT_SIZE + GUIConstants.COMPONENT_PADDING, - ).render() + msg_height = -1 * top + GUIConstants.COMPONENT_PADDING + msg_width = right + 2*GUIConstants.COMPONENT_PADDING + draw.rectangle( + ( + int((self.canvas_width - msg_width)/2), + self.canvas_height - msg_height, + int((self.canvas_width + msg_width)/2), + self.canvas_height + ), + fill=GUIConstants.BACKGROUND_COLOR, + ) + draw.text( + (int(self.canvas_width/2), self.canvas_height - int(GUIConstants.COMPONENT_PADDING/2)), + msg, + fill=GUIConstants.BODY_FONT_COLOR, + font=font, + anchor="ms" # Middle, baSeline + ) - def draw_block_labels(self, cur_block_x, cur_block_y): + + def draw_block_labels(self): # Create overlay for block labels (e.g. "D-5") block_labels_x = ["1", "2", "3", "4", "5", "6"] block_labels_y = ["A", "B", "C", "D", "E", "F"] @@ -1231,8 +1283,8 @@ def draw_block_labels(self, cur_block_x, cur_block_y): draw.rectangle((self.mask_width, 0, self.canvas_width - self.mask_width, self.pixels_per_block), fill=GUIConstants.ACCENT_COLOR) draw.rectangle((0, self.mask_height, self.pixels_per_block, self.canvas_height - self.mask_height), fill=GUIConstants.ACCENT_COLOR) - label_font = Fonts.get_font(GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, GUIConstants.TOP_NAV_TITLE_FONT_SIZE + 8) - x_label = block_labels_x[cur_block_x] + label_font = Fonts.get_font(GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, 28) + x_label = block_labels_x[self.cur_block_x] (left, top, right, bottom) = label_font.getbbox(x_label, anchor="ls") x_label_height = -1 * top @@ -1244,7 +1296,7 @@ def draw_block_labels(self, cur_block_x, cur_block_y): anchor="ms", # Middle, baSeline ) - y_label = block_labels_y[cur_block_y] + y_label = block_labels_y[self.cur_block_y] (left, top, right, bottom) = label_font.getbbox(y_label, anchor="ls") y_label_height = -1 * top draw.text( @@ -1258,63 +1310,66 @@ def draw_block_labels(self, cur_block_x, cur_block_y): return block_labels - def _run(self): + def _render(self): # Track our current coordinates for the upper left corner of our view - cur_block_x = 0 - cur_block_y = 0 - cur_x = self.qr_border * self.pixels_per_block - self.mask_width - cur_y = self.qr_border * self.pixels_per_block - self.mask_height - next_x = cur_x - next_y = cur_y + self.cur_block_x = self.initial_block_x + self.cur_block_y = self.initial_block_y + self.cur_x = (self.cur_block_x * self.qr_blocks_per_zoom * self.pixels_per_block) + self.qr_border * self.pixels_per_block - self.mask_width + self.cur_y = (self.cur_block_y * self.qr_blocks_per_zoom * self.pixels_per_block) + self.qr_border * self.pixels_per_block - self.mask_height + self.next_x = self.cur_x + self.next_y = self.cur_y - block_labels = self.draw_block_labels(0, 0) + block_labels = self.draw_block_labels() self.renderer.show_image( - self.qr_image.crop((cur_x, cur_y, cur_x + self.canvas_width, cur_y + self.canvas_height)), + self.qr_image.crop((self.cur_x, self.cur_y, self.cur_x + self.canvas_width, self.cur_y + self.canvas_height)), alpha_overlay=Image.alpha_composite(self.block_mask, block_labels) ) + + def _run(self): while True: input = self.hw_inputs.wait_for(HardwareButtonsConstants.KEYS__LEFT_RIGHT_UP_DOWN + HardwareButtonsConstants.KEYS__ANYCLICK) if input == HardwareButtonsConstants.KEY_RIGHT: - next_x = cur_x + self.qr_blocks_per_zoom * self.pixels_per_block - cur_block_x += 1 - if next_x > self.qr_width - self.canvas_width: - next_x = cur_x - cur_block_x -= 1 + self.next_x = self.cur_x + self.qr_blocks_per_zoom * self.pixels_per_block + self.cur_block_x += 1 + if self.next_x > self.qr_width - self.canvas_width: + self.next_x = self.cur_x + self.cur_block_x -= 1 elif input == HardwareButtonsConstants.KEY_LEFT: - next_x = cur_x - self.qr_blocks_per_zoom * self.pixels_per_block - cur_block_x -= 1 - if next_x < 0: - next_x = cur_x - cur_block_x += 1 + self.next_x = self.cur_x - self.qr_blocks_per_zoom * self.pixels_per_block + self.cur_block_x -= 1 + if self.next_x < 0: + self.next_x = self.cur_x + self.cur_block_x += 1 elif input == HardwareButtonsConstants.KEY_DOWN: - next_y = cur_y + self.qr_blocks_per_zoom * self.pixels_per_block - cur_block_y += 1 - if next_y > self.height - self.canvas_height: - next_y = cur_y - cur_block_y -= 1 + self.next_y = self.cur_y + self.qr_blocks_per_zoom * self.pixels_per_block + self.cur_block_y += 1 + if self.next_y > self.height - self.canvas_height: + self.next_y = self.cur_y + self.cur_block_y -= 1 elif input == HardwareButtonsConstants.KEY_UP: - next_y = cur_y - self.qr_blocks_per_zoom * self.pixels_per_block - cur_block_y -= 1 - if next_y < 0: - next_y = cur_y - cur_block_y += 1 + self.next_y = self.cur_y - self.qr_blocks_per_zoom * self.pixels_per_block + self.cur_block_y -= 1 + if self.next_y < 0: + self.next_y = self.cur_y + self.cur_block_y += 1 elif input in HardwareButtonsConstants.KEYS__ANYCLICK: return # Create overlay for block labels (e.g. "D-5") - block_labels = self.draw_block_labels(cur_block_x, cur_block_y) - - if cur_x != next_x or cur_y != next_y: - self.renderer.show_image_pan( - self.qr_image, - cur_x, cur_y, next_x, next_y, - rate=self.pixels_per_block, - alpha_overlay=Image.alpha_composite(self.block_mask, block_labels) - ) - cur_x = next_x - cur_y = next_y + block_labels = self.draw_block_labels() + + if self.cur_x != self.next_x or self.cur_y != self.next_y: + with self.renderer.lock: + self.renderer.show_image_pan( + self.qr_image, + self.cur_x, self.cur_y, self.next_x, self.next_y, + rate=self.pixels_per_block, + alpha_overlay=Image.alpha_composite(self.block_mask, block_labels) + ) + self.cur_x = self.next_x + self.cur_y = self.next_y @@ -1325,7 +1380,7 @@ def __post_init__(self): super().__post_init__() self.components.append(TextArea( - text="Optionally scan your transcribed SeedQR to confirm that it reads back correctly.", + text=_("Optionally scan your transcribed SeedQR to confirm that it reads back correctly."), screen_y=self.top_nav.height, height=self.buttons[0].screen_y - self.top_nav.height, )) @@ -1382,10 +1437,9 @@ class SeedAddressVerificationScreen(ButtonListScreen): def __post_init__(self): # Customize defaults - self.title = "Verify Address" + self.title = _("Verify Address") self.is_bottom_list = True self.show_back_button = False - self.button_data = ["Skip 10", "Cancel"] super().__post_init__() @@ -1446,9 +1500,10 @@ def run(self): return textarea = TextArea( - text=f"Checking address {self.threadsafe_counter.cur_count}", - font_name=GUIConstants.BODY_FONT_NAME, - font_size=GUIConstants.BODY_FONT_SIZE, + # TRANSLATOR_NOTE: Inserts the nth address number (e.g. "Checking address 7") + text=_("Checking address {}").format(self.threadsafe_counter.cur_count), + font_name=GUIConstants.get_body_font_name(), + font_size=GUIConstants.get_body_font_size(), screen_y=self.screen_y ) @@ -1463,12 +1518,12 @@ def run(self): @dataclass class LoadMultisigWalletDescriptorScreen(ButtonListScreen): def __post_init__(self): - self.title = "Multisig Verification" + self.title = _("Multisig Verification") self.is_bottom_list = True super().__post_init__() self.components.append(TextArea( - text="Load your multisig wallet descriptor to verify your receive/self-transfer or change address.", + text=_("Load your multisig wallet descriptor to verify your receive/self-transfer or change address."), screen_y=self.top_nav.height, height=self.buttons[0].screen_y - self.top_nav.height, )) @@ -1481,27 +1536,27 @@ class MultisigWalletDescriptorScreen(ButtonListScreen): fingerprints: List[str] = None def __post_init__(self): - self.title = "Descriptor Loaded" + self.title = _("Descriptor Loaded") self.is_bottom_list = True super().__post_init__() self.components.append(IconTextLine( - label_text="Policy", + # TRANSLATOR_NOTE: Label for the multisig wallet's signing policy (e.g. 2-of-3) + label_text=_("Policy"), value_text=self.policy, - font_size=GUIConstants.TOP_NAV_TITLE_FONT_SIZE, + font_size=20, screen_y=self.top_nav.height, is_text_centered=True, )) self.components.append(IconTextLine( - label_text="Signing Keys", + label_text=_("Signing Keys"), value_text=" ".join(self.fingerprints), - font_size=GUIConstants.TOP_NAV_TITLE_FONT_SIZE + 4, + font_size=24, font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, screen_y=self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING, is_text_centered=True, auto_line_break=True, - allow_text_overflow=True, )) @@ -1533,12 +1588,12 @@ def __post_init__(self): raise Exception("Bug in paged_message calculation") if len(self.sign_message_data["paged_message"]) == 1: - self.title = "Review Message" + self.title = _("Review Message") else: self.title = f"""Message (pt {self.page_num + 1}/{len(self.sign_message_data["paged_message"])})""" self.is_bottom_list = True self.is_button_text_centered = True - self.button_data = ["Next"] + self.button_data = [ButtonOption("Next")] super().__post_init__() message_display = TextArea( @@ -1557,16 +1612,16 @@ class SeedSignMessageConfirmAddressScreen(ButtonListScreen): address: str = None def __post_init__(self): - self.title = "Confirm Address" + self.title = _("Confirm Address") self.is_bottom_list = True self.is_button_text_centered = True - self.button_data = ["Sign Message"] + self.button_data = [ButtonOption("Sign Message")] super().__post_init__() derivation_path_display = IconTextLine( icon_name=SeedSignerIconConstants.DERIVATION, icon_color=GUIConstants.INFO_COLOR, - label_text="derivation path", + label_text=_("derivation path"), value_text=self.derivation_path, is_text_centered=True, screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING, diff --git a/src/seedsigner/gui/screens/settings_screens.py b/src/seedsigner/gui/screens/settings_screens.py index 55fa5dd00..082ad4971 100644 --- a/src/seedsigner/gui/screens/settings_screens.py +++ b/src/seedsigner/gui/screens/settings_screens.py @@ -1,17 +1,20 @@ import time from dataclasses import dataclass +from gettext import gettext as _ from PIL.ImageOps import autocontrast from typing import List + +from seedsigner.helpers.l10n import mark_for_translation as _mft from seedsigner.gui.components import Button, CheckboxButton, CheckedSelectionButton, FontAwesomeIconConstants, Fonts, GUIConstants, Icon, IconButton, IconTextLine, TextArea from seedsigner.gui.screens.scan_screens import ScanScreen - -from seedsigner.gui.screens.screen import BaseScreen, BaseTopNavScreen, ButtonListScreen +from seedsigner.gui.screens.screen import BaseScreen, BaseTopNavScreen, ButtonListScreen, ButtonOption from seedsigner.hardware.buttons import HardwareButtonsConstants from seedsigner.hardware.camera import Camera from seedsigner.models.settings import SettingsConstants + @dataclass class SettingsEntryUpdateSelectionScreen(ButtonListScreen): display_name: str = None @@ -21,7 +24,7 @@ class SettingsEntryUpdateSelectionScreen(ButtonListScreen): selected_button: int = 0 def __post_init__(self): - self.title = "Settings" + self.title = _("Settings") self.is_bottom_list = True self.use_checked_selection_buttons = True if self.settings_entry_type == SettingsConstants.TYPE__MULTISELECT: @@ -31,20 +34,21 @@ def __post_init__(self): super().__post_init__() self.components.append(TextArea( - text=self.display_name, + text=_(self.display_name), font_size=GUIConstants.BODY_FONT_MAX_SIZE, is_text_centered=True, - auto_line_break=False, + auto_line_break=True, screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING )) if self.help_text: prev_component_bottom = self.components[-1].screen_y + self.components[-1].height self.components.append(TextArea( - text=self.help_text, + text=_(self.help_text), font_color=GUIConstants.LABEL_FONT_COLOR, is_text_centered=True, screen_y=prev_component_bottom + GUIConstants.COMPONENT_PADDING, + auto_line_break=True, )) @@ -52,7 +56,8 @@ def __post_init__(self): @dataclass class IOTestScreen(BaseTopNavScreen): def __post_init__(self): - self.title = "I/O Test" + # TRANSLATOR_NOTE: Short for "Input/Output"; screen to make sure the buttons and camera are working properly + self.title = _("I/O Test") self.show_back_button = False self.resolution = (96, 96) self.framerate = 10 @@ -122,8 +127,8 @@ def __post_init__(self): self.components.append(self.joystick_right_button) # Hardware keys UI - font = Fonts.get_font(GUIConstants.BUTTON_FONT_NAME, GUIConstants.BUTTON_FONT_SIZE) - (left, top, text_width, bottom) = font.getbbox(text="Clear", anchor="ls") + font = Fonts.get_font(GUIConstants.get_button_font_name(), GUIConstants.get_button_font_size()) + (left, top, text_width, bottom) = font.getbbox(text=_("Clear"), anchor="ls") icon = Icon( icon_name=FontAwesomeIconConstants.CAMERA, icon_size=GUIConstants.ICON_INLINE_FONT_SIZE, @@ -133,12 +138,14 @@ def __post_init__(self): key2_y = int(self.canvas_height/2) - int(key_button_height/2) self.key2_button = Button( - text="Clear", # Initialize with text to set vertical centering + # TRANSLATOR_NOTE: Blank the screen + text=_("Clear"), # Initialize with text to set vertical centering width=key_button_width, height=key_button_height, screen_x=self.canvas_width - key_button_width + GUIConstants.EDGE_PADDING, screen_y=key2_y, outline_color=GUIConstants.ACCENT_COLOR, + is_scrollable_text=False, # Text has to dynamically update, can't use scrollable Button ) self.key2_button.text = " " # but default state is empty self.components.append(self.key2_button) @@ -154,12 +161,13 @@ def __post_init__(self): self.components.append(self.key1_button) self.key3_button = Button( - text="Exit", + text=_("Exit"), width=key_button_width, height=key_button_height, screen_x=self.canvas_width - key_button_width + GUIConstants.EDGE_PADDING, screen_y=key2_y + 3*GUIConstants.COMPONENT_PADDING + key_button_height, outline_color=GUIConstants.ACCENT_COLOR, + is_scrollable_text=False, # No help for l10n, but currently ScrollableTextLine interferes with the small button's left edge. (TODO:) ) self.components.append(self.key3_button) @@ -168,8 +176,8 @@ def _run(self): cur_selected_button = self.key1_button msg_height = GUIConstants.ICON_LARGE_BUTTON_SIZE + 2*GUIConstants.COMPONENT_PADDING camera_message = TextArea( - text="Capturing image...", - font_size=GUIConstants.TOP_NAV_TITLE_FONT_SIZE, + text=_("Capturing image..."), + font_size=GUIConstants.get_top_nav_title_font_size(), is_text_centered=True, height=msg_height, screen_y=int((self.canvas_height - msg_height)/ 2), @@ -178,21 +186,25 @@ def _run(self): input = self.hw_inputs.wait_for(keys=HardwareButtonsConstants.ALL_KEYS, check_release=False) if input == HardwareButtonsConstants.KEY1: + # Note that there are three distinct screen updates that happen at + # different times, therefore we claim the `Renderer.lock` three separate + # times. cur_selected_button = self.key1_button with self.renderer.lock: - cur_selected_button.is_selected = True - cur_selected_button.render() - camera_message.render() # Render edges around message box self.image_draw.rectangle( ( -1, int((self.canvas_height - msg_height)/ 2) - 1, self.canvas_width + 1, int((self.canvas_height + msg_height)/ 2) + 1 ), + fill="black", outline=GUIConstants.ACCENT_COLOR, width=1, ) + cur_selected_button.is_selected = True + cur_selected_button.render() + camera_message.render() self.renderer.show_image() # Snap a pic, render it as the background, re-render all onscreen elements @@ -214,7 +226,7 @@ def _run(self): ) with self.renderer.lock: self.canvas.paste(display_version, (0, self.top_nav.height)) - self.key2_button.text = "Clear" + self.key2_button.text = _("Clear") for component in self.components: component.render() self.renderer.show_image() @@ -281,17 +293,19 @@ def _run(self): @dataclass class DonateScreen(BaseTopNavScreen): def __post_init__(self): - self.title = "Donate" + self.title = _("Donate") super().__post_init__() self.components.append(TextArea( - text="SeedSigner is 100% free & open source, funded solely by the Bitcoin community.\n\nDonate onchain or LN at:", + # TRANSLATOR_NOTE: If your language uses the percent sign ("%"), your translation must also use two percent signs ("%%") due to python formatting oddities. "100%%" will be rendered as "100%". + text=_("SeedSigner is 100%% free & open source, funded solely by the Bitcoin community.\n\nDonate onchain or LN at:").replace("%%", "%"), screen_y=self.top_nav.height + 3*GUIConstants.COMPONENT_PADDING, )) self.components.append(TextArea( text="seedsigner.com", - font_size=GUIConstants.TOP_NAV_TITLE_FONT_SIZE + 8, + font_name=GUIConstants.get_body_font_name(), + font_size=28, font_color=GUIConstants.ACCENT_COLOR, supersampling_factor=1, screen_y=self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING @@ -302,20 +316,20 @@ def __post_init__(self): @dataclass class SettingsQRConfirmationScreen(ButtonListScreen): config_name: str = None - title: str = "Settings QR" - status_message: str = "Settings updated..." + title: str = _mft("Settings QR") + status_message: str = _mft("Settings updated...") is_bottom_list: bool = True def __post_init__(self): # Customize defaults - self.button_data = ["Home"] + self.button_data = [ButtonOption("Home")] self.show_back_button = False super().__post_init__() start_y = self.top_nav.height + 20 if self.config_name: self.config_name_textarea = TextArea( - text=f'"{self.config_name}"', + text=f'"{self.config_name}"', # User-supplied string (from SettingsQR); don't wrap to translate is_text_centered=True, auto_line_break=True, screen_y=start_y @@ -324,7 +338,7 @@ def __post_init__(self): start_y = self.config_name_textarea.screen_y + 50 self.components.append(TextArea( - text=self.status_message, + text=_(self.status_message), is_text_centered=True, auto_line_break=True, screen_y=start_y diff --git a/src/seedsigner/gui/screens/tools_screens.py b/src/seedsigner/gui/screens/tools_screens.py index d5e6106ba..254d4bf94 100644 --- a/src/seedsigner/gui/screens/tools_screens.py +++ b/src/seedsigner/gui/screens/tools_screens.py @@ -1,6 +1,7 @@ import time from dataclasses import dataclass +from gettext import gettext as _ from typing import Any from PIL.Image import Image from seedsigner.hardware.camera import Camera @@ -16,10 +17,6 @@ @dataclass class ToolsImageEntropyLivePreviewScreen(BaseScreen): def __post_init__(self): - # Customize defaults - self.title = "Initializing Camera..." - - # Initialize the base class super().__post_init__() self.camera = Camera.get_instance() @@ -30,10 +27,9 @@ def _run(self): # save preview image frames to use as additional entropy below preview_images = [] max_entropy_frames = 50 - instructions_font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.BUTTON_FONT_SIZE) + instructions_font = Fonts.get_font(GUIConstants.get_body_font_name(), GUIConstants.get_button_font_size()) while True: - # Check for BACK button press if self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_LEFT): # Have to manually update last input time since we're not in a wait_for loop self.hw_inputs.update_last_input_time() @@ -54,6 +50,27 @@ def _run(self): self.hw_inputs.update_last_input_time() self.camera.stop_video_stream_mode() + with self.renderer.lock: + self.renderer.canvas.paste(frame) + + self.renderer.draw.text( + xy=( + int(self.renderer.canvas_width/2), + self.renderer.canvas_height - GUIConstants.EDGE_PADDING + ), + text=_("Capturing image..."), + fill=GUIConstants.ACCENT_COLOR, + font=instructions_font, + stroke_width=4, + stroke_fill=GUIConstants.BACKGROUND_COLOR, + anchor="ms" + ) + self.renderer.show_image() + + return preview_images + + # If we're still here, it's just another preview frame loop + with self.renderer.lock: self.renderer.canvas.paste(frame) self.renderer.draw.text( @@ -61,8 +78,8 @@ def _run(self): int(self.renderer.canvas_width/2), self.renderer.canvas_height - GUIConstants.EDGE_PADDING ), - text="Capturing image...", - fill=GUIConstants.ACCENT_COLOR, + text="< " + _("back") + " | " + _("click joystick"), # TODO: Render with UI elements instead of text + fill=GUIConstants.BODY_FONT_COLOR, font=instructions_font, stroke_width=4, stroke_fill=GUIConstants.BACKGROUND_COLOR, @@ -70,17 +87,35 @@ def _run(self): ) self.renderer.show_image() - return preview_images + if len(preview_images) == max_entropy_frames: + # Keep a moving window of the last n preview frames; pop the oldest + # before we add the currest frame. + preview_images.pop(0) + preview_images.append(frame) - # If we're still here, it's just another preview frame loop - self.renderer.canvas.paste(frame) + +@dataclass +class ToolsImageEntropyFinalImageScreen(BaseScreen): + final_image: Image = None + + def _run(self): + instructions_font = Fonts.get_font(GUIConstants.get_body_font_name(), GUIConstants.get_button_font_size()) + + with self.renderer.lock: + self.renderer.canvas.paste(self.final_image) + + # TRANSLATOR_NOTE: A prompt to the user to either accept or reshoot the image + reshoot = _("reshoot") + + # TRANSLATOR_NOTE: A prompt to the user to either accept or reshoot the image + accept = _("accept") self.renderer.draw.text( xy=( int(self.renderer.canvas_width/2), self.renderer.canvas_height - GUIConstants.EDGE_PADDING ), - text="< back | click joystick", + text=" < " + reshoot + " | " + accept + " > ", fill=GUIConstants.BODY_FONT_COLOR, font=instructions_font, stroke_width=4, @@ -89,36 +124,6 @@ def _run(self): ) self.renderer.show_image() - if len(preview_images) == max_entropy_frames: - # Keep a moving window of the last n preview frames; pop the oldest - # before we add the currest frame. - preview_images.pop(0) - preview_images.append(frame) - - - -@dataclass -class ToolsImageEntropyFinalImageScreen(BaseScreen): - final_image: Image = None - - def _run(self): - instructions_font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.BUTTON_FONT_SIZE) - - self.renderer.canvas.paste(self.final_image) - self.renderer.draw.text( - xy=( - int(self.renderer.canvas_width/2), - self.renderer.canvas_height - GUIConstants.EDGE_PADDING - ), - text=" < reshoot | accept > ", - fill=GUIConstants.BODY_FONT_COLOR, - font=instructions_font, - stroke_width=4, - stroke_fill=GUIConstants.BACKGROUND_COLOR, - anchor="ms" - ) - self.renderer.show_image() - input = self.hw_inputs.wait_for([HardwareButtonsConstants.KEY_LEFT, HardwareButtonsConstants.KEY_RIGHT]) if input == HardwareButtonsConstants.KEY_LEFT: return RET_CODE__BACK_BUTTON @@ -127,9 +132,10 @@ def _run(self): @dataclass class ToolsDiceEntropyEntryScreen(KeyboardScreen): + def __post_init__(self): - # Override values set by the parent class - self.title = f"Dice Roll 1/{self.return_after_n_chars}" + # TRANSLATOR_NOTE: current roll number vs total rolls (e.g. roll 7 of 50) + self.title = _("Dice Roll {}/{}").format(1, self.return_after_n_chars) self.custom_additional_keys = [Keyboard.KEY_BACKSPACE] # Specify the keys in the keyboard @@ -161,7 +167,7 @@ def __post_init__(self): def update_title(self) -> bool: - self.title = f"Dice Roll {self.cursor_position + 1}/{self.return_after_n_chars}" + self.title = _("Dice Roll {}/{}").format(self.cursor_position + 1, self.return_after_n_chars) return True @@ -172,13 +178,15 @@ class ToolsCalcFinalWordFinalizePromptScreen(ButtonListScreen): num_entropy_bits: int = None def __post_init__(self): - self.title = "Build Final Word" + # TRANSLATOR_NOTE: Build the last word in a 12 or 24 word BIP-39 mnemonic seed phrase. + self.title = _("Build Final Word") self.is_bottom_list = True self.is_button_text_centered = True super().__post_init__() self.components.append(TextArea( - text=f"The {self.mnemonic_length}th word is built from {self.num_entropy_bits} more entropy bits plus auto-calculated checksum.", + # TRANSLATOR_NOTE: Number of BIP-39 seed words, and the entropy -- in bits, contained within. + text=_("The {}th word is built from {} more entropy bits plus auto-calculated checksum.").format(self.mnemonic_length, self.num_entropy_bits), screen_y=self.top_nav.height + int(GUIConstants.COMPONENT_PADDING/2), )) @@ -188,30 +196,34 @@ def __post_init__(self): class ToolsCoinFlipEntryScreen(KeyboardScreen): def __post_init__(self): # Override values set by the parent class - self.title = f"Coin Flip 1/{self.return_after_n_chars}" + # TRANSLATOR_NOTE: current coin-flip number vs total flips (e.g. flip 3 of 4) + self.title = _("Coin Flip {}/{}").format(1, self.return_after_n_chars) self.custom_additional_keys = [Keyboard.KEY_BACKSPACE_2] - + # Specify the keys in the keyboard self.rows = 1 self.cols = 4 - self.key_height = GUIConstants.TOP_NAV_TITLE_FONT_SIZE + 2 + 2*GUIConstants.EDGE_PADDING + self.key_height = GUIConstants.get_top_nav_title_font_size() + 2 + 2*GUIConstants.EDGE_PADDING self.keys_charset = "10" # Now initialize the parent class super().__post_init__() self.components.append(TextArea( - text="Heads = 1", + # TRANSLATOR_NOTE: How we call the "front" side result during a coin toss. + text=_("Heads = 1"), screen_y = self.keyboard.rect[3] + 4*GUIConstants.COMPONENT_PADDING, )) self.components.append(TextArea( - text="Tails = 0", + # TRANSLATOR_NOTE: How we call the "back" side result during a coin toss. + text=_("Tails = 0"), screen_y = self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING, )) def update_title(self) -> bool: - self.title = f"Coin Flip {self.cursor_position + 1}/{self.return_after_n_chars}" + # l10n_note already done. + self.title = _("Coin Flip {}/{}").format(self.cursor_position + 1, self.return_after_n_chars) return True @@ -228,7 +240,7 @@ def __post_init__(self): super().__post_init__() # First what's the total bit display width and where do the checksum bits start? - bit_font_size = GUIConstants.BUTTON_FONT_SIZE + 2 + bit_font_size = GUIConstants.get_button_font_size() + 2 font = Fonts.get_font(GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, bit_font_size) (left, top, bit_display_width, bit_font_height) = font.getbbox("0" * 11, anchor="lt") (left, top, checksum_x, bottom) = font.getbbox("0" * (11 - len(self.checksum_bits)), anchor="lt") @@ -252,8 +264,10 @@ def __post_init__(self): # significant n bits always rendered in same column) discard_selected_bits = "_" * (len(self.checksum_bits)) + # TRANSLATOR_NOTE: The additional entropy the user supplied (e.g. coin flips) + your_input = _('Your input: "{}"').format(selection_text) self.components.append(TextArea( - text=f"""Your input: \"{selection_text}\"""", + text=your_input, screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING - 2, # Nudge to last line doesn't get too close to "Next" button height_ignores_below_baseline=True, # Keep the next line (bits display) snugged up, regardless of text rendering below the baseline )) @@ -288,7 +302,8 @@ def __post_init__(self): # Show the checksum.. self.components.append(TextArea( - text="Checksum", + # TRANSLATOR_NOTE: A function of "x" to be used for detecting errors in "x" + text=_("Checksum"), edge_padding=0, screen_y=first_bits_line.screen_y + first_bits_line.height + 2*GUIConstants.COMPONENT_PADDING, )) @@ -324,7 +339,8 @@ def __post_init__(self): # And now the *actual* final word after merging the bit data self.components.append(TextArea( - text=f"""Final Word: \"{self.actual_final_word}\"""", + # TRANSLATOR_NOTE: labeled presentation of the last word in a BIP-39 mnemonic seed phrase. + text=_('Final Word: "{}"').format(self.actual_final_word), screen_y=self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING, height_ignores_below_baseline=True, # Keep the next line (bits display) snugged up, regardless of text rendering below the baseline )) @@ -364,15 +380,20 @@ class ToolsCalcFinalWordDoneScreen(ButtonListScreen): fingerprint: str = None def __post_init__(self): - # Customize defaults - self.title = f"{self.mnemonic_word_length}th Word" + # Manually specify 12 vs 24 case for easier ordinal translation + if self.mnemonic_word_length == 12: + # TRANSLATOR_NOTE: a label for the last word of a 12-word BIP-39 mnemonic seed phrase + self.title = _("12th Word") + else: + # TRANSLATOR_NOTE: a label for the last word of a 24-word BIP-39 mnemonic seed phrase + self.title = _("24th Word") self.is_bottom_list = True super().__post_init__() self.components.append(TextArea( text=f"""\"{self.final_word}\"""", - font_size=GUIConstants.TOP_NAV_TITLE_FONT_SIZE + 6, + font_size=26, is_text_centered=True, screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING, )) @@ -380,7 +401,8 @@ def __post_init__(self): self.components.append(IconTextLine( icon_name=SeedSignerIconConstants.FINGERPRINT, icon_color=GUIConstants.INFO_COLOR, - label_text="fingerprint", + # TRANSLATOR_NOTE: a label for the shortened Key-id of a BIP-32 master HD wallet + label_text=_("fingerprint"), value_text=self.fingerprint, is_text_centered=True, screen_y=self.components[-1].screen_y + self.components[-1].height + 3*GUIConstants.COMPONENT_PADDING, @@ -396,7 +418,8 @@ class ToolsAddressExplorerAddressTypeScreen(ButtonListScreen): custom_derivation_path: str = None def __post_init__(self): - self.title = "Address Explorer" + # TRANSLATOR_NOTE: a label for the tool to explore public addresses for this seed. + self.title = _("Address Explorer") self.is_bottom_list = True super().__post_init__() @@ -404,7 +427,8 @@ def __post_init__(self): self.components.append(IconTextLine( icon_name=SeedSignerIconConstants.FINGERPRINT, icon_color=GUIConstants.INFO_COLOR, - label_text="Fingerprint", + # TRANSLATOR_NOTE: a label for the shortened Key-id of a BIP-32 master HD wallet + label_text=_("Fingerprint"), value_text=self.fingerprint, screen_x=GUIConstants.EDGE_PADDING, screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING, @@ -413,7 +437,8 @@ def __post_init__(self): if self.script_type != SettingsConstants.CUSTOM_DERIVATION: self.components.append(IconTextLine( icon_name=SeedSignerIconConstants.DERIVATION, - label_text="Derivation", + # TRANSLATOR_NOTE: a label for the derivation-path into a BIP-32 HD wallet + label_text=_("Derivation"), value_text=SettingsDefinition.get_settings_entry(attr_name=SettingsConstants.SETTING__SCRIPT_TYPES).get_selection_option_display_name_by_value(value=self.script_type), screen_x=GUIConstants.EDGE_PADDING, screen_y=self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING, @@ -421,7 +446,8 @@ def __post_init__(self): else: self.components.append(IconTextLine( icon_name=SeedSignerIconConstants.DERIVATION, - label_text="Derivation", + # l10n_note already exists. + label_text=_("Derivation"), value_text=self.custom_derivation_path, screen_x=GUIConstants.EDGE_PADDING, screen_y=self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING, @@ -429,7 +455,8 @@ def __post_init__(self): else: self.components.append(IconTextLine( - label_text="Wallet descriptor", + # TRANSLATOR_NOTE: a label for a BIP-380-ish Output Descriptor + label_text=_("Wallet descriptor"), value_text=self.wallet_descriptor_display_name, is_text_centered=True, screen_x=GUIConstants.EDGE_PADDING, diff --git a/src/seedsigner/gui/toast.py b/src/seedsigner/gui/toast.py index d21ff02c9..baefa16b1 100644 --- a/src/seedsigner/gui/toast.py +++ b/src/seedsigner/gui/toast.py @@ -1,6 +1,8 @@ import logging import time from dataclasses import dataclass +from gettext import gettext as _ + from seedsigner.gui.components import BaseComponent, GUIConstants, Icon, SeedSignerIconConstants, TextArea from seedsigner.models.threads import BaseThread @@ -187,21 +189,26 @@ def run(self): class RemoveSDCardToastManagerThread(BaseToastOverlayManagerThread): - def __init__(self, activation_delay=3): - # Note: activation_delay is configurable so the screenshot generator can get the - # toast to immediately render. + def __init__(self, activation_delay: int = 3, duration: int = 1e6): + """ + * activation_delay: configurable so the screenshot generator can get the + toast to immediately render. + * duration: default value is essentially forever. Overrideable for the + screenshot generator. + """ super().__init__( - activation_delay=activation_delay, # seconds - duration=1e6, # seconds ("forever") + activation_delay=activation_delay, + duration=duration, ) def instantiate_toast(self) -> ToastOverlay: + body_font_size = GUIConstants.get_body_font_size() return ToastOverlay( icon_name=SeedSignerIconConstants.MICROSD, - label_text="You can remove\nthe SD card now", - font_size=GUIConstants.BODY_FONT_SIZE, - height=GUIConstants.BODY_FONT_SIZE * 2 + GUIConstants.BODY_LINE_SPACING + GUIConstants.EDGE_PADDING, + label_text=_("You can remove\nthe SD card now"), + font_size=body_font_size, + height=body_font_size * 2 + GUIConstants.BODY_LINE_SPACING + GUIConstants.EDGE_PADDING, ) @@ -219,7 +226,7 @@ def __init__(self, action: str, *args, **kwargs): from seedsigner.hardware.microsd import MicroSD if action not in [MicroSD.ACTION__INSERTED, MicroSD.ACTION__REMOVED]: raise Exception(f"Invalid MicroSD action: {action}") - self.message = "SD card removed" if action == MicroSD.ACTION__REMOVED else "SD card inserted" + self.message = _("SD card removed") if action == MicroSD.ACTION__REMOVED else _("SD card inserted") super().__init__(*args, **kwargs) diff --git a/src/seedsigner/hardware/buttons.py b/src/seedsigner/hardware/buttons.py index 6fecc10fa..f37dfa80a 100644 --- a/src/seedsigner/hardware/buttons.py +++ b/src/seedsigner/hardware/buttons.py @@ -65,6 +65,15 @@ def get_instance(cls): return cls._instance + + @classmethod + def get_instance_no_hardware(cls): + # This is the only way to access the one and only instance + if cls._instance is None: + cls._instance = cls.__new__(cls) + + + def wait_for(self, keys=[], check_release=True, release_keys=[]) -> int: # TODO: Refactor to keep control in the Controller and not here from seedsigner.controller import Controller diff --git a/src/seedsigner/hardware/camera.py b/src/seedsigner/hardware/camera.py index 59ff41016..ab4f700fb 100644 --- a/src/seedsigner/hardware/camera.py +++ b/src/seedsigner/hardware/camera.py @@ -1,6 +1,5 @@ import io -from picamera import PiCamera from PIL import Image from seedsigner.hardware.pivideostream import PiVideoStream from seedsigner.models.settings import Settings, SettingsConstants @@ -23,6 +22,7 @@ def get_instance(cls): def start_video_stream_mode(self, resolution=(512, 384), framerate=12, format="bgr"): + from seedsigner.hardware.pivideostream import PiVideoStream if self._video_stream is not None: self.stop_video_stream_mode() @@ -49,6 +49,7 @@ def stop_video_stream_mode(self): def start_single_frame_mode(self, resolution=(720, 480)): + from picamera import PiCamera if self._video_stream is not None: self.stop_video_stream_mode() if self._picamera is not None: diff --git a/src/seedsigner/helpers/embit_utils.py b/src/seedsigner/helpers/embit_utils.py index 7e260aa43..1754b0803 100644 --- a/src/seedsigner/helpers/embit_utils.py +++ b/src/seedsigner/helpers/embit_utils.py @@ -50,7 +50,7 @@ def get_standard_derivation_path(network: str = SettingsConstants.MAINNET, walle elif script_type == SettingsConstants.NATIVE_SEGWIT: return f"m/48'/{network_path}/0'/2'" elif script_type == SettingsConstants.TAPROOT: - raise Exception("Taproot multisig/musig not yet supported") + raise Exception("Taproot multisig not yet supported") else: raise Exception("Unexpected script type") else: diff --git a/src/seedsigner/helpers/l10n.py b/src/seedsigner/helpers/l10n.py new file mode 100644 index 000000000..a971da580 --- /dev/null +++ b/src/seedsigner/helpers/l10n.py @@ -0,0 +1,3 @@ +def mark_for_translation(message: str) -> str: + # Wraps the target string literal for translation but does NOT return the translated string. + return message \ No newline at end of file diff --git a/src/seedsigner/helpers/qr.py b/src/seedsigner/helpers/qr.py index 5377a1a74..75291efe3 100644 --- a/src/seedsigner/helpers/qr.py +++ b/src/seedsigner/helpers/qr.py @@ -102,6 +102,6 @@ def qrimage_io(self, data, width=240, height=240, border=3, background_color="80 # if qrencode fails, fall back to only encoder if rv != 0: return self.qrimage(data,width,height,border) - img = Image.open("/tmp/qrcode.png").resize((width,height), Image.NEAREST).convert("RGBA") + img = Image.open("/tmp/qrcode.png").resize((width,height), Image.Resampling.NEAREST).convert("RGBA") return img diff --git a/src/seedsigner/models/encode_qr.py b/src/seedsigner/models/encode_qr.py index 65a8f5d1f..c66e85674 100644 --- a/src/seedsigner/models/encode_qr.py +++ b/src/seedsigner/models/encode_qr.py @@ -15,7 +15,7 @@ from seedsigner.models.settings import SettingsConstants from urtypes.crypto import PSBT as UR_PSBT -from urtypes.crypto import Account, HDKey, Output, Keypath, PathComponent, SCRIPT_EXPRESSION_TAG_MAP +from urtypes.crypto import Account, HDKey, Output, Keypath, PathComponent, SCRIPT_EXPRESSION_TAG_MAP, CoinInfo @@ -344,11 +344,13 @@ def derivation_to_keypath(path: str) -> list: return Keypath(arr, self.root.my_fingerprint, len(arr)) origin = derivation_to_keypath(self.derivation) + self.use_info = None if self.network == SettingsConstants.MAINNET else CoinInfo(type=None, network=1) self.ur_hdkey = HDKey({ 'key': self.xpub.key.serialize(), 'chain_code': self.xpub.chain_code, 'origin': origin, - 'parent_fingerprint': self.xpub.fingerprint}) + 'parent_fingerprint': self.xpub.fingerprint, + 'use_info': self.use_info }) ur_outputs = [] diff --git a/src/seedsigner/models/seed.py b/src/seedsigner/models/seed.py index 2218ae6cd..82ed9eb2c 100644 --- a/src/seedsigner/models/seed.py +++ b/src/seedsigner/models/seed.py @@ -78,6 +78,11 @@ def mnemonic_display_list(self) -> List[str]: return unicodedata.normalize("NFC", " ".join(self._mnemonic)).split() + @property + def has_passphrase(self): + return self._passphrase != "" + + @property def passphrase(self): return self._passphrase diff --git a/src/seedsigner/models/settings.py b/src/seedsigner/models/settings.py index ae5b4ffe7..3c26d797d 100644 --- a/src/seedsigner/models/settings.py +++ b/src/seedsigner/models/settings.py @@ -1,6 +1,8 @@ +import gettext import logging import json import os +import pathlib import platform from typing import List @@ -36,6 +38,19 @@ def get_instance(cls): with open(Settings.SETTINGS_FILENAME) as settings_file: settings.update(json.load(settings_file)) + # Setup multilanguage support + path = os.path.join( + pathlib.Path(__file__).parent.resolve().parent.resolve(), + "resources", + "seedsigner-translations", + "l10n" + ) + gettext.bindtextdomain('messages', localedir=path) + gettext.textdomain('messages') + + # Load default/persistent locale setting + settings.load_locale() + return cls._instance @@ -141,11 +156,8 @@ def update(self, new_settings: dict): # Break comma-separated SettingsQR input into List new_settings[entry.attr_name] = new_settings[entry.attr_name].split(",") - # Can't just merge the _data dict; have to replace keys they have in common - # (otherwise list values will be merged instead of replaced). for key, value in new_settings.items(): - self._data.pop(key, None) - self._data[key] = value + self.set_value(key, value) def set_value(self, attr_name: str, value: any): @@ -155,7 +167,9 @@ def set_value(self, attr_name: str, value: any): Note that for multiselect, the value must be a List. """ if attr_name not in self._data: - raise Exception(f"Setting for {attr_name} not found") + # Outdated settings + print(f"Setting {attr_name} not recognized. Ignoring.") + return if SettingsDefinition.get_settings_entry(attr_name).type == SettingsConstants.TYPE__MULTISELECT: if type(value) != list: @@ -171,7 +185,11 @@ def set_value(self, attr_name: str, value: any): self._data[attr_name] = value self.save() - + + # Special handling for localization + if attr_name == SettingsConstants.SETTING__LOCALE: + self.load_locale() + def get_value(self, attr_name: str): """ @@ -222,13 +240,22 @@ def get_multiselect_value_display_names(self, attr_name: str) -> List[str]: return display_names + def load_locale(self): + locale = self.get_value(SettingsConstants.SETTING__LOCALE) + os.environ['LANGUAGE'] = locale + + # Re-initialize with the new locale + print(f"Set LANGUAGE locale to {os.environ['LANGUAGE']}") + + + """ Intentionally keeping the properties very limited to avoid an expectation of boilerplate property code for every SettingsEntry. It's more cumbersome, but instead use: - settings.get_value(SettingsConstants.SETTING__MY_SETTING_ATTR) + Settings.get_instance().get_value(SettingsConstants.SETTING__MY_SETTING_ATTR) """ @property def debug(self) -> bool: diff --git a/src/seedsigner/models/settings_definition.py b/src/seedsigner/models/settings_definition.py index 089a94b21..7a64ae020 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -1,6 +1,7 @@ from dataclasses import dataclass from typing import Any, List +from seedsigner.helpers.l10n import mark_for_translation as _mft class SettingsConstants: @@ -10,25 +11,25 @@ class SettingsConstants: OPTION__PROMPT = "P" OPTION__REQUIRED = "R" OPTIONS__ENABLED_DISABLED = [ - (OPTION__ENABLED, "Enabled"), - (OPTION__DISABLED, "Disabled"), + (OPTION__ENABLED, _mft("Enabled")), + (OPTION__DISABLED, _mft("Disabled")), ] OPTIONS__ONLY_DISABLED = [ - (OPTION__DISABLED, "Disabled"), + (OPTION__DISABLED, _mft("Disabled")), ] OPTIONS__PROMPT_REQUIRED_DISABLED = [ - (OPTION__PROMPT, "Prompt"), - (OPTION__REQUIRED, "Required"), - (OPTION__DISABLED, "Disabled"), + (OPTION__PROMPT, _mft("Prompt")), + (OPTION__REQUIRED, _mft("Required")), + (OPTION__DISABLED, _mft("Disabled")), ] OPTIONS__ENABLED_DISABLED_REQUIRED = OPTIONS__ENABLED_DISABLED +[ - (OPTION__REQUIRED, "Required"), + (OPTION__REQUIRED, _mft("Required")), ] OPTIONS__ENABLED_DISABLED_PROMPT = OPTIONS__ENABLED_DISABLED + [ - (OPTION__PROMPT, "Prompt"), + (OPTION__PROMPT, _mft("Prompt")), ] ALL_OPTIONS = OPTIONS__ENABLED_DISABLED_PROMPT + [ - (OPTION__REQUIRED, "Required"), + (OPTION__REQUIRED, _mft("Required")), ] # User-facing selection options @@ -45,9 +46,30 @@ class SettingsConstants: (COORDINATOR__KEEPER, "Keeper"), ] - LANGUAGE__ENGLISH = "en" - ALL_LANGUAGES = [ - (LANGUAGE__ENGLISH, "English"), + LOCALE__ARABIC = "ar" + LOCALE__CZECH = "cs" + LOCALE__ENGLISH = "en" + LOCALE__FRENCH = "fr" + LOCALE__GERMAN = "de" + LOCALE__HEBREW = "he" + LOCALE__JAPANESE = "ja" + LOCALE__KOREAN = "kr" + LOCALE__PORTUGUESE = "pt" + LOCALE__RUSSIAN = "ru" + LOCALE__SPANISH = "es" + # Do not wrap for translation; always present each language in its native form + ALL_LOCALES = [ + # (LOCALE__ARABIC, "Arabic"), + # (LOCALE__CZECH, "čeština"), + (LOCALE__ENGLISH, "English"), + # (LOCALE__FRENCH, "Français"), + # (LOCALE__GERMAN, "Deutsch"), + # (LOCALE__HEBREW, "Hebrew"), + # (LOCALE__JAPANESE, "Japanese"), + # (LOCALE__KOREAN, "Korean"), + # (LOCALE__PORTUGUESE, "Português"), + # (LOCALE__RUSSIAN, "русский"), + (LOCALE__SPANISH, "Español"), ] BTC_DENOMINATION__BTC = "btc" @@ -55,10 +77,10 @@ class SettingsConstants: BTC_DENOMINATION__THRESHOLD = "thr" BTC_DENOMINATION__BTCSATSHYBRID = "hyb" ALL_BTC_DENOMINATIONS = [ - (BTC_DENOMINATION__BTC, "BTC"), - (BTC_DENOMINATION__SATS, "sats"), - (BTC_DENOMINATION__THRESHOLD, "Threshold at 0.01"), - (BTC_DENOMINATION__BTCSATSHYBRID, "BTC | sats hybrid"), + (BTC_DENOMINATION__BTC, _mft("BTC")), + (BTC_DENOMINATION__SATS, _mft("sats")), + (BTC_DENOMINATION__THRESHOLD, _mft("Threshold at 0.01")), + (BTC_DENOMINATION__BTCSATSHYBRID, _mft("BTC | sats hybrid")), ] CAMERA_ROTATION__0 = 0 @@ -66,20 +88,26 @@ class SettingsConstants: CAMERA_ROTATION__180 = 180 CAMERA_ROTATION__270 = 270 ALL_CAMERA_ROTATIONS = [ - (CAMERA_ROTATION__0, "0°"), - (CAMERA_ROTATION__90, "90°"), - (CAMERA_ROTATION__180, "180°"), - (CAMERA_ROTATION__270, "270°"), + (CAMERA_ROTATION__0, _mft("0°")), + (CAMERA_ROTATION__90, _mft("90°")), + (CAMERA_ROTATION__180, _mft("180°")), + (CAMERA_ROTATION__270, _mft("270°")), ] # QR code constants DENSITY__LOW = "L" DENSITY__MEDIUM = "M" DENSITY__HIGH = "H" + # TRANSLATOR_NOTE: QR code density option: Low, Medium, High + density_low = _mft("Low") + # TRANSLATOR_NOTE: QR code density option: Low, Medium, High + density_medium = _mft("Medium") + # TRANSLATOR_NOTE: QR code density option: Low, Medium, High + density_high = _mft("High") ALL_DENSITIES = [ - (DENSITY__LOW, "Low"), - (DENSITY__MEDIUM, "Medium"), - (DENSITY__HIGH, "High"), + (DENSITY__LOW, density_low), + (DENSITY__MEDIUM, density_medium), + (DENSITY__HIGH, density_high), ] # Seed-related constants @@ -87,13 +115,14 @@ class SettingsConstants: TESTNET = "T" REGTEST = "R" ALL_NETWORKS = [ - (MAINNET, "Mainnet"), - (TESTNET, "Testnet"), - (REGTEST, "Regtest") + (MAINNET, _mft("Mainnet")), + (TESTNET, _mft("Testnet")), + (REGTEST, _mft("Regtest")) ] @classmethod def map_network_to_embit(cls, network) -> str: + # Note these are `embit` constants; do not wrap for translation if network == SettingsConstants.MAINNET: return "main" elif network == SettingsConstants.TESTNET: @@ -101,14 +130,14 @@ def map_network_to_embit(cls, network) -> str: if network == SettingsConstants.REGTEST: return "regtest" - PERSISTENT_SETTINGS__SD_INSERTED__HELP_TEXT = "Store Settings on SD card" - PERSISTENT_SETTINGS__SD_REMOVED__HELP_TEXT = "Insert SD card to enable" + PERSISTENT_SETTINGS__SD_INSERTED__HELP_TEXT = _mft("Store Settings on SD card") + PERSISTENT_SETTINGS__SD_REMOVED__HELP_TEXT = _mft("Insert SD card to enable") SINGLE_SIG = "ss" MULTISIG = "ms" ALL_SIG_TYPES = [ - (SINGLE_SIG, "Single Sig"), - (MULTISIG, "Multisig"), + (SINGLE_SIG, _mft("Single Sig")), + (MULTISIG, _mft("Multisig")), ] LEGACY_P2PKH = "leg" @@ -117,11 +146,11 @@ def map_network_to_embit(cls, network) -> str: TAPROOT = "tr" CUSTOM_DERIVATION = "cus" ALL_SCRIPT_TYPES = [ - (NATIVE_SEGWIT, "Native Segwit"), - (NESTED_SEGWIT, "Nested Segwit"), - (LEGACY_P2PKH, "Legacy"), - (TAPROOT, "Taproot"), - (CUSTOM_DERIVATION, "Custom Derivation"), + (NATIVE_SEGWIT, _mft("Native Segwit")), + (NESTED_SEGWIT, _mft("Nested Segwit")), + (LEGACY_P2PKH, _mft("Legacy")), + (TAPROOT, _mft("Taproot")), + (CUSTOM_DERIVATION, _mft("Custom Derivation")), ] WORDLIST_LANGUAGE__ENGLISH = "en" @@ -145,7 +174,8 @@ def map_network_to_embit(cls, network) -> str: # Individual SettingsEntry attr_names - SETTING__LANGUAGE = "language" + # Note: attr_names are internal constants; do not wrap for translation + SETTING__LOCALE = "locale" SETTING__WORDLIST_LANGUAGE = "wordlist_language" SETTING__PERSISTENT_SETTINGS = "persistent_settings" SETTING__COORDINATORS = "coordinators" @@ -207,8 +237,10 @@ def map_network_to_embit(cls, network) -> str: ELECTRUM_PBKDF2_ROUNDS=2048 # Label strings - LABEL__BIP39_PASSPHRASE = "BIP-39 Passphrase" - LABEL__CUSTOM_EXTENSION = "Custom Extension" # Terminology used by Electrum seeds + LABEL__BIP39_PASSPHRASE = _mft("BIP-39 Passphrase") + # TRANSLATOR_NOTE: Terminology used by Electrum seeds; equivalent to bip39 passphrase + custom_extension = _mft("Custom Extension") + LABEL__CUSTOM_EXTENSION = custom_extension @@ -279,7 +311,7 @@ def get_selection_option_display_name_by_value(self, value) -> str: option_value = option display_name = option if option_value == value: - return display_name + return _mft(display_name) def get_selection_option_value_by_display_name(self, display_name: str): @@ -345,21 +377,19 @@ class SettingsDefinition: settings_entries: List[SettingsEntry] = [ # General options - # TODO: Full babel multilanguage support! Until then, type == HIDDEN SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM, - attr_name=SettingsConstants.SETTING__LANGUAGE, + attr_name=SettingsConstants.SETTING__LOCALE, abbreviated_name="lang", - display_name="Language", + display_name=_mft("Language"), type=SettingsConstants.TYPE__SELECT_1, - visibility=SettingsConstants.VISIBILITY__HIDDEN, - selection_options=SettingsConstants.ALL_LANGUAGES, - default_value=SettingsConstants.LANGUAGE__ENGLISH), + selection_options=SettingsConstants.ALL_LOCALES, + default_value=SettingsConstants.LOCALE__ENGLISH), # TODO: Support other bip-39 wordlist languages! Until then, type == HIDDEN SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM, attr_name=SettingsConstants.SETTING__WORDLIST_LANGUAGE, abbreviated_name="wordlist_lang", - display_name="Mnemonic language", + display_name=_mft("Mnemonic language"), type=SettingsConstants.TYPE__SELECT_1, visibility=SettingsConstants.VISIBILITY__HIDDEN, selection_options=SettingsConstants.ALL_WORDLIST_LANGUAGES, @@ -368,14 +398,14 @@ class SettingsDefinition: SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM, attr_name=SettingsConstants.SETTING__PERSISTENT_SETTINGS, abbreviated_name="persistent", - display_name="Persistent settings", + display_name=_mft("Persistent settings"), help_text=SettingsConstants.PERSISTENT_SETTINGS__SD_INSERTED__HELP_TEXT, default_value=SettingsConstants.OPTION__DISABLED), SettingsEntry(category=SettingsConstants.CATEGORY__WALLET, attr_name=SettingsConstants.SETTING__COORDINATORS, abbreviated_name="coords", - display_name="Coordinator software", + display_name=_mft("Coordinator software"), type=SettingsConstants.TYPE__MULTISELECT, selection_options=SettingsConstants.ALL_COORDINATORS, default_value=[ @@ -388,7 +418,7 @@ class SettingsDefinition: SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM, attr_name=SettingsConstants.SETTING__BTC_DENOMINATION, abbreviated_name="denom", - display_name="Denomination display", + display_name=_mft("Denomination display"), type=SettingsConstants.TYPE__SELECT_1, selection_options=SettingsConstants.ALL_BTC_DENOMINATIONS, default_value=SettingsConstants.BTC_DENOMINATION__THRESHOLD), @@ -397,7 +427,7 @@ class SettingsDefinition: # Advanced options SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__NETWORK, - display_name="Bitcoin network", + display_name=_mft("Bitcoin network"), type=SettingsConstants.TYPE__SELECT_1, visibility=SettingsConstants.VISIBILITY__ADVANCED, selection_options=SettingsConstants.ALL_NETWORKS, @@ -405,7 +435,7 @@ class SettingsDefinition: SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__QR_DENSITY, - display_name="QR code density", + display_name=_mft("QR code density"), type=SettingsConstants.TYPE__SELECT_1, visibility=SettingsConstants.VISIBILITY__ADVANCED, selection_options=SettingsConstants.ALL_DENSITIES, @@ -413,14 +443,14 @@ class SettingsDefinition: SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__XPUB_EXPORT, - display_name="Xpub export", + display_name=_mft("Xpub export"), visibility=SettingsConstants.VISIBILITY__ADVANCED, default_value=SettingsConstants.OPTION__ENABLED), SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__SIG_TYPES, abbreviated_name="sigs", - display_name="Sig types", + display_name=_mft("Sig types"), type=SettingsConstants.TYPE__MULTISELECT, visibility=SettingsConstants.VISIBILITY__ADVANCED, selection_options=SettingsConstants.ALL_SIG_TYPES, @@ -429,7 +459,7 @@ class SettingsDefinition: SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__SCRIPT_TYPES, abbreviated_name="scripts", - display_name="Script types", + display_name=_mft("Script types"), type=SettingsConstants.TYPE__MULTISELECT, visibility=SettingsConstants.VISIBILITY__ADVANCED, selection_options=SettingsConstants.ALL_SCRIPT_TYPES, @@ -437,13 +467,13 @@ class SettingsDefinition: SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__XPUB_DETAILS, - display_name="Show xpub details", + display_name=_mft("Show xpub details"), visibility=SettingsConstants.VISIBILITY__ADVANCED, default_value=SettingsConstants.OPTION__ENABLED), SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__PASSPHRASE, - display_name="BIP-39 passphrase", + display_name=_mft("BIP-39 passphrase"), type=SettingsConstants.TYPE__SELECT_1, visibility=SettingsConstants.VISIBILITY__ADVANCED, selection_options=SettingsConstants.OPTIONS__ENABLED_DISABLED_REQUIRED, @@ -452,7 +482,7 @@ class SettingsDefinition: SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__CAMERA_ROTATION, abbreviated_name="camera", - display_name="Camera rotation", + display_name=_mft("Camera rotation"), type=SettingsConstants.TYPE__SELECT_1, visibility=SettingsConstants.VISIBILITY__ADVANCED, selection_options=SettingsConstants.ALL_CAMERA_ROTATIONS, @@ -460,14 +490,14 @@ class SettingsDefinition: SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__COMPACT_SEEDQR, - display_name="CompactSeedQR", + display_name=_mft("Compact SeedQR"), visibility=SettingsConstants.VISIBILITY__ADVANCED, default_value=SettingsConstants.OPTION__ENABLED), SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__BIP85_CHILD_SEEDS, abbreviated_name="bip85", - display_name="BIP-85 child seeds", + display_name=_mft("BIP-85 child seeds"), visibility=SettingsConstants.VISIBILITY__ADVANCED, default_value=SettingsConstants.OPTION__DISABLED), @@ -475,40 +505,40 @@ class SettingsDefinition: attr_name=SettingsConstants.SETTING__ELECTRUM_SEEDS, abbreviated_name="electrum", display_name="Electrum seeds", - help_text="Native Segwit only", + help_text=_mft("Native Segwit only"), visibility=SettingsConstants.VISIBILITY__ADVANCED, default_value=SettingsConstants.OPTION__DISABLED), SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__MESSAGE_SIGNING, - display_name="Message signing", + display_name=_mft("Message signing"), visibility=SettingsConstants.VISIBILITY__ADVANCED, default_value=SettingsConstants.OPTION__DISABLED), SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__PRIVACY_WARNINGS, abbreviated_name="priv_warn", - display_name="Show privacy warnings", + display_name=_mft("Show privacy warnings"), visibility=SettingsConstants.VISIBILITY__ADVANCED, default_value=SettingsConstants.OPTION__ENABLED), SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__DIRE_WARNINGS, abbreviated_name="dire_warn", - display_name="Show dire warnings", + display_name=_mft("Show dire warnings"), visibility=SettingsConstants.VISIBILITY__ADVANCED, default_value=SettingsConstants.OPTION__ENABLED), SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__QR_BRIGHTNESS_TIPS, - display_name="Show QR brightness tips", + display_name=_mft("Show QR brightness tips"), visibility=SettingsConstants.VISIBILITY__ADVANCED, default_value=SettingsConstants.OPTION__ENABLED), SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__PARTNER_LOGOS, abbreviated_name="partners", - display_name="Show partner logos", + display_name=_mft("Show partner logos"), visibility=SettingsConstants.VISIBILITY__ADVANCED, default_value=SettingsConstants.OPTION__ENABLED), @@ -524,7 +554,7 @@ class SettingsDefinition: SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM, attr_name=SettingsConstants.SETTING__QR_BRIGHTNESS, abbreviated_name="qr_brightness", - display_name="QR background color", + display_name=_mft("QR background color"), type=SettingsConstants.TYPE__FREE_ENTRY, visibility=SettingsConstants.VISIBILITY__HIDDEN, default_value=62), diff --git a/src/seedsigner/models/singleton.py b/src/seedsigner/models/singleton.py index e472e08f1..9635106b5 100644 --- a/src/seedsigner/models/singleton.py +++ b/src/seedsigner/models/singleton.py @@ -21,7 +21,7 @@ def get_instance(cls): if cls._instance: return cls._instance else: - raise Exception("Must call %s.configure_instance(config) first" % cls.__name__) + raise Exception("Must call %s.configure_instance() first" % cls.__name__) @classmethod diff --git a/src/seedsigner/resources/seedsigner-translations b/src/seedsigner/resources/seedsigner-translations new file mode 160000 index 000000000..597e03daf --- /dev/null +++ b/src/seedsigner/resources/seedsigner-translations @@ -0,0 +1 @@ +Subproject commit 597e03dafbc234ea4a7d71fe03cd828472c3eb2c diff --git a/src/seedsigner/views/psbt_views.py b/src/seedsigner/views/psbt_views.py index b0a32cd91..636751d12 100644 --- a/src/seedsigner/views/psbt_views.py +++ b/src/seedsigner/views/psbt_views.py @@ -1,26 +1,23 @@ -from embit.psbt import PSBT -from embit import script -from embit.networks import NETWORKS -from seedsigner.controller import Controller +from gettext import gettext as _ -from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerIconConstants -from seedsigner.models.encode_qr import UrPsbtQrEncoder from seedsigner.models.psbt_parser import PSBTParser from seedsigner.models.settings import SettingsConstants -from seedsigner.gui.screens.psbt_screens import PSBTOpReturnScreen, PSBTOverviewScreen, PSBTMathScreen, PSBTAddressDetailsScreen, PSBTChangeDetailsScreen, PSBTFinalizeScreen -from seedsigner.gui.screens.screen import (RET_CODE__BACK_BUTTON, ButtonListScreen, WarningScreen, DireWarningScreen, QRDisplayScreen) +from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerIconConstants +from seedsigner.gui.screens.screen import (RET_CODE__BACK_BUTTON, ButtonListScreen, ButtonOption, WarningScreen, DireWarningScreen, QRDisplayScreen) from seedsigner.views.view import BackStackView, MainMenuView, NotYetImplementedView, View, Destination class PSBTSelectSeedView(View): - SCAN_SEED = ("Scan a seed", SeedSignerIconConstants.QRCODE) - TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) - TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) - TYPE_ELECTRUM = ("Enter Electrum seed", FontAwesomeIconConstants.KEYBOARD) + SCAN_SEED = ButtonOption("Scan a seed", SeedSignerIconConstants.QRCODE) + TYPE_12WORD = ButtonOption("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) + TYPE_24WORD = ButtonOption("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) + TYPE_ELECTRUM = ButtonOption("Enter Electrum seed", FontAwesomeIconConstants.KEYBOARD) def run(self): + from seedsigner.controller import Controller + # Note: we can't just autoroute to the PSBT Overview because we might have a # multisig where we want to sign with more than one key on this device. if not self.controller.psbt: @@ -38,9 +35,10 @@ def run(self): button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)) if not PSBTParser.has_matching_input_fingerprint(psbt=self.controller.psbt, seed=seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)): # Doesn't look like this seed can sign the current PSBT - button_str += " (?)" + # TRANSLATOR_NOTE: Inserts fingerprint w/"?" to indicate that this seed can't sign the current PSBT + button_str = _("{} (?)").format(button_str) - button_data.append((button_str, SeedSignerIconConstants.FINGERPRINT)) + button_data.append(ButtonOption(button_str, SeedSignerIconConstants.FINGERPRINT)) button_data.append(self.SCAN_SEED) button_data.append(self.TYPE_12WORD) @@ -50,7 +48,7 @@ def run(self): selected_menu_num = self.run_screen( ButtonListScreen, - title="Select Signer", + title=_("Select Signer"), is_button_text_centered=False, button_data=button_data ) @@ -94,7 +92,7 @@ def __init__(self): # The PSBTParser takes a while to read the PSBT. Run the loading screen while # we wait. from seedsigner.gui.screens.screen import LoadingScreenThread - self.loading_screen = LoadingScreenThread(text="Parsing PSBT...") + self.loading_screen = LoadingScreenThread(text=_("Parsing PSBT...")) self.loading_screen.start() try: @@ -109,6 +107,7 @@ def __init__(self): def run(self): + from seedsigner.gui.screens.psbt_screens import PSBTOverviewScreen psbt_parser = self.controller.psbt_parser change_data = psbt_parser.change_data @@ -168,9 +167,9 @@ def run(self): class PSBTUnsupportedScriptTypeWarningView(View): def run(self): selected_menu_num = WarningScreen( - status_headline="Unsupported Script Type!", - text="PSBT has unsupported input script type, please verify your change addresses.", - button_data=["Continue"], + status_headline=_("Unsupported Script Type!"), + text=_("PSBT has unsupported input script type, please verify your change addresses."), + button_data=[ButtonOption("Continue")], ).display() if selected_menu_num == RET_CODE__BACK_BUTTON: @@ -188,9 +187,10 @@ def run(self): class PSBTNoChangeWarningView(View): def run(self): selected_menu_num = WarningScreen( - status_headline="Full Spend!", - text="This PSBT spends its entire input value. No change is coming back to your wallet.", - button_data=["Continue"], + # TRANSLATOR_NOTE: User will receive no change back; the inputs to this transaction are fully spent + status_headline=_("Full Spend!"), + text=_("This PSBT spends its entire input value. No change is coming back to your wallet."), + button_data=[ButtonOption("Continue")], ).display() if selected_menu_num == RET_CODE__BACK_BUTTON: @@ -214,6 +214,7 @@ class PSBTMathView(View): + change value """ def run(self): + from seedsigner.gui.screens.psbt_screens import PSBTMathScreen psbt_parser: PSBTParser = self.controller.psbt_parser if not psbt_parser: # Should not be able to get here @@ -250,20 +251,24 @@ def __init__(self, address_num): def run(self): + from seedsigner.gui.screens.psbt_screens import PSBTAddressDetailsScreen psbt_parser: PSBTParser = self.controller.psbt_parser if not psbt_parser: # Should not be able to get here raise Exception("Routing error") - title = "Will Send" + # TRANSLATOR_NOTE: Future-tense used to indicate that this transaction will send this amount, as opposed to "Send" on its own which could be misread as an instant command (e.g. "Send Now"). + title = _("Will Send") if psbt_parser.num_destinations > 1: title += f" (#{self.address_num + 1})" + button_data = [] if self.address_num < psbt_parser.num_destinations - 1: - button_data = ["Next Recipient"] + button_data.append(ButtonOption("Next Recipient")) else: - button_data = ["Next"] + # TRANSLATOR_NOTE: Short for "Next step" + button_data.append(ButtonOption("Next")) selected_menu_num = self.run_screen( PSBTAddressDetailsScreen, @@ -294,10 +299,9 @@ def run(self): class PSBTChangeDetailsView(View): - NEXT = "Next" - SKIP_VERIFICATION = "Skip Verification" - VERIFY_MULTISIG = "Verify Multisig Change" - + NEXT = ButtonOption("Next") + SKIP_VERIFICATION = ButtonOption("Skip Verification") + VERIFY_MULTISIG = ButtonOption("Verify Multisig Change") def __init__(self, change_address_num): super().__init__() @@ -305,6 +309,7 @@ def __init__(self, change_address_num): def run(self): + from seedsigner.gui.screens.psbt_screens import PSBTChangeDetailsScreen psbt_parser: PSBTParser = self.controller.psbt_parser if not psbt_parser: @@ -339,11 +344,11 @@ def run(self): derivation_path_addr_index = int(derivation_path.split("/")[-1]) if is_change_derivation_path: - title = "Your Change" - self.VERIFY_MULTISIG = "Verify Multisig Change" + # TRANSLATOR_NOTE: The amount you're receiving back from the transaction + title = _("Your Change") else: - title = "Self-Transfer" - self.VERIFY_MULTISIG = "Verify Multisig Addr" + title = _("Self-Transfer") + self.VERIFY_MULTISIG.button_label = _("Verify Multisig Addr") # if psbt_parser.num_change_outputs > 1: # title += f" (#{self.change_address_num + 1})" @@ -361,10 +366,13 @@ def run(self): else: # Single sig try: + from embit import script + from embit.networks import NETWORKS + if is_change_derivation_path: - loading_screen_text = "Verifying Change..." + loading_screen_text = _("Verifying Change...") else: - loading_screen_text = "Verifying Self-Transfer..." + loading_screen_text = _("Verifying Self-Transfer...") from seedsigner.gui.screens.screen import LoadingScreenThread loading_screen = LoadingScreenThread(text=loading_screen_text) loading_screen.start() @@ -436,6 +444,7 @@ def run(self): return Destination(PSBTFinalizeView) elif button_data[selected_menu_num] == self.VERIFY_MULTISIG: + from seedsigner.controller import Controller from seedsigner.views.seed_views import LoadMultisigWalletDescriptorView self.controller.resume_main_flow = Controller.FLOW__PSBT return Destination(LoadMultisigWalletDescriptorView) @@ -451,17 +460,19 @@ def __init__(self, is_change: bool = True, is_multisig: bool = False): def run(self): if self.is_multisig: - title = "Caution" - text = f"""PSBT's {"change" if self.is_change else "self-transfer"} address could not be verified with your multisig wallet descriptor.""" + title = _("Caution") + # TRANSLATOR_NOTE: Variable is either "change" or "self-transfer". + text = _("PSBT's {} address could not be verified with your multisig wallet descriptor.").format(_("change") if self.is_change else _("self-transfer")) else: - title = "Suspicious PSBT" - text = f"""PSBT's {"change" if self.is_change else "self-transfer"} address could not be generated from your seed.""" + title = _("Suspicious PSBT") + # TRANSLATOR_NOTE: Variable is either "change" or "self-transfer". + text = _("PSBT's {} address could not be generated from your seed.").format(_("change") if self.is_change else _("self-transfer")) DireWarningScreen( title=title, - status_headline="Address Verification Failed", + status_headline=_("Address Verification Failed"), text=text, - button_data=["Discard PSBT"], + button_data=[ButtonOption("Discard PSBT")], show_back_button=False, ).display() @@ -476,14 +487,15 @@ class PSBTOpReturnView(View): Shows the OP_RETURN data """ def run(self): + from seedsigner.gui.screens.psbt_screens import PSBTOpReturnScreen psbt_parser: PSBTParser = self.controller.psbt_parser if not psbt_parser: # Should not be able to get here raise Exception("Routing error") - title = "OP_RETURN" - button_data = ["Next"] + title = _("OP_RETURN") + button_data = [ButtonOption("Next")] selected_menu_num = self.run_screen( PSBTOpReturnScreen, @@ -502,10 +514,13 @@ def run(self): class PSBTFinalizeView(View): """ """ - APPROVE_PSBT = "Approve PSBT" + APPROVE_PSBT = ButtonOption("Approve PSBT") def run(self): + from embit.psbt import PSBT + from seedsigner.gui.screens.psbt_screens import PSBTFinalizeScreen + psbt_parser: PSBTParser = self.controller.psbt_parser psbt: PSBT = self.controller.psbt @@ -541,6 +556,8 @@ def run(self): class PSBTSignedQRDisplayView(View): def run(self): + from seedsigner.models.encode_qr import UrPsbtQrEncoder + qr_encoder = UrPsbtQrEncoder( psbt=self.controller.psbt, qr_density=self.settings.get_value(SettingsConstants.SETTING__QR_DENSITY), @@ -554,7 +571,7 @@ def run(self): class PSBTSigningErrorView(View): - SELECT_DIFF_SEED = "Select Diff Seed" + SELECT_DIFF_SEED = ButtonOption("Select Diff Seed") def run(self): psbt_parser: PSBTParser = self.controller.psbt_parser @@ -565,10 +582,10 @@ def run(self): # Just a WarningScreen here; only use DireWarningScreen for true security risks. selected_menu_num = self.run_screen( WarningScreen, - title="PSBT Error", + title=_("PSBT Error"), status_icon_name=SeedSignerIconConstants.WARNING, - status_headline="Signing Failed", - text="Signing with this seed did not add a valid signature.", + status_headline=_("Signing Failed"), + text=_("Signing with this seed did not add a valid signature."), button_data=[self.SELECT_DIFF_SEED] ) diff --git a/src/seedsigner/views/scan_views.py b/src/seedsigner/views/scan_views.py index 1b923efa0..aa83c6bd5 100644 --- a/src/seedsigner/views/scan_views.py +++ b/src/seedsigner/views/scan_views.py @@ -1,18 +1,15 @@ import logging import re -from embit.descriptor import Descriptor - -from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON -from seedsigner.models.decode_qr import DecodeQR -from seedsigner.models.seed import Seed +from gettext import gettext as _ +from seedsigner.helpers.l10n import mark_for_translation as _mft from seedsigner.models.settings import SettingsConstants -from seedsigner.views.settings_views import SettingsIngestSettingsQRView -from seedsigner.views.view import BackStackView, ErrorView, MainMenuView, NotYetImplementedView, OptionDisabledView, View, Destination +from seedsigner.views.view import BackStackView, ErrorView, MainMenuView, NotYetImplementedView, View, Destination logger = logging.getLogger(__name__) + class ScanView(View): """ The catch-all generic scanning View that will accept any of our supported QR @@ -22,11 +19,13 @@ class ScanView(View): dedicated errors when an unexpected QR type is scanned (e.g. Scan PSBT was selected but a SeedQR was scanned). """ - instructions_text = "Scan a QR code" - invalid_qr_type_message = "QRCode not recognized or not yet supported." + instructions_text = _mft("Scan a QR code") + invalid_qr_type_message = _mft("QRCode not recognized or not yet supported.") def __init__(self): + from seedsigner.models.decode_qr import DecodeQR + super().__init__() # Define the decoder here to make it available to child classes' is_valid_qr_type # checks and so we can inject data into it in the test suite's `before_run()`. @@ -60,10 +59,11 @@ def run(self): # current flow. # Report QR types in more human-readable text (e.g. QRType # `seed__compactseedqr` as "seed: compactseedqr"). + # TODO: cleanup l10n presentation return Destination(ErrorView, view_args=dict( title="Error", - status_headline="Wrong QR Type", - text=self.invalid_qr_type_message + f""", received "{self.decoder.qr_type.replace("__", ": ").replace("_", " ")}\" format""", + status_headline=_("Wrong QR Type"), + text=_(self.invalid_qr_type_message) + f""", received "{self.decoder.qr_type.replace("__", ": ").replace("_", " ")}\" format""", button_text="Back", next_destination=Destination(BackStackView, skip_current_view=True), )) @@ -73,10 +73,11 @@ def run(self): if not seed_mnemonic: # seed is not valid, Exit if not valid with message - raise Exception("Not yet implemented!") + return Destination(NotYetImplementedView) else: # Found a valid mnemonic seed! All new seeds should be considered # pending (might set a passphrase, SeedXOR, etc) until finalized. + from seedsigner.models.seed import Seed from .seed_views import SeedFinalizeView self.controller.storage.set_pending_seed( Seed(mnemonic=seed_mnemonic, wordlist_language_code=self.wordlist_language_code) @@ -95,10 +96,12 @@ def run(self): return Destination(PSBTSelectSeedView, skip_current_view=True) elif self.decoder.is_settings: + from seedsigner.views.settings_views import SettingsIngestSettingsQRView data = self.decoder.get_settings_data() return Destination(SettingsIngestSettingsQRView, view_args=dict(data=data)) elif self.decoder.is_wallet_descriptor: + from embit.descriptor import Descriptor from seedsigner.views.seed_views import MultisigWalletDescriptorView descriptor_str = self.decoder.get_wallet_descriptor() @@ -161,10 +164,10 @@ def run(self): # start everything over. self.controller.resume_main_flow = None return Destination(ErrorView, view_args=dict( - title="Error", - status_headline="Unknown QR Type", - text="QRCode is invalid or is a data format not yet supported.", - button_text="Done", + title=_("Error"), + status_headline=_("Unknown QR Type"), + text=_("QRCode is invalid or is a data format not yet supported."), + button_text=_("Done"), next_destination=Destination(MainMenuView, clear_history=True), )) @@ -173,8 +176,8 @@ def run(self): class ScanPSBTView(ScanView): - instructions_text = "Scan PSBT" - invalid_qr_type_message = "Expected a PSBT" + instructions_text = _mft("Scan PSBT") + invalid_qr_type_message = _mft("Expected a PSBT") @property def is_valid_qr_type(self): @@ -183,8 +186,8 @@ def is_valid_qr_type(self): class ScanSeedQRView(ScanView): - instructions_text = "Scan SeedQR" - invalid_qr_type_message = f"Expected a SeedQR" + instructions_text = _mft("Scan SeedQR") + invalid_qr_type_message = _mft("Expected a SeedQR") @property def is_valid_qr_type(self): @@ -193,8 +196,8 @@ def is_valid_qr_type(self): class ScanWalletDescriptorView(ScanView): - instructions_text = "Scan descriptor" - invalid_qr_type_message = "Expected a wallet descriptor QR" + instructions_text = _mft("Scan descriptor") + invalid_qr_type_message = _mft("Expected a wallet descriptor QR") @property def is_valid_qr_type(self): @@ -203,8 +206,8 @@ def is_valid_qr_type(self): class ScanAddressView(ScanView): - instructions_text = "Scan address QR" - invalid_qr_type_message = "Expected an address QR" + instructions_text = _mft("Scan address QR") + invalid_qr_type_message = _mft("Expected an address QR") @property def is_valid_qr_type(self): diff --git a/src/seedsigner/views/screensaver.py b/src/seedsigner/views/screensaver.py index 84476da47..d96f7f5d0 100644 --- a/src/seedsigner/views/screensaver.py +++ b/src/seedsigner/views/screensaver.py @@ -3,16 +3,19 @@ import random import time -from PIL import Image +from dataclasses import dataclass +from gettext import gettext as _ from seedsigner.gui.components import Fonts, GUIConstants, load_image from seedsigner.gui.screens.screen import BaseScreen from seedsigner.models.settings import Settings from seedsigner.models.settings_definition import SettingsConstants +from seedsigner.views.view import View logger = logging.getLogger(__name__) + # TODO: This early code is now outdated vis-a-vis Screen vs View distinctions class LogoScreen(BaseScreen): def __init__(self): @@ -29,49 +32,90 @@ def __init__(self): self.partner_logos[partner] = load_image(logo_url) + def _run(self): + pass + + def get_random_partner(self) -> str: return self.partners[random.randrange(len(self.partners))] +@dataclass +class OpeningSplashView(View): + is_screenshot_renderer: bool = False + force_partner_logos: bool|None = None + + def run(self): + self.run_screen( + OpeningSplashScreen, + is_screenshot_renderer=self.is_screenshot_renderer, + force_partner_logos=self.force_partner_logos + ) + + + class OpeningSplashScreen(LogoScreen): - def start(self): + def __init__(self, is_screenshot_renderer=False, force_partner_logos=None): + self.is_screenshot_renderer = is_screenshot_renderer + self.force_partner_logos = force_partner_logos + super().__init__() + + + def _render(self): + from PIL import Image from seedsigner.controller import Controller controller = Controller.get_instance() + # TODO: Fix for the screenshot generator. When generating screenshots for + # multiple locales, there is a button still in the canvas from the previous + # screenshot, even though the Renderer has been reconfigured and re- + # instantiated. This is a hack to clear the screen for now. + self.clear_screen() + show_partner_logos = Settings.get_instance().get_value(SettingsConstants.SETTING__PARTNER_LOGOS) == SettingsConstants.OPTION__ENABLED + if self.force_partner_logos is not None: + show_partner_logos = self.force_partner_logos if show_partner_logos: logo_offset_y = -56 else: logo_offset_y = 0 - # Fade in alpha - for i in range(250, -1, -25): - self.logo.putalpha(255 - i) - background = Image.new("RGBA", size=self.logo.size, color="black") - self.renderer.canvas.paste(Image.alpha_composite(background, self.logo), (0, logo_offset_y)) - self.renderer.show_image() + background = Image.new("RGBA", size=self.logo.size, color="black") + if not self.is_screenshot_renderer: + # Fade in alpha + for i in range(250, -1, -25): + self.logo.putalpha(255 - i) + self.renderer.canvas.paste(Image.alpha_composite(background, self.logo), (0, logo_offset_y)) + self.renderer.show_image() + else: + # Skip animation for the screenshot generator + self.renderer.canvas.paste(self.logo, (0, logo_offset_y)) # Display version num below SeedSigner logo - font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.TOP_NAV_TITLE_FONT_SIZE) + font = Fonts.get_font(GUIConstants.get_body_font_name(), GUIConstants.get_top_nav_title_font_size()) version = f"v{controller.VERSION}" - (left, top, version_tw, version_th) = font.getbbox(version, anchor="lt") # The logo png is 240x240, but the actual logo is 70px tall, vertically centered + logo_height = 70 version_x = int(self.renderer.canvas_width/2) - version_y = int(self.canvas_height/2) + 35 + logo_offset_y + GUIConstants.COMPONENT_PADDING + version_y = int(self.canvas_height/2) + int(logo_height/2) + logo_offset_y + GUIConstants.COMPONENT_PADDING self.renderer.draw.text(xy=(version_x, version_y), text=version, font=font, fill=GUIConstants.ACCENT_COLOR, anchor="mt") - self.renderer.show_image() + + if not self.is_screenshot_renderer: + self.renderer.show_image() if show_partner_logos: - # Hold on the version num for a moment - time.sleep(1) + if not self.is_screenshot_renderer: + # Hold on the version num for a moment + time.sleep(1) # Set up the partner logo partner_logo: Image.Image = self.partner_logos[self.get_random_partner()] - font = Fonts.get_font(GUIConstants.TOP_NAV_TITLE_FONT_NAME, GUIConstants.BODY_FONT_SIZE) - sponsor_text = "With support from:" + font = Fonts.get_font(GUIConstants.get_top_nav_title_font_name(), GUIConstants.get_body_font_size()) + # TRANSLATOR_NOTE: This is on the opening splash screen, displayed above the HRF logo + sponsor_text = _("With support from:") (left, top, tw, th) = font.getbbox(sponsor_text, anchor="lt") x = int((self.renderer.canvas_width) / 2) @@ -87,12 +131,15 @@ def start(self): self.renderer.show_image() - time.sleep(2) + if not self.is_screenshot_renderer: + # Hold on the splash screen for a moment + time.sleep(2) class ScreensaverScreen(LogoScreen): def __init__(self, buttons): + from PIL import Image super().__init__() self.buttons = buttons diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 9483c1119..d478a4d08 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -1,26 +1,19 @@ import logging -import embit import random import time from binascii import hexlify -from embit import bip39 +from gettext import gettext as _ + from embit.descriptor import Descriptor -from embit.networks import NETWORKS -from typing import List -from seedsigner.controller import Controller from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerIconConstants -from seedsigner.helpers import embit_utils from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen, WarningScreen, DireWarningScreen, seed_screens) -from seedsigner.gui.screens.screen import LargeIconStatusScreen, QRDisplayScreen -from seedsigner.helpers import embit_utils -from seedsigner.models.decode_qr import DecodeQR +from seedsigner.gui.screens.screen import ButtonOption from seedsigner.models.encode_qr import CompactSeedQrEncoder, GenericStaticQrEncoder, SeedQrEncoder, SpecterXPubQrEncoder, StaticXpubQrEncoder, UrXpubQrEncoder -from seedsigner.models.psbt_parser import PSBTParser from seedsigner.models.qr_type import QRType -from seedsigner.models.seed import InvalidSeedException, Seed +from seedsigner.models.seed import Seed from seedsigner.models.settings import Settings, SettingsConstants from seedsigner.models.settings_definition import SettingsDefinition from seedsigner.models.threads import BaseThread, ThreadsafeCounter @@ -29,8 +22,9 @@ logger = logging.getLogger(__name__) + class SeedsMenuView(View): - LOAD = "Load a seed" + LOAD = ButtonOption("Load a seed") def __init__(self): super().__init__() @@ -48,25 +42,25 @@ def run(self): button_data = [] for seed in self.seeds: - button_data.append((seed["fingerprint"], SeedSignerIconConstants.FINGERPRINT)) - button_data.append("Load a seed") + button_data.append(ButtonOption(seed["fingerprint"], SeedSignerIconConstants.FINGERPRINT)) + button_data.append(self.LOAD) selected_menu_num = self.run_screen( ButtonListScreen, - title="In-Memory Seeds", + title=_("In-Memory Seeds"), is_button_text_centered=False, button_data=button_data ) - if len(self.seeds) > 0 and selected_menu_num < len(self.seeds): + if selected_menu_num == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + + elif len(self.seeds) > 0 and selected_menu_num < len(self.seeds): return Destination(SeedOptionsView, view_args={"seed_num": selected_menu_num}) - elif selected_menu_num == len(self.seeds): + elif button_data[selected_menu_num] == self.LOAD: return Destination(LoadSeedView) - elif selected_menu_num == RET_CODE__BACK_BUTTON: - return Destination(BackStackView) - class SeedSelectSeedView(View): @@ -77,33 +71,34 @@ class SeedSelectSeedView(View): * `flow`: indicates which user flow is in progress during seed selection (e.g. verify single sig addr or sign message). """ - SCAN_SEED = ("Scan a seed", SeedSignerIconConstants.QRCODE) - TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) - TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) - TYPE_ELECTRUM = ("Enter Electrum seed", FontAwesomeIconConstants.KEYBOARD) + SCAN_SEED = ButtonOption("Scan a seed", SeedSignerIconConstants.QRCODE) + TYPE_12WORD = ButtonOption("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) + TYPE_24WORD = ButtonOption("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) + TYPE_ELECTRUM = ButtonOption("Enter Electrum seed", FontAwesomeIconConstants.KEYBOARD) - def __init__(self, flow: str = Controller.FLOW__VERIFY_SINGLESIG_ADDR): + def __init__(self, flow: str): super().__init__() self.flow = flow def run(self): + from seedsigner.controller import Controller seeds = self.controller.storage.seeds if self.flow == Controller.FLOW__VERIFY_SINGLESIG_ADDR: - title = "Verify Address" + title = _("Verify Address") if not seeds: - text = "Load the seed to verify" + text = _("Load the seed to verify") else: - text = "Select seed to verify" + text = _("Select seed to verify") elif self.flow == Controller.FLOW__SIGN_MESSAGE: - title = "Sign Message" + title = _("Sign Message") if not seeds: - text = "Load the seed to sign with" + text = _("Load the seed to sign with") else: - text = "Select seed to sign with" + text = _("Select seed to sign with") else: raise Exception(f"Unsupported `flow` specified: {self.flow}") @@ -111,11 +106,7 @@ def run(self): button_data = [] for seed in seeds: button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)) - - if seed.passphrase is not None: - # TODO: Include lock icon on right side of button - pass - button_data.append((button_str, SeedSignerIconConstants.FINGERPRINT, "blue")) + button_data.append(ButtonOption(button_str, SeedSignerIconConstants.FINGERPRINT, icon_color="blue")) button_data.append(self.SCAN_SEED) button_data.append(self.TYPE_12WORD) @@ -168,11 +159,11 @@ def run(self): Loading seeds, passphrases, etc ****************************************************************************""" class LoadSeedView(View): - SEED_QR = (" Scan a SeedQR", SeedSignerIconConstants.QRCODE) - TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) - TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) - TYPE_ELECTRUM = ("Enter Electrum seed", FontAwesomeIconConstants.KEYBOARD) - CREATE = (" Create a seed", SeedSignerIconConstants.PLUS) + SEED_QR = ButtonOption("Scan a SeedQR", SeedSignerIconConstants.QRCODE) + TYPE_12WORD = ButtonOption("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) + TYPE_24WORD = ButtonOption("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) + TYPE_ELECTRUM = ButtonOption("Enter Electrum seed", FontAwesomeIconConstants.KEYBOARD) + CREATE = ButtonOption("Create a seed", SeedSignerIconConstants.PLUS) def run(self): button_data = [ @@ -188,7 +179,7 @@ def run(self): selected_menu_num = self.run_screen( ButtonListScreen, - title="Load A Seed", + title=_("Load A Seed"), is_button_text_centered=False, button_data=button_data ) @@ -228,7 +219,8 @@ def __init__(self, cur_word_index: int = 0, is_calc_final_word: bool=False): def run(self): ret = self.run_screen( seed_screens.SeedMnemonicEntryScreen, - title=f"Seed Word #{self.cur_word_index + 1}", # Human-readable 1-indexing! + # TRANSLATOR_NOTE: Inserts the word number (e.g. "Seed Word #6") + title=_("Seed Word #{}").format(self.cur_word_index + 1), # Human-readable 1-indexing! initial_letters=list(self.cur_word) if self.cur_word else ["a"], wordlist=Seed.get_wordlist(wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)), ) @@ -265,6 +257,7 @@ def run(self): ) else: # Attempt to finalize the mnemonic + from seedsigner.models.seed import InvalidSeedException try: self.controller.storage.convert_pending_mnemonic_to_pending_seed() except InvalidSeedException: @@ -275,21 +268,21 @@ def run(self): class SeedMnemonicInvalidView(View): - EDIT = "Review & Edit" - DISCARD = ("Discard", None, None, "red") + EDIT = ButtonOption("Review & Edit") + DISCARD = ButtonOption("Discard", button_label_color="red") def __init__(self): super().__init__() - self.mnemonic: List[str] = self.controller.storage.pending_mnemonic + self.mnemonic: list[str] = self.controller.storage.pending_mnemonic def run(self): button_data = [self.EDIT, self.DISCARD] selected_menu_num = self.run_screen( WarningScreen, - title="Invalid Mnemonic!", + title=_("Invalid Mnemonic!"), status_headline=None, - text=f"Checksum failure; not a valid seed phrase.", + text=_("Checksum failure; not a valid seed phrase."), show_back_button=False, button_data=button_data, ) @@ -304,19 +297,33 @@ def run(self): class SeedFinalizeView(View): - FINALIZE = "Done" + FINALIZE = ButtonOption("Done") + PASSPHRASE = ButtonOption("BIP-39 Passphrase") def __init__(self): super().__init__() self.seed = self.controller.storage.get_pending_seed() - self.fingerprint = self.seed.get_fingerprint(network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)) + + if self.seed.get_fingerprint == "": + # Expected normal user flow + self.fingerprint = self.seed.get_fingerprint(network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)) + + else: + # This view should display the "naked" seed's fingerprint. Normally the + # just-loaded seed would be naked, but this is special handling for the + # screenshot generator which creates a pending seed w/a passphrase already + # set. + passphrase = self.seed.passphrase + self.seed.set_passphrase("") + self.fingerprint = self.seed.get_fingerprint(network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)) + self.seed.set_passphrase(passphrase) def run(self): button_data = [self.FINALIZE] - passphrase_button = self.seed.passphrase_label + self.PASSPHRASE.button_label = self.seed.passphrase_label if self.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) != SettingsConstants.OPTION__DISABLED: - button_data.append(passphrase_button) + button_data.append(self.PASSPHRASE) selected_menu_num = self.run_screen( seed_screens.SeedFinalizeScreen, @@ -328,20 +335,32 @@ def run(self): seed_num = self.controller.storage.finalize_pending_seed() return Destination(SeedOptionsView, view_args={"seed_num": seed_num}, clear_history=True) - elif button_data[selected_menu_num] == passphrase_button: + elif button_data[selected_menu_num] == self.PASSPHRASE: return Destination(SeedAddPassphraseView) + elif selected_menu_num == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + class SeedAddPassphraseView(View): - def __init__(self): + """ + initial_keyboard: used by the screenshot generator to render each different keyboard layout. + """ + def __init__(self, initial_keyboard: str = seed_screens.SeedAddPassphraseScreen.KEYBOARD__LOWERCASE_BUTTON_TEXT): super().__init__() + self.initial_keyboard = initial_keyboard self.seed = self.controller.storage.get_pending_seed() def run(self): passphrase_title=self.seed.passphrase_label - ret_dict = self.run_screen(seed_screens.SeedAddPassphraseScreen, passphrase=self.seed.passphrase, title=passphrase_title) + ret_dict = self.run_screen( + seed_screens.SeedAddPassphraseScreen, + passphrase=self.seed.passphrase, + title=passphrase_title, + initial_keyboard=self.initial_keyboard, + ) # The new passphrase will be the return value; it might be empty. self.seed.set_passphrase(ret_dict["passphrase"]) @@ -361,8 +380,8 @@ def run(self): class SeedAddPassphraseExitDialogView(View): - EDIT = "Edit passphrase" - DISCARD = ("Discard passphrase", None, None, "red") + EDIT = ButtonOption("Edit passphrase") + DISCARD = ButtonOption("Discard passphrase", button_label_color="red") def __init__(self): super().__init__() @@ -374,9 +393,9 @@ def run(self): selected_menu_num = self.run_screen( WarningScreen, - title="Discard passphrase?", + title=_("Discard passphrase?"), status_headline=None, - text=f"Your current passphrase entry will be erased", + text=_("Your current passphrase entry will be erased"), show_back_button=False, button_data=button_data, ) @@ -394,8 +413,8 @@ class SeedReviewPassphraseView(View): """ Display the completed passphrase back to the user. """ - EDIT = "Edit passphrase" - DONE = "Done" + EDIT = ButtonOption("Edit passphrase") + DONE = ButtonOption("Done") def __init__(self): super().__init__() @@ -434,8 +453,8 @@ def run(self): class SeedDiscardView(View): - KEEP = "Keep Seed" - DISCARD = ("Discard", None, None, "red") + KEEP = ButtonOption("Keep Seed") + DISCARD = ButtonOption("Discard", button_label_color="red") def __init__(self, seed_num: int = None): super().__init__() @@ -450,11 +469,13 @@ def run(self): button_data = [self.KEEP, self.DISCARD] fingerprint = self.seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)) + # TRANSLATOR_NOTE: Inserts the seed fingerprint + text = _("Wipe seed {} from the device?").format(fingerprint) selected_menu_num = self.run_screen( WarningScreen, - title="Discard Seed?", + title=_("Discard Seed?"), status_headline=None, - text=f"Wipe seed {fingerprint} from the device?", + text=text, show_back_button=False, button_data=button_data, ) @@ -484,9 +505,9 @@ class SeedElectrumMnemonicStartView(View): def run(self): self.run_screen( WarningScreen, - title="Electrum warning", + title=_("Electrum warning"), status_headline=None, - text=f"Some features are disabled for Electrum seeds.", + text=_("Some features are disabled for Electrum seeds."), show_back_button=False, ) @@ -500,14 +521,14 @@ def run(self): Views for actions on individual seeds: ****************************************************************************""" class SeedOptionsView(View): - SCAN_PSBT = ("Scan PSBT", SeedSignerIconConstants.QRCODE) - VERIFY_ADDRESS = "Verify Addr" - EXPORT_XPUB = "Export Xpub" - EXPLORER = "Address Explorer" - SIGN_MESSAGE = "Sign Message" - BACKUP = ("Backup Seed", None, None, None, SeedSignerIconConstants.CHEVRON_RIGHT) - BIP85_CHILD_SEED = "BIP-85 Child Seed" - DISCARD = ("Discard Seed", None, None, "red") + SCAN_PSBT = ButtonOption("Scan PSBT", SeedSignerIconConstants.QRCODE) + VERIFY_ADDRESS = ButtonOption("Verify Addr") + EXPORT_XPUB = ButtonOption("Export Xpub") + EXPLORER = ButtonOption("Address Explorer") + SIGN_MESSAGE = ButtonOption("Sign Message") + BACKUP = ButtonOption("Backup Seed", right_icon_name=SeedSignerIconConstants.CHEVRON_RIGHT) + BIP85_CHILD_SEED = ButtonOption("BIP-85 Child Seed") + DISCARD = ButtonOption("Discard Seed", button_label_color="red") def __init__(self, seed_num: int): @@ -517,6 +538,7 @@ def __init__(self, seed_num: int): def run(self): + from seedsigner.controller import Controller from seedsigner.views.psbt_views import PSBTOverviewView if self.controller.unverified_address: @@ -536,6 +558,7 @@ def run(self): return Destination(SeedSignMessageConfirmMessageView, skip_current_view=True) if self.controller.psbt: + from seedsigner.models.psbt_parser import PSBTParser if PSBTParser.has_matching_input_fingerprint(self.controller.psbt, self.seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)): if self.controller.resume_main_flow and self.controller.resume_main_flow == Controller.FLOW__PSBT: # Re-route us directly back to the start of the PSBT flow @@ -546,8 +569,9 @@ def run(self): button_data = [] if self.controller.unverified_address: + # TODO: Verify that an addr verification flow can actually reach this code addr = self.controller.unverified_address["address"][:7] - self.VERIFY_ADDRESS += f" {addr}" + self.VERIFY_ADDRESS.button_label += f" {addr}" button_data.append(self.VERIFY_ADDRESS) button_data.append(self.SCAN_PSBT) @@ -610,8 +634,8 @@ def run(self): class SeedBackupView(View): - VIEW_WORDS = "View Seed Words" - EXPORT_SEEDQR = "Export as SeedQR" + VIEW_WORDS = ButtonOption("View Seed Words") + EXPORT_SEEDQR = ButtonOption("Export as SeedQR") def __init__(self, seed_num): super().__init__() @@ -627,7 +651,7 @@ def run(self): selected_menu_num = self.run_screen( ButtonListScreen, - title="Backup Seed", + title=_("Backup Seed"), button_data=button_data, is_bottom_list=True, ) @@ -647,8 +671,8 @@ def run(self): Export Xpub flow ****************************************************************************""" class SeedExportXpubSigTypeView(View): - SINGLE_SIG = "Single Sig" - MULTISIG = "Multisig" + SINGLE_SIG = ButtonOption("Single Sig", return_data=SettingsConstants.SINGLE_SIG) + MULTISIG = ButtonOption("Multisig", return_data=SettingsConstants.MULTISIG) def __init__(self, seed_num: int): super().__init__() @@ -660,22 +684,18 @@ def run(self): # Nothing to select; skip this screen return Destination(SeedExportXpubScriptTypeView, view_args={"seed_num": self.seed_num, "sig_type": self.settings.get_value(SettingsConstants.SETTING__SIG_TYPES)[0]}, skip_current_view=True) - button_data=[self.SINGLE_SIG, self.MULTISIG] + button_data = [self.SINGLE_SIG, self.MULTISIG] selected_menu_num = self.run_screen( ButtonListScreen, - title="Export Xpub", + title=_("Export Xpub"), button_data=button_data ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - if button_data[selected_menu_num] == self.SINGLE_SIG: - return Destination(SeedExportXpubScriptTypeView, view_args={"seed_num": self.seed_num, "sig_type": SettingsConstants.SINGLE_SIG}) - - elif button_data[selected_menu_num] == self.MULTISIG: - return Destination(SeedExportXpubScriptTypeView, view_args={"seed_num": self.seed_num, "sig_type": SettingsConstants.MULTISIG}) + return Destination(SeedExportXpubScriptTypeView, view_args={"seed_num": self.seed_num, "sig_type": button_data[selected_menu_num].return_data}) @@ -687,6 +707,7 @@ def __init__(self, seed_num: int, sig_type: str): def run(self): + from seedsigner.controller import Controller from .tools_views import ToolsAddressExplorerAddressTypeView args = {"seed_num": self.seed_num, "sig_type": self.sig_type} @@ -709,13 +730,14 @@ def run(self): else: return Destination(SeedExportXpubCoordinatorView, view_args=args, skip_current_view=True) - title = "Export Xpub" + title = _("Export Xpub") if self.controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER: - title = "Address Explorer" + title = _("Address Explorer") button_data = [] - for script_type in self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SCRIPT_TYPES): - button_data.append(script_type) + for script_type, display_name in SettingsConstants.ALL_SCRIPT_TYPES: + if script_type in self.settings.get_value(SettingsConstants.SETTING__SCRIPT_TYPES): + button_data.append(ButtonOption(display_name, return_data=script_type)) selected_menu_num = self.run_screen( ButtonListScreen, @@ -732,9 +754,7 @@ def run(self): return Destination(BackStackView) else: - script_types_settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__SCRIPT_TYPES) - selected_display_name = button_data[selected_menu_num] - args["script_type"] = script_types_settings_entry.get_selection_option_value_by_display_name(selected_display_name) + args["script_type"] = button_data[selected_menu_num].return_data if args["script_type"] == SettingsConstants.CUSTOM_DERIVATION: return Destination(SeedExportXpubCustomDerivationView, view_args=args) @@ -757,6 +777,7 @@ def __init__(self, seed_num: int, sig_type: str, script_type: str): def run(self): + from seedsigner.controller import Controller ret = self.run_screen( seed_screens.SeedExportXpubCustomDerivationScreen, initial_value=self.custom_derivation_path, @@ -805,11 +826,13 @@ def run(self): args["coordinator"] = self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)[0] return Destination(SeedExportXpubWarningView, view_args=args, skip_current_view=True) - button_data = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS) + button_data = [] + for display_name, setting_option in zip(self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS), self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)): + button_data.append(ButtonOption(display_name, return_data=setting_option)) selected_menu_num = self.run_screen( ButtonListScreen, - title="Export Xpub", + title=_("Export Xpub"), is_button_text_centered=False, button_data=button_data, ) @@ -817,9 +840,10 @@ def run(self): if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - coordinators_settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__COORDINATORS) - selected_display_name = button_data[selected_menu_num] - args["coordinator"] = coordinators_settings_entry.get_selection_option_value_by_display_name(selected_display_name) + # coordinators_settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__COORDINATORS) + # selected_display_name = button_data[selected_menu_num] + # args["coordinator"] = coordinators_settings_entry.get_selection_option_value_by_display_name(selected_display_name) + args["coordinator"] = button_data[selected_menu_num].return_data return Destination(SeedExportXpubWarningView, view_args=args) @@ -855,8 +879,8 @@ def run(self): selected_menu_num = self.run_screen( WarningScreen, - status_headline="Privacy Leak!", - text="""Xpub can be used to view all future transactions.""", + status_headline=_("Privacy Leak!"), + text=_("Xpub can be used to view all future transactions."), ) if selected_menu_num == 0: @@ -891,6 +915,7 @@ def run(self): elif seed_derivation_override: derivation_path = seed_derivation_override else: + from seedsigner.helpers import embit_utils derivation_path = embit_utils.get_standard_derivation_path( network=self.settings.get_value(SettingsConstants.SETTING__NETWORK), wallet_type=self.sig_type, @@ -904,17 +929,19 @@ def run(self): else: # The derivation calc takes a few moments. Run the loading screen while we wait. from seedsigner.gui.screens.screen import LoadingScreenThread - self.loading_screen = LoadingScreenThread(text="Generating xpub...") + self.loading_screen = LoadingScreenThread(text=_("Generating xpub...")) self.loading_screen.start() try: + from embit.bip32 import HDKey + from embit.networks import NETWORKS embit_network = NETWORKS[SettingsConstants.map_network_to_embit(self.settings.get_value(SettingsConstants.SETTING__NETWORK))] version = self.seed.detect_version( derivation_path, self.settings.get_value(SettingsConstants.SETTING__NETWORK), self.sig_type ) - root = embit.bip32.HDKey.from_seed( + root = HDKey.from_seed( self.seed.seed_bytes, version=embit_network["xprv"] ) @@ -966,8 +993,7 @@ def __init__(self, seed_num: int, coordinator: str, derivation_path: str, sig_ty self.qr_encoder = SpecterXPubQrEncoder(**encoder_args) elif coordinator in [SettingsConstants.COORDINATOR__BLUE_WALLET, - SettingsConstants.COORDINATOR__KEEPER, - SettingsConstants.COORDINATOR__NUNCHUK]: + SettingsConstants.COORDINATOR__KEEPER]: self.qr_encoder = StaticXpubQrEncoder(**encoder_args) else: @@ -975,6 +1001,7 @@ def __init__(self, seed_num: int, coordinator: str, derivation_path: str, sig_ty def run(self): + from seedsigner.gui.screens.screen import QRDisplayScreen self.run_screen( QRDisplayScreen, qr_encoder=self.qr_encoder @@ -1010,7 +1037,7 @@ def run(self): selected_menu_num = self.run_screen( DireWarningScreen, - text="""Never input your seed phrase into a device that connects to the internet.""", + text=_("You must keep your seed words private & away from all online devices."), ) if selected_menu_num == 0: @@ -1023,6 +1050,9 @@ def run(self): class SeedWordsView(View): + NEXT = ButtonOption("Next") + DONE = ButtonOption("Done") + def __init__(self, seed_num: int, bip85_data: dict = None, page_index: int = 0): super().__init__() self.seed_num = seed_num @@ -1035,26 +1065,24 @@ def __init__(self, seed_num: int, bip85_data: dict = None, page_index: int = 0): def run(self): - NEXT = "Next" - DONE = "Done" - # Slice the mnemonic to our current 4-word section words_per_page = 4 # TODO: eventually make this configurable for bigger screens? if self.bip85_data is not None: mnemonic = self.seed.get_bip85_child_mnemonic(self.bip85_data["child_index"], self.bip85_data["num_words"]).split() - title = f"""Child #{self.bip85_data["child_index"]}""" + # TRANSLATOR_NOTE: Inserts the child index (e.g. "Child #0") + title = _("Child #{}").format(self.bip85_data["child_index"]) else: mnemonic = self.seed.mnemonic_display_list - title = "Seed Words" + title = _("Seed Words") words = mnemonic[self.page_index*words_per_page:(self.page_index + 1)*words_per_page] button_data = [] num_pages = int(len(mnemonic)/words_per_page) if self.page_index < num_pages - 1 or self.seed_num is None: - button_data.append(NEXT) + button_data.append(self.NEXT) else: - button_data.append(DONE) + button_data.append(self.DONE) selected_menu_num = seed_screens.SeedWordsScreen( title=f"{title}: {self.page_index+1}/{num_pages}", @@ -1067,7 +1095,7 @@ def run(self): if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - if button_data[selected_menu_num] == NEXT: + if button_data[selected_menu_num] == self.NEXT: if self.seed_num is None and self.page_index == num_pages - 1: return Destination( SeedWordsBackupTestPromptView, @@ -1079,7 +1107,7 @@ def run(self): view_args=dict(seed_num=self.seed_num, page_index=self.page_index + 1, bip85_data=self.bip85_data) ) - elif button_data[selected_menu_num] == DONE: + elif button_data[selected_menu_num] == self.DONE: # Must clear history to avoid BACK button returning to private info return Destination( SeedWordsBackupTestPromptView, @@ -1099,31 +1127,31 @@ class SeedBIP85ApplicationModeView(View): * WIF (HDSEED) * XPRV (BIP32) """ + # TODO: Future enhancement to display WIF (HD-SEED) and XPRV (Bip32)? + WORDS_12 = ButtonOption("12 Words") + WORDS_24 = ButtonOption("24 Words") + def __init__(self, seed_num: int): super().__init__() self.seed_num = seed_num self.num_words = 0 - self.bip85_app_num = 39 # TODO: Support other Application numbers + self.bip85_app_num = 39 # TODO: Support other Application numbers; TODO: Define this as a constant def run(self): - # TODO: Future enhancement to display WIF (HD-SEED) and XPRV (Bip32)? - WORDS_12 = "12 Words" - WORDS_24 = "24 Words" - - button_data = [WORDS_12, WORDS_24] + button_data = [self.WORDS_12, self.WORDS_24] selected_menu_num = ButtonListScreen( - title="BIP-85 Num Words", + title=_("BIP-85 Num Words"), button_data=button_data ).display() if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - if button_data[selected_menu_num] == WORDS_12: + if button_data[selected_menu_num] == self.WORDS_12: self.num_words = 12 - elif button_data[selected_menu_num] == WORDS_24: + elif button_data[selected_menu_num] == self.WORDS_24: self.num_words = 24 return Destination( @@ -1133,8 +1161,8 @@ def run(self): -# View to retrieve the derived seed index class SeedBIP85SelectChildIndexView(View): + # View to retrieve the derived seed index def __init__(self, seed_num: int, num_words: int): super().__init__() self.seed_num = seed_num @@ -1142,7 +1170,7 @@ def __init__(self, seed_num: int, num_words: int): def run(self): - # Change this later to use the generic Screen input keyboard + # TODO: Change this later to use the generic Screen input keyboard ret = seed_screens.SeedBIP85SelectChildIndexScreen().display() if ret == RET_CODE__BACK_BUTTON: @@ -1176,11 +1204,11 @@ def __init__(self, seed_num: int, num_words: int): def run(self): DireWarningScreen( - title="BIP-85 Index Error", + title=_("BIP-85 Index Error"), show_back_button=False, - status_headline=f"Invalid Child Index", - text=f"BIP-85 Child Index must be between 0 and {2**31-1}.", - button_data=["Try Again"] + status_headline=_("Invalid Child Index"), + text=_("BIP-85 Child Index must be between 0 and 2^31-1."), + button_data=[ButtonOption("Try Again")] ).display() return Destination( @@ -1198,6 +1226,9 @@ def run(self): Seed Words Backup Test ****************************************************************************""" class SeedWordsBackupTestPromptView(View): + VERIFY = ButtonOption("Verify") + SKIP = ButtonOption("Skip") + def __init__(self, seed_num: int, bip85_data: dict = None): super().__init__() self.seed_num = seed_num @@ -1205,20 +1236,18 @@ def __init__(self, seed_num: int, bip85_data: dict = None): def run(self): - VERIFY = "Verify" - SKIP = "Skip" - button_data = [VERIFY, SKIP] + button_data = [self.VERIFY, self.SKIP] selected_menu_num = seed_screens.SeedWordsBackupTestPromptScreen( button_data=button_data, ).display() - if button_data[selected_menu_num] == VERIFY: + if button_data[selected_menu_num] == self.VERIFY: return Destination( SeedWordsBackupTestView, view_args=dict(seed_num=self.seed_num, bip85_data=self.bip85_data), ) - elif button_data[selected_menu_num] == SKIP: + elif button_data[selected_menu_num] == self.SKIP: if self.seed_num is not None: return Destination(SeedOptionsView, view_args=dict(seed_num=self.seed_num)) else: @@ -1227,7 +1256,11 @@ def run(self): class SeedWordsBackupTestView(View): - def __init__(self, seed_num: int, bip85_data: dict = None, confirmed_list: List[bool] = None, cur_index: int = None): + def __init__(self, seed_num: int, bip85_data: dict = None, confirmed_list: list[bool] = None, cur_index: int = None, rand_seed: int = None): + """ + Note: `rand_seed` is ONLY USED BY THE SCREENSHOT GENERATOR!!! (to ensure + consistent screenshot results). + """ super().__init__() self.seed_num = seed_num if self.seed_num is None: @@ -1246,24 +1279,32 @@ def __init__(self, seed_num: int, bip85_data: dict = None, confirmed_list: List[ self.confirmed_list = [] self.cur_index = cur_index + self.rand_seed = rand_seed def run(self): + from embit import bip39 + + if self.rand_seed is not None: + random.seed(self.rand_seed + self.cur_index if self.cur_index is not None else 0) + if self.cur_index is None: self.cur_index = int(random.random() * len(self.mnemonic_list)) while self.cur_index in self.confirmed_list: self.cur_index = int(random.random() * len(self.mnemonic_list)) - real_word = self.mnemonic_list[self.cur_index] - fake_word1 = bip39.WORDLIST[int(random.random() * 2047)] - fake_word2 = bip39.WORDLIST[int(random.random() * 2047)] - fake_word3 = bip39.WORDLIST[int(random.random() * 2047)] + real_word = ButtonOption(self.mnemonic_list[self.cur_index]) + fake_word1 = ButtonOption(bip39.WORDLIST[int(random.random() * 2047)]) + fake_word2 = ButtonOption(bip39.WORDLIST[int(random.random() * 2047)]) + fake_word3 = ButtonOption(bip39.WORDLIST[int(random.random() * 2047)]) button_data = [real_word, fake_word1, fake_word2, fake_word3] random.shuffle(button_data) + # TRANSLATOR_NOTE: Inserts the word number (e.g. "Verify Word #1") + title = _("Verify Word #{}").format(self.cur_index + 1) selected_menu_num = ButtonListScreen( - title=f"Verify Word #{self.cur_index + 1}", + title=title, show_back_button=False, button_data=button_data, is_bottom_list=True, @@ -1293,7 +1334,7 @@ def run(self): seed_num=self.seed_num, bip85_data=self.bip85_data, cur_index=self.cur_index, - wrong_word=button_data[selected_menu_num], + wrong_word=button_data[selected_menu_num].button_label, confirmed_list=self.confirmed_list, ) ) @@ -1301,7 +1342,10 @@ def run(self): class SeedWordsBackupTestMistakeView(View): - def __init__(self, seed_num: int, bip85_data: dict = None, cur_index: int = None, wrong_word: str = None, confirmed_list: List[bool] = None): + REVIEW = ButtonOption("Review Seed Words") + RETRY = ButtonOption("Try Again") + + def __init__(self, seed_num: int, bip85_data: dict = None, cur_index: int = None, wrong_word: str = None, confirmed_list: list[bool] = None): super().__init__() self.seed_num = seed_num self.bip85_data = bip85_data @@ -1311,25 +1355,29 @@ def __init__(self, seed_num: int, bip85_data: dict = None, cur_index: int = None def run(self): - REVIEW = "Review Seed Words" - RETRY = "Try Again" - button_data = [REVIEW, RETRY] + button_data = [self.REVIEW, self.RETRY] + + # TRANSLATOR_NOTE: Inserts the word number and the word (e.g. "Word #1 is not "apple"!") + text = _("Word #{} is not \"{}\"!").format(self.cur_index + 1, self.wrong_word) + + # TRANSLATOR_NOTE: User selected the wrong word during the mnemonic backup test (e.g. incorrectly said the 5th word was "zoo") + status_headline = _("Wrong Word!") selected_menu_num = DireWarningScreen( - title="Verification Error", + title=_("Verification Error"), show_back_button=False, - status_headline=f"Wrong Word!", - text=f"Word #{self.cur_index + 1} is not \"{self.wrong_word}\"!", + status_headline=status_headline, button_data=button_data, + text=text, ).display() - if button_data[selected_menu_num] == REVIEW: + if button_data[selected_menu_num] == self.REVIEW: return Destination( SeedWordsView, view_args=dict(seed_num=self.seed_num, bip85_data=self.bip85_data), ) - elif button_data[selected_menu_num] == RETRY: + elif button_data[selected_menu_num] == self.RETRY: return Destination( SeedWordsBackupTestView, view_args=dict( @@ -1348,12 +1396,13 @@ def __init__(self, seed_num: int): self.seed_num = seed_num def run(self): + from seedsigner.gui.screens.screen import LargeIconStatusScreen LargeIconStatusScreen( - title="Backup Verified", + title=_("Backup Verified"), show_back_button=False, - status_headline="Success!", - text="All mnemonic backup words were successfully verified!", - button_data=["OK"] + status_headline=_("Success!"), + text=_("All mnemonic backup words were successfully verified!"), + button_data=[ButtonOption("OK")] ).display() if self.seed_num is not None: @@ -1375,15 +1424,11 @@ def __init__(self, seed_num: int): def run(self): seed = self.controller.get_seed(self.seed_num) if len(seed.mnemonic_list) == 12: - STANDARD = "Standard: 25x25" - COMPACT = "Compact: 21x21" - num_modules_standard = 25 - num_modules_compact = 21 + STANDARD = ButtonOption("Standard: 25x25", return_data=25) + COMPACT = ButtonOption("Compact: 21x21", return_data=21) else: - STANDARD = "Standard: 29x29" - COMPACT = "Compact: 25x25" - num_modules_standard = 29 - num_modules_compact = 25 + STANDARD = ButtonOption("Standard: 29x29", return_data=29) + COMPACT = ButtonOption("Compact: 25x25", return_data=25) if self.settings.get_value(SettingsConstants.SETTING__COMPACT_SEEDQR) != SettingsConstants.OPTION__ENABLED: # Only configured for standard SeedQR @@ -1392,7 +1437,7 @@ def run(self): view_args={ "seed_num": self.seed_num, "seedqr_format": QRType.SEED__SEEDQR, - "num_modules": num_modules_standard, + "num_modules": STANDARD.return_data, }, skip_current_view=True, ) @@ -1400,7 +1445,7 @@ def run(self): button_data = [STANDARD, COMPACT] selected_menu_num = seed_screens.SeedTranscribeSeedQRFormatScreen( - title="SeedQR Format", + title=_("SeedQR Format"), button_data=button_data, ).display() @@ -1409,10 +1454,10 @@ def run(self): if button_data[selected_menu_num] == STANDARD: seedqr_format = QRType.SEED__SEEDQR - num_modules = num_modules_standard else: seedqr_format = QRType.SEED__COMPACTSEEDQR - num_modules = num_modules_compact + + num_modules = button_data[selected_menu_num].return_data return Destination( SeedTranscribeSeedQRWarningView, @@ -1449,8 +1494,8 @@ def run(self): return destination selected_menu_num = DireWarningScreen( - status_headline="SeedQR is your private key!", - text="""Never photograph or scan it into a device that connects to the internet.""", + status_headline=_("SeedQR is your private key!"), + text=_("Never photograph or scan it into a device that connects to the internet."), ).display() if selected_menu_num == RET_CODE__BACK_BUTTON: @@ -1501,12 +1546,18 @@ def run(self): class SeedTranscribeSeedQRZoomedInView(View): - def __init__(self, seed_num: int, seedqr_format: str): + """ + intial_block_x, initial_block_y: Used by the screenshot generator to shift the view + to a more interesting part of the QR code template. + """ + def __init__(self, seed_num: int, seedqr_format: str, initial_block_x: int = 0, initial_block_y: int = 0): super().__init__() self.seed_num = seed_num self.seedqr_format = seedqr_format self.seed = self.controller.get_seed(seed_num) - + self.initial_block_x = initial_block_x + self.initial_block_y = initial_block_y + def run(self): encoder_args = dict(mnemonic=self.seed.mnemonic_list, @@ -1532,6 +1583,8 @@ def run(self): seed_screens.SeedTranscribeSeedQRZoomedInScreen( qr_data=data, num_modules=num_modules, + initial_block_x=self.initial_block_x, + initial_block_y=self.initial_block_y, ).display() return Destination(SeedTranscribeSeedQRConfirmQRPromptView, view_args={"seed_num": self.seed_num}) @@ -1539,6 +1592,9 @@ def run(self): class SeedTranscribeSeedQRConfirmQRPromptView(View): + SCAN = ButtonOption("Confirm SeedQR", SeedSignerIconConstants.QRCODE) + DONE = ButtonOption("Done") + def __init__(self, seed_num: int): super().__init__() self.seed_num = seed_num @@ -1546,22 +1602,20 @@ def __init__(self, seed_num: int): def run(self): - SCAN = ("Confirm SeedQR", SeedSignerIconConstants.QRCODE) - DONE = "Done" - button_data = [SCAN, DONE] + button_data = [self.SCAN, self.DONE] selected_menu_option = seed_screens.SeedTranscribeSeedQRConfirmQRPromptScreen( - title="Confirm SeedQR?", + title=_("Confirm SeedQR?"), button_data=button_data, ).display() if selected_menu_option == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif button_data[selected_menu_option] == SCAN: + elif button_data[selected_menu_option] == self.SCAN: return Destination(SeedTranscribeSeedQRConfirmScanView, view_args={"seed_num": self.seed_num}) - elif button_data[selected_menu_option] == DONE: + elif button_data[selected_menu_option] == self.DONE: return Destination(SeedOptionsView, view_args={"seed_num": self.seed_num}, clear_history=True) @@ -1574,12 +1628,16 @@ def __init__(self, seed_num: int): def run(self): from seedsigner.gui.screens.scan_screens import ScanScreen + from seedsigner.models.decode_qr import DecodeQR # Run the live preview and QR code capture process # TODO: Does this belong in its own BaseThread? wordlist_language_code = self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE) self.decoder = DecodeQR(wordlist_language_code=wordlist_language_code) - ScanScreen(decoder=self.decoder, instructions_text="Scan your SeedQR").display() + ScanScreen( + decoder=self.decoder, + instructions_text=_("Scan your SeedQR") + ).display() if self.decoder.is_complete: if self.decoder.is_seed: @@ -1587,22 +1645,23 @@ def run(self): # Found a valid mnemonic seed! But does it match? if seed_mnemonic != self.seed.mnemonic_list: DireWarningScreen( - title="Confirm SeedQR", - status_headline="Error!", - text="Your transcribed SeedQR does not match your original seed!", + title=_("Confirm SeedQR"), + status_headline=_("Error!"), + text=_("Your transcribed SeedQR does not match your original seed!"), show_back_button=False, - button_data=["Review SeedQR"], + button_data=[_("Review SeedQR")], ).display() return Destination(BackStackView, skip_current_view=True) else: + from seedsigner.gui.screens.screen import LargeIconStatusScreen LargeIconStatusScreen( - title="Confirm SeedQR", - status_headline="Success!", - text="Your transcribed SeedQR successfully scanned and yielded the same seed.", + title=_("Confirm SeedQR"), + status_headline=_("Success!"), + text=_("Your transcribed SeedQR successfully scanned and yielded the same seed."), show_back_button=False, - button_data=["OK"], + button_data=[_("OK")], ).display() return Destination(SeedOptionsView, view_args={"seed_num": self.seed_num}) @@ -1610,11 +1669,11 @@ def run(self): else: # Will this case ever happen? Will trigger if a different kind of QR code is scanned DireWarningScreen( - title="Confirm SeedQR", - status_headline="Error!", - text="Your transcribed SeedQR could not be read!", + title=_("Confirm SeedQR"), + status_headline=_("Error!"), + text=_("Your transcribed SeedQR could not be read!"), show_back_button=False, - button_data=["Review SeedQR"], + button_data=[_("Review SeedQR")], ).display() return Destination(BackStackView, skip_current_view=True) @@ -1635,10 +1694,13 @@ def __init__(self, address: str, script_type: str, network: str): def run(self): + from seedsigner.helpers import embit_utils + from seedsigner.controller import Controller + if self.controller.unverified_address["script_type"] == SettingsConstants.LEGACY_P2PKH: # Legacy P2PKH addresses are always singlesig sig_type = SettingsConstants.SINGLE_SIG - destination = Destination(SeedSelectSeedView, skip_current_view=True) + destination = Destination(SeedSelectSeedView, view_args=dict(flow=Controller.FLOW__VERIFY_SINGLESIG_ADDR), skip_current_view=True) if self.controller.unverified_address["script_type"] == SettingsConstants.NESTED_SEGWIT: # No way to differentiate single sig from multisig @@ -1657,7 +1719,7 @@ def run(self): else: sig_type = SettingsConstants.SINGLE_SIG - destination = Destination(SeedSelectSeedView, skip_current_view=True) + destination = Destination(SeedSelectSeedView, view_args=dict(flow=Controller.FLOW__VERIFY_SINGLESIG_ADDR), skip_current_view=True) elif self.controller.unverified_address["script_type"] == SettingsConstants.TAPROOT: # TODO: add Taproot support @@ -1677,15 +1739,17 @@ def run(self): class AddressVerificationSigTypeView(View): - SINGLE_SIG = "Single Sig" - MULTISIG = "Multisig" + SINGLE_SIG = ButtonOption("Single Sig") + MULTISIG = ButtonOption("Multisig") def run(self): + from seedsigner.helpers import embit_utils + from seedsigner.controller import Controller button_data = [self.SINGLE_SIG, self.MULTISIG] selected_menu_num = self.run_screen( seed_screens.AddressVerificationSigTypeScreen, - title="Verify Address", - text="Sig type can't be auto-detected from this address. Please specify:", + title=_("Verify Address"), + text=_("Sig type can't be auto-detected from this address. Please specify:"), button_data=button_data, is_bottom_list=True, ) @@ -1729,6 +1793,10 @@ class SeedAddressVerificationView(View): Performs single sig verification on `seed_num` if specified, otherwise assumes multisig. """ + # TRANSLATOR_NOTE: Option when scanning for a matching address; skips ten addresses ahead + SKIP_10 = ButtonOption("Skip 10") + CANCEL = ButtonOption("Cancel") + def __init__(self, seed_num: int = None): super().__init__() self.seed_num = seed_num @@ -1736,7 +1804,7 @@ def __init__(self, seed_num: int = None): self.seed_derivation_override = "" if not self.is_multisig: if seed_num is None: - raise Exception("Can't validate a single sig addr without specifying a seed") + raise Exception(_("Can't validate a single sig addr without specifying a seed")) self.seed_num = seed_num self.seed = self.controller.get_seed(seed_num) self.seed_derivation_override = self.seed.derivation_override(sig_type=SettingsConstants.SINGLE_SIG) @@ -1782,9 +1850,7 @@ def run(self): # Start brute-force calculations from the zero-th index self.addr_verification_thread.start() - SKIP_10 = "Skip 10" - CANCEL = "Cancel" - button_data = [SKIP_10, CANCEL] + button_data = [self.SKIP_10, self.CANCEL] script_type_settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__SCRIPT_TYPES) script_type_display = script_type_settings_entry.get_selection_option_display_name_by_value(self.script_type) @@ -1826,17 +1892,17 @@ def run(self): time.sleep(0.1) continue - if button_data[selected_menu_num] == SKIP_10: + if button_data[selected_menu_num] == self.SKIP_10: self.threadsafe_counter.increment(10) - elif button_data[selected_menu_num] == CANCEL: + elif button_data[selected_menu_num] == self.CANCEL: break if self.verified_index.cur_count is not None: # Successfully verified the addr; update the data self.controller.unverified_address["verified_index"] = self.verified_index.cur_count self.controller.unverified_address["verified_index_is_change"] = self.verified_index_is_change.cur_count == 1 - return Destination(AddressVerificationSuccessView, view_args=dict(seed_num=self.seed_num)) + return Destination(SeedAddressVerificationSuccessView, view_args=dict(seed_num=self.seed_num)) else: # Halt the thread if the user gave up (will already be stopped if it verified the @@ -1870,6 +1936,7 @@ def __init__(self, address: str, seed: Seed, descriptor: Descriptor, script_type def run(self): + from seedsigner.helpers import embit_utils while self.keep_running: if self.threadsafe_counter.cur_count % 10 == 0: logger.info(f"Incremented to {self.threadsafe_counter.cur_count}") @@ -1901,7 +1968,7 @@ def run(self): -class AddressVerificationSuccessView(View): +class SeedAddressVerificationSuccessView(View): def __init__(self, seed_num: int): super().__init__() self.seed_num = seed_num @@ -1910,19 +1977,34 @@ def __init__(self, seed_num: int): def run(self): + from seedsigner.gui.screens.screen import LargeIconStatusScreen address = self.controller.unverified_address["address"] sig_type = self.controller.unverified_address["sig_type"] verified_index = self.controller.unverified_address["verified_index"] verified_index_is_change = self.controller.unverified_address["verified_index_is_change"] if sig_type == SettingsConstants.MULTISIG: - source = "multisig" + source = _("multisig") else: - source = f"seed {self.seed.get_fingerprint()}" + # TRANSLATOR_NOTE: Inserts the seed fingerprint + source = _("seed {}").format(self.seed.get_fingerprint()) + + # TRANSLATOR_NOTE: Used in a sentence describing the address type (change or receive) + change_text = _("change") + # TRANSLATOR_NOTE: Used in a sentence describing the address type (change or receive) + receive_text = _("receive") + + # TRANSLATOR_NOTE: Address verification success message (e.g. "bc1qabc = seed 12345678's receive address #0.") + text = _("{} = {}'s {} address #{}.").format( + address[:7], + source, + change_text if verified_index_is_change else receive_text, + verified_index + ) LargeIconStatusScreen( - status_headline="Address Verified", - text=f"""{address[:7]} = {source}'s {"change" if verified_index_is_change else "receive"} address #{verified_index}.""", + status_headline=_("Address Verified"), + text=text, show_back_button=False, ).display() @@ -1931,14 +2013,11 @@ def run(self): class LoadMultisigWalletDescriptorView(View): - SCAN = ("Scan Descriptor", SeedSignerIconConstants.QRCODE) - CANCEL = "Cancel" + SCAN = ButtonOption("Scan Descriptor", SeedSignerIconConstants.QRCODE) + CANCEL = ButtonOption("Cancel") def run(self): - button_data = [ - self.SCAN, - self.CANCEL - ] + button_data = [self.SCAN, self.CANCEL] selected_menu_num = self.run_screen( seed_screens.LoadMultisigWalletDescriptorScreen, button_data=button_data, @@ -1950,6 +2029,7 @@ def run(self): return Destination(ScanWalletDescriptorView) elif button_data[selected_menu_num] == self.CANCEL: + from seedsigner.controller import Controller if self.controller.resume_main_flow == Controller.FLOW__PSBT: return Destination(BackStackView) else: @@ -1958,10 +2038,10 @@ def run(self): class MultisigWalletDescriptorView(View): - RETURN = "Return to PSBT" - VERIFY_ADDR = "Verify Addr" - ADDRESS_EXPLORER = "Address Explorer" - OK = "OK" + RETURN = ButtonOption("Return to PSBT") + VERIFY_ADDR = ButtonOption("Verify Addr") + ADDRESS_EXPLORER = ButtonOption("Address Explorer") + OK = ButtonOption("OK") def run(self): descriptor = self.controller.multisig_wallet_descriptor @@ -1972,14 +2052,16 @@ def run(self): fingerprints.append(fingerprint) policy = descriptor.brief_policy.split("multisig")[0].strip() + # policy = " / ".join(policy.split(" of ")) # i18n w/o l10n since coming from non-l10n embit button_data = [self.OK] if self.controller.resume_main_flow: + from seedsigner.controller import Controller if self.controller.resume_main_flow == Controller.FLOW__PSBT: button_data = [self.RETURN] elif self.controller.resume_main_flow == Controller.FLOW__VERIFY_MULTISIG_ADDR and self.controller.unverified_address: - verify_addr_display = f"""{self.VERIFY_ADDR} {self.controller.unverified_address["address"][:7]}""" - button_data = [verify_addr_display] + verify_addr_display = f"""{_(self.VERIFY_ADDR.button_label)} {self.controller.unverified_address["address"][:7]}""" + button_data = [ButtonOption(verify_addr_display)] elif self.controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER: button_data = [self.ADDRESS_EXPLORER] @@ -2000,7 +2082,7 @@ def run(self): self.controller.resume_main_flow = None return Destination(PSBTChangeDetailsView, view_args=dict(change_address_num=0)) - elif button_data[selected_menu_num].startswith(self.VERIFY_ADDR): + elif button_data[selected_menu_num].button_label.startswith(_(self.VERIFY_ADDR.button_label)): self.controller.resume_main_flow = None return Destination(SeedAddressVerificationView) @@ -2023,6 +2105,7 @@ class SeedSignMessageStartView(View): load a seed first. """ def __init__(self, derivation_path: str, message: str): + from seedsigner.helpers import embit_utils super().__init__() self.derivation_path = derivation_path self.message = message @@ -2041,7 +2124,7 @@ def __init__(self, derivation_path: str, message: str): # Note: addr_format["network"] can be MAINNET or [TESTNET, REGTEST] if self.settings.get_value(SettingsConstants.SETTING__NETWORK) not in addr_format["network"]: from seedsigner.views.view import NetworkMismatchErrorView - self.set_redirect(Destination(NetworkMismatchErrorView, view_args=dict(text=f"Current network setting ({self.settings.get_value_display_name(SettingsConstants.SETTING__NETWORK)}) doesn't match {self.derivation_path}"))) + self.set_redirect(Destination(NetworkMismatchErrorView, view_args=dict(derivation_path=self.derivation_path))) # cleanup. Note: We could leave this in place so the user can resume the # flow, but for now we avoid complications and keep things simple. @@ -2063,6 +2146,7 @@ def __init__(self, derivation_path: str, message: str): # We already know which seed we're signing with self.set_redirect(Destination(SeedSignMessageConfirmMessageView, skip_current_view=True)) else: + from seedsigner.controller import Controller self.set_redirect(Destination(SeedSelectSeedView, view_args=dict(flow=Controller.FLOW__SIGN_MESSAGE), skip_current_view=True)) @@ -2072,10 +2156,6 @@ def __init__(self, page_num: int = 0): super().__init__() self.page_num = page_num # Note: zero-indexed numbering! - self.seed_num = self.controller.sign_message_data.get("seed_num") - if self.seed_num is None: - raise Exception("Routing error: seed_num hasn't been set") - def run(self): from seedsigner.gui.screens.seed_screens import SeedSignMessageConfirmMessageScreen @@ -2103,6 +2183,7 @@ def run(self): class SeedSignMessageConfirmAddressView(View): def __init__(self): + from seedsigner.helpers import embit_utils super().__init__() data = self.controller.sign_message_data seed_num = data.get("seed_num") @@ -2118,7 +2199,7 @@ def __init__(self): seed = self.controller.storage.seeds[seed_num] addr_format = embit_utils.parse_derivation_path(self.derivation_path) if not addr_format["clean_match"] or addr_format["script_type"] == SettingsConstants.CUSTOM_DERIVATION: - raise Exception("Signing messages for custom derivation paths not supported") + raise Exception(_("Signing messages for custom derivation paths not supported")) if addr_format["network"] != SettingsConstants.MAINNET: # We're in either Testnet or Regtest or...? @@ -2126,7 +2207,7 @@ def __init__(self): addr_format["network"] = self.settings.get_value(SettingsConstants.SETTING__NETWORK) else: from seedsigner.views.view import NetworkMismatchErrorView - self.set_redirect(Destination(NetworkMismatchErrorView, view_args=dict(text=f"Current network setting ({self.settings.get_value_display_name(SettingsConstants.SETTING__NETWORK)}) doesn't match {self.derivation_path}"))) + self.set_redirect(Destination(NetworkMismatchErrorView, view_args=dict(derivation_path=self.derivation_path))) # cleanup. Note: We could leave this in place so the user can resume the # flow, but for now we avoid complications and keep things simple. @@ -2160,6 +2241,7 @@ class SeedSignMessageSignedMessageQRView(View): Displays the signed message as a QR code. """ def __init__(self): + from seedsigner.helpers import embit_utils super().__init__() data = self.controller.sign_message_data @@ -2172,6 +2254,7 @@ def __init__(self): def run(self): + from seedsigner.gui.screens.screen import QRDisplayScreen qr_encoder = GenericStaticQrEncoder(data=self.signed_message) self.run_screen( diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index e2a742ca7..b4fb86a63 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -1,19 +1,20 @@ import logging -from seedsigner.gui.components import SeedSignerIconConstants -from seedsigner.hardware.microsd import MicroSD - -from .view import View, Destination, MainMenuView +from gettext import gettext as _ +from seedsigner.gui.components import SeedSignerIconConstants from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen, settings_screens) +from seedsigner.gui.screens.screen import ButtonOption from seedsigner.models.settings import Settings, SettingsConstants, SettingsDefinition +from .view import View, Destination, MainMenuView + logger = logging.getLogger(__name__) class SettingsMenuView(View): - IO_TEST = "I/O test" - DONATE = "Donate" + IO_TEST = ButtonOption("I/O test") + DONATE = ButtonOption("Donate") def __init__(self, visibility: str = SettingsConstants.VISIBILITY__GENERAL, selected_attr: str = None, initial_scroll: int = 0): super().__init__() @@ -28,7 +29,7 @@ def run(self): settings_entries = SettingsDefinition.get_settings_entries( visibility=self.visibility ) - button_data=[e.display_name for e in settings_entries] + button_data=[ButtonOption(e.display_name) for e in settings_entries] selected_button = 0 if self.selected_attr: @@ -38,17 +39,17 @@ def run(self): break if self.visibility == SettingsConstants.VISIBILITY__GENERAL: - title = "Settings" + title = _("Settings") # Set up the next nested level of menuing - button_data.append(("Advanced", None, None, None, SeedSignerIconConstants.CHEVRON_RIGHT)) + button_data.append(ButtonOption("Advanced", right_icon_name=SeedSignerIconConstants.CHEVRON_RIGHT)) next_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED}) button_data.append(self.IO_TEST) button_data.append(self.DONATE) elif self.visibility == SettingsConstants.VISIBILITY__ADVANCED: - title = "Advanced" + title = _("Advanced") # So far there are no real Developer options; disabling for now # button_data.append(("Developer Options", None, None, None, SeedSignerIconConstants.CHEVRON_RIGHT)) @@ -56,7 +57,7 @@ def run(self): next_destination = None elif self.visibility == SettingsConstants.VISIBILITY__DEVELOPER: - title = "Dev Options" + title = _("Dev Options") next_destination = None selected_menu_num = self.run_screen( @@ -114,8 +115,7 @@ def run(self): value, display_name = value else: display_name = value - - button_data.append(display_name) + button_data.append(ButtonOption(display_name)) if (type(initial_value) == list and value in initial_value) or value == initial_value: checked_buttons.append(i) @@ -191,6 +191,7 @@ def run(self): class SettingsIngestSettingsQRView(View): def __init__(self, data: str): + from seedsigner.hardware.microsd import MicroSD super().__init__() # May raise an Exception which will bubble up to the Controller to display to the @@ -200,15 +201,16 @@ def __init__(self, data: str): self.settings.update(settings_update_dict) if MicroSD.get_instance().is_inserted and self.settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__ENABLED: - self.status_message = "Persistent Settings enabled. Settings saved to SD card." + self.status_message = _("Persistent Settings enabled. Settings saved to SD card.") else: - self.status_message = "Settings updated in temporary memory" + self.status_message = _("Settings updated in temporary memory") def run(self): from seedsigner.gui.screens.settings_screens import SettingsQRConfirmationScreen self.run_screen( SettingsQRConfirmationScreen, + title=_("Settings QR"), config_name=self.config_name, status_message=self.status_message, ) diff --git a/src/seedsigner/views/tools_views.py b/src/seedsigner/views/tools_views.py index eabb1171c..a6828fe7a 100644 --- a/src/seedsigner/views/tools_views.py +++ b/src/seedsigner/views/tools_views.py @@ -1,21 +1,14 @@ -from dataclasses import dataclass import hashlib import logging import os import time -from embit.descriptor import Descriptor -from PIL import Image -from PIL.ImageOps import autocontrast +from gettext import gettext as _ -from seedsigner.controller import Controller from seedsigner.gui.components import FontAwesomeIconConstants, GUIConstants, SeedSignerIconConstants -from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen, WarningScreen) -from seedsigner.gui.screens.tools_screens import (ToolsCalcFinalWordDoneScreen, ToolsCalcFinalWordFinalizePromptScreen, - ToolsCalcFinalWordScreen, ToolsCoinFlipEntryScreen, ToolsDiceEntropyEntryScreen, ToolsImageEntropyFinalImageScreen, - ToolsImageEntropyLivePreviewScreen, ToolsAddressExplorerAddressTypeScreen) -from seedsigner.helpers import embit_utils, mnemonic_generation -from seedsigner.models.encode_qr import GenericStaticQrEncoder +from seedsigner.gui.screens import RET_CODE__BACK_BUTTON, ButtonListScreen +from seedsigner.gui.screens.screen import ButtonOption +from seedsigner.helpers import mnemonic_generation from seedsigner.models.seed import Seed from seedsigner.models.settings_definition import SettingsConstants from seedsigner.views.seed_views import SeedDiscardView, SeedFinalizeView, SeedMnemonicEntryView, SeedOptionsView, SeedWordsWarningView, SeedExportXpubScriptTypeView @@ -25,19 +18,20 @@ logger = logging.getLogger(__name__) + class ToolsMenuView(View): - IMAGE = (" New seed", FontAwesomeIconConstants.CAMERA) - DICE = ("New seed", FontAwesomeIconConstants.DICE) - KEYBOARD = ("Calc 12th/24th word", FontAwesomeIconConstants.KEYBOARD) - ADDRESS_EXPLORER = "Address Explorer" - VERIFY_ADDRESS = "Verify address" + IMAGE = ButtonOption("New seed", FontAwesomeIconConstants.CAMERA) + DICE = ButtonOption("New seed", FontAwesomeIconConstants.DICE) + KEYBOARD = ButtonOption("Calc 12th/24th word", FontAwesomeIconConstants.KEYBOARD) + ADDRESS_EXPLORER = ButtonOption("Address Explorer") + VERIFY_ADDRESS = ButtonOption("Verify Address") def run(self): button_data = [self.IMAGE, self.DICE, self.KEYBOARD, self.ADDRESS_EXPLORER, self.VERIFY_ADDRESS] selected_menu_num = self.run_screen( ButtonListScreen, - title="Tools", + title=_("Tools"), is_button_text_centered=False, button_data=button_data ) @@ -68,6 +62,7 @@ def run(self): ****************************************************************************""" class ToolsImageEntropyLivePreviewView(View): def run(self): + from seedsigner.gui.screens.tools_screens import ToolsImageEntropyLivePreviewScreen self.controller.image_entropy_preview_frames = None ret = ToolsImageEntropyLivePreviewScreen().display() @@ -81,6 +76,9 @@ def run(self): class ToolsImageEntropyFinalImageView(View): def run(self): + from PIL import Image + from PIL.ImageOps import autocontrast + from seedsigner.gui.screens.tools_screens import ToolsImageEntropyFinalImageScreen if not self.controller.image_entropy_final_image: from seedsigner.hardware.camera import Camera # Take the final full-res image @@ -99,7 +97,7 @@ def run(self): ).crop( (120, 0, 600, 480) ).resize( - (self.canvas_width, self.canvas_height), Image.BICUBIC + (self.canvas_width, self.canvas_height), Image.Resampling.BICUBIC ) ret = ToolsImageEntropyFinalImageScreen( @@ -116,23 +114,21 @@ def run(self): class ToolsImageEntropyMnemonicLengthView(View): + TWELVE_WORDS = ButtonOption("12 words", return_data=12) + TWENTYFOUR_WORDS = ButtonOption("24 words", return_data=24) + def run(self): - TWELVE_WORDS = "12 words" - TWENTYFOUR_WORDS = "24 words" - button_data = [TWELVE_WORDS, TWENTYFOUR_WORDS] + button_data = [self.TWELVE_WORDS, self.TWENTYFOUR_WORDS] selected_menu_num = ButtonListScreen( - title="Mnemonic Length?", + title=_("Mnemonic Length?"), button_data=button_data, ).display() if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - if button_data[selected_menu_num] == TWELVE_WORDS: - mnemonic_length = 12 - else: - mnemonic_length = 24 + mnemonic_length = button_data[selected_menu_num].return_data preview_images = self.controller.image_entropy_preview_frames seed_entropy_image = self.controller.image_entropy_final_image @@ -189,12 +185,20 @@ def run(self): ****************************************************************************""" class ToolsDiceEntropyMnemonicLengthView(View): def run(self): - TWELVE = f"12 words ({mnemonic_generation.DICE__NUM_ROLLS__12WORD} rolls)" - TWENTY_FOUR = f"24 words ({mnemonic_generation.DICE__NUM_ROLLS__24WORD} rolls)" - + # Since we're dynamically building the ButtonOption button_labels here, it's too + # awkward to use the usual class-level attr approach. + + # TRANSLATOR_NOTE: Inserts the number of dice rolls needed for a 12-word mnemonic + twelve = _("12 words ({} rolls)").format(mnemonic_generation.DICE__NUM_ROLLS__12WORD) + TWELVE = ButtonOption(twelve, return_data=mnemonic_generation.DICE__NUM_ROLLS__12WORD) + + # TRANSLATOR_NOTE: Inserts the number of dice rolls needed for a 24-word mnemonic + twenty_four = _("24 words ({} rolls)").format(mnemonic_generation.DICE__NUM_ROLLS__24WORD) + TWENTY_FOUR = ButtonOption(twenty_four, return_data=mnemonic_generation.DICE__NUM_ROLLS__24WORD) + button_data = [TWELVE, TWENTY_FOUR] selected_menu_num = ButtonListScreen( - title="Mnemonic Length", + title=_("Mnemonic Length"), is_bottom_list=True, is_button_text_centered=True, button_data=button_data, @@ -218,6 +222,7 @@ def __init__(self, total_rolls: int): def run(self): + from seedsigner.gui.screens.tools_screens import ToolsDiceEntropyEntryScreen ret = ToolsDiceEntropyEntryScreen( return_after_n_chars=self.total_rolls, ).display() @@ -240,15 +245,15 @@ def run(self): Calc final word Views ****************************************************************************""" class ToolsCalcFinalWordNumWordsView(View): - TWELVE = "12 words" - TWENTY_FOUR = "24 words" + TWELVE = ButtonOption("12 words", return_data=12) + TWENTY_FOUR = ButtonOption("24 words", return_data=24) def run(self): button_data = [self.TWELVE, self.TWENTY_FOUR] selected_menu_num = self.run_screen( ButtonListScreen, - title="Mnemonic Length", + title=_("Mnemonic Length"), is_bottom_list=True, is_button_text_centered=True, button_data=button_data, @@ -257,22 +262,24 @@ def run(self): if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif button_data[selected_menu_num] == self.TWELVE: - self.controller.storage.init_pending_mnemonic(12) + self.controller.storage.init_pending_mnemonic(button_data[selected_menu_num].return_data) - # return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True)) - return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True)) + return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True)) - elif button_data[selected_menu_num] == self.TWENTY_FOUR: - self.controller.storage.init_pending_mnemonic(24) - # return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True)) - return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True)) +class ToolsCalcFinalWordFinalizePromptView(View): + # TRANSLATOR_NOTE: Label to gather entropy through coin tosses + COIN_FLIPS = ButtonOption("Coin flip entropy") + # TRANSLATOR_NOTE: Label to gather entropy through user specified BIP-39 word + SELECT_WORD = ButtonOption("Word selection entropy") + + # TRANSLATOR_NOTE: Label to allow user to default entropy as all-zeros + ZEROS = ButtonOption("Finalize with zeros") -class ToolsCalcFinalWordFinalizePromptView(View): def run(self): + from seedsigner.gui.screens.tools_screens import ToolsCalcFinalWordFinalizePromptScreen mnemonic = self.controller.storage.pending_mnemonic mnemonic_length = len(mnemonic) if mnemonic_length == 12: @@ -280,11 +287,7 @@ def run(self): else: num_entropy_bits = 3 - COIN_FLIPS = "Coin flip entropy" - SELECT_WORD = f"Word selection entropy" - ZEROS = "Finalize with zeros" - - button_data = [COIN_FLIPS, SELECT_WORD, ZEROS] + button_data = [self.COIN_FLIPS, self.SELECT_WORD, self.ZEROS] selected_menu_num = ToolsCalcFinalWordFinalizePromptScreen( mnemonic_length=mnemonic_length, num_entropy_bits=num_entropy_bits, @@ -294,15 +297,15 @@ def run(self): if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif button_data[selected_menu_num] == COIN_FLIPS: + elif button_data[selected_menu_num] == self.COIN_FLIPS: return Destination(ToolsCalcFinalWordCoinFlipsView) - elif button_data[selected_menu_num] == SELECT_WORD: + elif button_data[selected_menu_num] == self.SELECT_WORD: # Clear the final word slot, just in case we're returning via BACK button self.controller.storage.update_pending_mnemonic(None, mnemonic_length - 1) return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True, cur_word_index=mnemonic_length - 1)) - elif button_data[selected_menu_num] == ZEROS: + elif button_data[selected_menu_num] == self.ZEROS: # User skipped the option to select a final word to provide last bits of # entropy. We'll insert all zeros and piggy-back on the coin flip attr wordlist_language_code = self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE) @@ -313,6 +316,7 @@ def run(self): class ToolsCalcFinalWordCoinFlipsView(View): def run(self): + from seedsigner.gui.screens.tools_screens import ToolsCoinFlipEntryScreen mnemonic_length = len(self.controller.storage.pending_mnemonic) if mnemonic_length == 12: @@ -333,6 +337,8 @@ def run(self): class ToolsCalcFinalWordShowFinalWordView(View): + NEXT = ButtonOption("Next") + def __init__(self, coin_flips: str = None): super().__init__() # Construct the actual final word. The user's selected_final_word @@ -385,11 +391,15 @@ def __init__(self, coin_flips: str = None): def run(self): - NEXT = "Next" - button_data = [NEXT] + from seedsigner.gui.screens.tools_screens import ToolsCalcFinalWordScreen + button_data = [self.NEXT] + + # TRANSLATOR_NOTE: label to calculate the last word of a BIP-39 mnemonic seed phrase + title = _("Final Word Calc") + selected_menu_num = self.run_screen( ToolsCalcFinalWordScreen, - title="Final Word Calc", + title=title, button_data=button_data, selected_final_word=self.selected_final_word, selected_final_bits=self.selected_final_bits, @@ -400,20 +410,22 @@ def run(self): if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif button_data[selected_menu_num] == NEXT: + elif button_data[selected_menu_num] == self.NEXT: return Destination(ToolsCalcFinalWordDoneView) class ToolsCalcFinalWordDoneView(View): + LOAD = ButtonOption("Load seed") + DISCARD = ButtonOption("Discard", button_label_color="red") + def run(self): + from seedsigner.gui.screens.tools_screens import ToolsCalcFinalWordDoneScreen mnemonic = self.controller.storage.pending_mnemonic mnemonic_word_length = len(mnemonic) final_word = mnemonic[-1] - LOAD = "Load seed" - DISCARD = ("Discard", None, None, "red") - button_data = [LOAD, DISCARD] + button_data = [self.LOAD, self.DISCARD] selected_menu_num = ToolsCalcFinalWordDoneScreen( final_word=final_word, @@ -427,37 +439,39 @@ def run(self): self.controller.storage.convert_pending_mnemonic_to_pending_seed() - if button_data[selected_menu_num] == LOAD: + if button_data[selected_menu_num] == self.LOAD: return Destination(SeedFinalizeView) - elif button_data[selected_menu_num] == DISCARD: + elif button_data[selected_menu_num] == self.DISCARD: return Destination(SeedDiscardView) + """**************************************************************************** Address Explorer Views ****************************************************************************""" class ToolsAddressExplorerSelectSourceView(View): - SCAN_SEED = ("Scan a seed", SeedSignerIconConstants.QRCODE) - SCAN_DESCRIPTOR = ("Scan wallet descriptor", SeedSignerIconConstants.QRCODE) - TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) - TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) - TYPE_ELECTRUM = ("Enter Electrum seed", FontAwesomeIconConstants.KEYBOARD) - + SCAN_SEED = ButtonOption("Scan a seed", SeedSignerIconConstants.QRCODE) + SCAN_DESCRIPTOR = ButtonOption("Scan wallet descriptor", SeedSignerIconConstants.QRCODE) + TYPE_12WORD = ButtonOption("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD, return_data=12) + TYPE_24WORD = ButtonOption("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD, return_data=24) + TYPE_ELECTRUM = ButtonOption("Enter Electrum seed", FontAwesomeIconConstants.KEYBOARD) def run(self): + from seedsigner.controller import Controller + seeds = self.controller.storage.seeds button_data = [] for seed in seeds: button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)) - button_data.append((button_str, SeedSignerIconConstants.FINGERPRINT)) + button_data.append(ButtonOption(button_str, SeedSignerIconConstants.FINGERPRINT)) button_data = button_data + [self.SCAN_SEED, self.SCAN_DESCRIPTOR, self.TYPE_12WORD, self.TYPE_24WORD] if self.settings.get_value(SettingsConstants.SETTING__ELECTRUM_SEEDS) == SettingsConstants.OPTION__ENABLED: button_data.append(self.TYPE_ELECTRUM) - + selected_menu_num = self.run_screen( ButtonListScreen, - title="Address Explorer", + title=_("Address Explorer"), button_data=button_data, is_button_text_centered=False, is_bottom_list=True, @@ -491,10 +505,7 @@ def run(self): elif button_data[selected_menu_num] in [self.TYPE_12WORD, self.TYPE_24WORD]: from seedsigner.views.seed_views import SeedMnemonicEntryView - if button_data[selected_menu_num] == self.TYPE_12WORD: - self.controller.storage.init_pending_mnemonic(num_words=12) - else: - self.controller.storage.init_pending_mnemonic(num_words=24) + self.controller.storage.init_pending_mnemonic(num_words=button_data[selected_menu_num].return_data) return Destination(SeedMnemonicEntryView) elif button_data[selected_menu_num] == self.TYPE_ELECTRUM: @@ -504,8 +515,11 @@ def run(self): class ToolsAddressExplorerAddressTypeView(View): - RECEIVE = "Receive Addresses" - CHANGE = "Change Addresses" + # TRANSLATOR_NOTE: label for addresses where others send us incoming payments + RECEIVE = ButtonOption("Receive Addresses") + + # TRANSLATOR_NOTE: label for addresses that collect the change from our own outgoing payments + CHANGE = ButtonOption("Change Addresses") def __init__(self, seed_num: int = None, script_type: str = None, custom_derivation: str = None): @@ -541,6 +555,7 @@ def __init__(self, seed_num: int = None, script_type: str = None, custom_derivat elif seed_derivation_override: derivation_path = seed_derivation_override else: + from seedsigner.helpers import embit_utils derivation_path = embit_utils.get_standard_derivation_path( network=self.settings.get_value(SettingsConstants.SETTING__NETWORK), wallet_type=SettingsConstants.SINGLE_SIG, @@ -557,11 +572,13 @@ def __init__(self, seed_num: int = None, script_type: str = None, custom_derivat def run(self): + from seedsigner.gui.screens.tools_screens import ToolsAddressExplorerAddressTypeScreen data = self.controller.address_explorer_data wallet_descriptor_display_name = None if "wallet_descriptor" in data: wallet_descriptor_display_name = data["wallet_descriptor"].brief_policy.replace(" (sorted)", "") + wallet_descriptor_display_name = " / ".join(wallet_descriptor_display_name.split(" of ")) # i18n w/o l10n since coming from non-l10n embit script_type = data["script_type"] if "script_type" in data else None @@ -620,7 +637,9 @@ def run(self): else: try: from seedsigner.gui.screens.screen import LoadingScreenThread - self.loading_screen = LoadingScreenThread(text="Calculating addrs...") + from seedsigner.helpers import embit_utils + # TRANSLATOR_NOTE: a status message that our payment addresses are being calculated + self.loading_screen = LoadingScreenThread(text=_("Calculating addrs...")) self.loading_screen.start() if addr_storage_key not in data: @@ -636,9 +655,10 @@ def run(self): data[addr_storage_key].append(address) else: # TODO: Custom derivation path - raise Exception("Custom Derivation address explorer not yet implemented") - + raise Exception(_("Custom Derivation address explorer not yet implemented")) + elif "wallet_descriptor" in data: + from embit.descriptor import Descriptor descriptor: Descriptor = data["wallet_descriptor"] if descriptor.is_basic_multisig: for i in range(self.start_index, self.start_index + addrs_per_screen): @@ -647,7 +667,7 @@ def run(self): data[addr_storage_key].append(address) else: - raise Exception("Single sig descriptors not yet supported") + raise Exception(_("Single sig descriptors not yet supported")) finally: # Everything is set. Stop the loading screen self.loading_screen.stop() @@ -663,16 +683,18 @@ def run(self): end_digits = -5 else: end_digits = -4 - button_data.append(f"{cur_index}:{address[:8]}...{address[end_digits:]}") + button_data.append(ButtonOption(f"{cur_index}:{address[:8]}...{address[end_digits:]}", active_button_label=f"{cur_index}:{address}")) - button_data.append(("Next {}".format(addrs_per_screen), None, None, None, SeedSignerIconConstants.CHEVRON_RIGHT)) + # TRANSLATOR_NOTE: Insert the number of addrs displayed per screen (e.g. "Next 10") + button_label = _("Next {}").format(addrs_per_screen) + button_data.append(ButtonOption(button_label, right_icon_name=SeedSignerIconConstants.CHEVRON_RIGHT)) selected_menu_num = self.run_screen( ButtonListScreen, - title="{} Addrs".format("Receive" if not self.is_change else "Change"), + title=_("Receive Addrs") if not self.is_change else _("Change Addrs"), button_data=button_data, button_font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, - button_font_size=GUIConstants.BUTTON_FONT_SIZE + 4, + button_font_size=GUIConstants.get_button_font_size() + 4, is_button_text_centered=False, is_bottom_list=True, selected_button=self.selected_button_index, @@ -695,6 +717,7 @@ def run(self): class ToolsAddressExplorerAddressView(View): + # TODO: pull address str from controller.address_explorer_data and pass addr_storage_key and addr_index instead def __init__(self, index: int, address: str, is_change: bool, start_index: int, parent_initial_scroll: int = 0): super().__init__() self.index = index @@ -706,6 +729,8 @@ def __init__(self, index: int, address: str, is_change: bool, start_index: int, def run(self): from seedsigner.gui.screens.screen import QRDisplayScreen + from seedsigner.models.encode_qr import GenericStaticQrEncoder + qr_encoder = GenericStaticQrEncoder(data=self.address) self.run_screen( QRDisplayScreen, diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index a7c83d49d..e7673eef7 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -1,9 +1,11 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass +from gettext import gettext as _ from typing import Type -from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerIconConstants +from seedsigner.helpers.l10n import mark_for_translation as _mft +from seedsigner.gui.components import SeedSignerIconConstants from seedsigner.gui.screens import RET_CODE__POWER_BUTTON, RET_CODE__BACK_BUTTON -from seedsigner.gui.screens.screen import BaseScreen, DireWarningScreen, LargeButtonScreen, PowerOffScreen, PowerOffNotRequiredScreen, ResetScreen, WarningScreen +from seedsigner.gui.screens.screen import BaseScreen, ButtonOption, DireWarningScreen, LargeButtonScreen, PowerOffScreen, PowerOffNotRequiredScreen, ResetScreen, WarningScreen from seedsigner.models.settings import Settings, SettingsConstants from seedsigner.models.settings_definition import SettingsDefinition from seedsigner.models.threads import BaseThread @@ -17,6 +19,7 @@ class BackStackView: pass + """ Views contain the biz logic to handle discrete tasks, exactly analogous to a Flask request/response function or a Django View. Each page/screen displayed to the user @@ -181,18 +184,17 @@ def __ne__(self, obj): # ######################################################################################### class MainMenuView(View): - SCAN = ("Scan", SeedSignerIconConstants.SCAN) - SEEDS = ("Seeds", SeedSignerIconConstants.SEEDS) - TOOLS = ("Tools", SeedSignerIconConstants.TOOLS) - SETTINGS = ("Settings", SeedSignerIconConstants.SETTINGS) - + SCAN = ButtonOption("Scan", SeedSignerIconConstants.SCAN) + SEEDS = ButtonOption("Seeds", SeedSignerIconConstants.SEEDS) + TOOLS = ButtonOption("Tools", SeedSignerIconConstants.TOOLS) + SETTINGS = ButtonOption("Settings", SeedSignerIconConstants.SETTINGS) def run(self): from seedsigner.gui.screens.screen import MainMenuScreen button_data = [self.SCAN, self.SEEDS, self.TOOLS, self.SETTINGS] selected_menu_num = self.run_screen( MainMenuScreen, - title="Home", + title=_("Home"), button_data=button_data, ) @@ -218,14 +220,14 @@ def run(self): class PowerOptionsView(View): - RESET = ("Restart", SeedSignerIconConstants.RESTART) - POWER_OFF = ("Power Off", SeedSignerIconConstants.POWER) + RESET = ButtonOption("Restart", SeedSignerIconConstants.RESTART) + POWER_OFF = ButtonOption("Power Off", SeedSignerIconConstants.POWER) def run(self): button_data = [self.RESET, self.POWER_OFF] selected_menu_num = self.run_screen( LargeButtonScreen, - title="Reset / Power", + title=_("Reset / Power"), show_back_button=True, button_data=button_data ) @@ -268,38 +270,26 @@ def run(self): class PowerOffView(View): def run(self): - if Settings.HOSTNAME == Settings.SEEDSIGNER_OS: - self.run_screen(PowerOffNotRequiredScreen) - return Destination(BackStackView) - else: - thread = PowerOffView.PowerOffThread() - thread.start() - self.run_screen(PowerOffScreen) - - - class PowerOffThread(BaseThread): - def run(self): - import time - from subprocess import call - while self.keep_running: - time.sleep(5) - call("sudo shutdown --poweroff now", shell=True) + self.run_screen(PowerOffNotRequiredScreen) + return Destination(BackStackView) @dataclass class NotYetImplementedView(View): - text: str = "This is still on our to-do list!" """ Temporary View to use during dev. """ + text: str = _mft("This is still on our to-do list!") + + def run(self): self.run_screen( WarningScreen, - title="Work In Progress", - status_headline="Not Yet Implemented", + title=_("Work In Progress"), + status_headline=_("Not Yet Implemented"), text=self.text, - button_data=["Back to Main Menu"], + button_data=[ButtonOption("Back to Main Menu")], ) return Destination(MainMenuView) @@ -308,13 +298,12 @@ def run(self): @dataclass class ErrorView(View): - title: str = "Error" + title: str = _mft("Error") show_back_button: bool = True status_headline: str = None text: str = None button_text: str = None - next_destination: Destination = field(default_factory=lambda: Destination(MainMenuView, clear_history=True)) - + next_destination: Destination = None def run(self): self.run_screen( @@ -322,30 +311,30 @@ def run(self): title=self.title, status_headline=self.status_headline, text=self.text, - button_data=[self.button_text], + button_data=[ButtonOption(self.button_text)], show_back_button=self.show_back_button, ) - - return self.next_destination + return self.next_destination if self.next_destination else Destination(MainMenuView, clear_history=True) @dataclass class NetworkMismatchErrorView(ErrorView): - title: str = "Network Mismatch" - show_back_button: bool = False - button_text: str = "Change Setting" - next_destination: Destination = None - + derivation_path: str = None def __post_init__(self): + from seedsigner.views.settings_views import SettingsEntryUpdateSelectionView + self.title: str = _("Network Mismatch") + self.show_back_button: bool = False + self.button_text: str = _("Change Setting") + self.next_destination = Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=SettingsConstants.SETTING__NETWORK), clear_history=True) super().__post_init__() - if not self.text: - self.text = f"Current network setting ({self.settings.get_value_display_name(SettingsConstants.SETTING__NETWORK)}) doesn't match current action." - if not self.next_destination: - from seedsigner.views.settings_views import SettingsEntryUpdateSelectionView - self.next_destination = Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=SettingsConstants.SETTING__NETWORK), clear_history=True) + # TRANSLATOR_NOTE: Inserts mainnet/testnet/regtest and derivation path + self.text = _("Current network setting ({}) doesn't match {}.").format( + self.settings.get_value_display_name(SettingsConstants.SETTING__NETWORK), + self.derivation_path, + ) @@ -357,11 +346,9 @@ class UnhandledExceptionView(View): def run(self): self.run_screen( DireWarningScreen, - title="System Error", + title=_("System Error"), status_headline=self.error[0], text=self.error[1] + "\n" + self.error[2], - button_data=["OK"], - show_back_button=False, allow_text_overflow=True, # Fit what we can, let the rest go off the edges ) @@ -371,21 +358,25 @@ def run(self): @dataclass class OptionDisabledView(View): - UPDATE_SETTING = "Update Setting" - DONE = "Done" + UPDATE_SETTING = ButtonOption("Update Setting") + DONE = ButtonOption("Done") settings_attr: str def __post_init__(self): super().__post_init__() self.settings_entry = SettingsDefinition.get_settings_entry(self.settings_attr) - self.error_msg = f"\"{self.settings_entry.display_name}\" is currently disabled in Settings." + + # TRANSLATOR_NOTE: Inserts the name of a settings option (e.g. "Persistent Settings" is currently...) + self.error_msg = _("\"{}\" is currently disabled in Settings.").format( + _(self.settings_entry.display_name), + ) def run(self): button_data = [self.UPDATE_SETTING, self.DONE] selected_menu_num = self.run_screen( WarningScreen, - title="Option Disabled", + title=_("Option Disabled"), status_headline=None, text=self.error_msg, button_data=button_data, @@ -398,26 +389,3 @@ def run(self): return Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=self.settings_attr), clear_history=True) else: return Destination(MainMenuView, clear_history=True) - - - -class RemoveMicroSDWarningView(View): - """ - Warning to remove the microsd - """ - def __init__(self, next_view: View): - super().__init__() - self.next_view = next_view - - def run(self): - self.run_screen( - WarningScreen, - title="Security Tip", - status_icon_name=FontAwesomeIconConstants.SDCARD, - status_headline="", - text="For maximum security,\nremove the MicroSD card\nbefore continuing.", - show_back_button=False, - button_data=["Continue"], - ) - - return Destination(self.next_view, clear_history=True) diff --git a/tests/README.md b/tests/README.md index 12f321d34..adc596c87 100644 --- a/tests/README.md +++ b/tests/README.md @@ -2,6 +2,7 @@ The tests are designed to be run on non-Raspi hardware. +## Setup On your testing machine you'll have to install: ```bash # general dependencies @@ -16,6 +17,14 @@ Then make the `seedsigner` python module visible/importable to the tests by inst pip3 install -e . ``` +## Running all tests, calculating overall test coverage +tldr: just run the convenience script from the project root: + +```bash +./tests/run_full_coverage.sh +``` + +## Running tests manually Run the whole test suite: ``` pytest @@ -45,18 +54,30 @@ Annoying complications: * Better idea: use a proper logger in the test file and use one of the above options to display logs -### Test Coverage +## Screenshot generator +The screenshot generator is meant to mostly be a utility and not really part of the test suite. However, +it is actually implemented to be run by `pytest`. + +see: [Screenshot generator README](screenshot_generator/README.md) + + +## Generate coverage manually Run tests and generate test coverage -``` +```bash coverage run -m pytest ``` -Show the resulting test coverage details: +The screenshots can generate their own separate coverage report: +```bash +coverage run -m pytest tests/screenshot_generator/generator.py --locale es ``` + +Show the resulting test coverage details: +```bash coverage report ``` -Generate the html overview: -``` +Generate the interactive html report: +```bash coverage html -``` +``` \ No newline at end of file diff --git a/tests/run_full_coverage.sh b/tests/run_full_coverage.sh new file mode 100755 index 000000000..5df87b96d --- /dev/null +++ b/tests/run_full_coverage.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +# Clean up any prior coverage results +coverage erase + +# Run the full test suite; `--parallel` writes coverage results to a separate file +# (otherwise it'll be overwritten in the next step) +coverage run --parallel -m pytest + +# Generate screenshots (only need to run for one locale to assess coverage) +coverage run --parallel -m pytest tests/screenshot_generator/generator.py --locale es + +# Combine the above coverage results +coverage combine + +# Show the report in the terminal +coverage report + +# Generate the interactive html report +coverage html diff --git a/tests/screenshot_generator/README.md b/tests/screenshot_generator/README.md index f25b163db..123c8e52f 100644 --- a/tests/screenshot_generator/README.md +++ b/tests/screenshot_generator/README.md @@ -1,8 +1,21 @@ # Screenshot Generator From the project root, run: -``` +```bash +# Generate screenshots for a specific locale +pytest tests/screenshot_generator/generator.py --locale es + +# Generate screenshots for all supported locales pytest tests/screenshot_generator/generator.py ``` +You can also run a `coverage` report to see exactly what the screenshots are and are not hitting: +```bash +coverage erase +coverage run -m pytest tests/screenshot_generator/generator.py --locale es && coverage combine && coverage report + +# Generate the interactive html report +coverage html +``` + Writes the screenshots to a dir in the project root: `seedsigner-screenshots`. diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index 4c0c092a8..114f5c68c 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -1,47 +1,112 @@ import embit +import pathlib +import pytest import os import random import sys import time from unittest.mock import Mock, patch, MagicMock -from seedsigner.helpers import embit_utils from embit import compact from embit.psbt import PSBT, OutputScope from embit.script import Script -from seedsigner.helpers import embit_utils -from seedsigner.models.psbt_parser import OPCODES, PSBTParser - - # Prevent importing modules w/Raspi hardware dependencies. # These must precede any SeedSigner imports. sys.modules['seedsigner.hardware.ST7789'] = MagicMock() -sys.modules['seedsigner.gui.screens.screensaver'] = MagicMock() -sys.modules['seedsigner.views.screensaver'] = MagicMock() +sys.modules['seedsigner.views.screensaver.ScreensaverScreen'] = MagicMock() sys.modules['RPi'] = MagicMock() sys.modules['RPi.GPIO'] = MagicMock() sys.modules['seedsigner.hardware.camera'] = MagicMock() sys.modules['seedsigner.hardware.microsd'] = MagicMock() +# Force the screenshots to mimic Pi Zero's output without libraqm +patch('PIL.ImageFont.core.HAVE_RAQM', False).start() from seedsigner.controller import Controller from seedsigner.gui.renderer import Renderer +from seedsigner.gui.screens.seed_screens import SeedAddPassphraseScreen from seedsigner.gui.toast import BaseToastOverlayManagerThread, RemoveSDCardToastManagerThread, SDCardStateChangeToastManagerThread from seedsigner.hardware.microsd import MicroSD +from seedsigner.helpers import embit_utils from seedsigner.models.decode_qr import DecodeQR +from seedsigner.models.psbt_parser import OPCODES, PSBTParser from seedsigner.models.qr_type import QRType from seedsigner.models.seed import Seed +from seedsigner.models.settings import Settings from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition from seedsigner.views import (MainMenuView, PowerOptionsView, RestartView, NotYetImplementedView, UnhandledExceptionView, psbt_views, seed_views, settings_views, tools_views) +from seedsigner.views.screensaver import OpeningSplashView from seedsigner.views.view import ErrorView, NetworkMismatchErrorView, OptionDisabledView, PowerOffView, View -from .utils import ScreenshotComplete, ScreenshotRenderer +from .utils import ScreenshotComplete, ScreenshotConfig, ScreenshotRenderer + +import warnings; warnings.warn = lambda *args, **kwargs: None + +# Dynamically generate a pytest test run for each locale +@pytest.mark.parametrize("locale", [x for x, y in SettingsConstants.ALL_LOCALES]) +def test_generate_all(locale, target_locale): + """ + `target_locale` is a fixture created in conftest.py via the `--locale` command line arg. -def test_generate_screenshots(target_locale): + Optionally skips all other locales. + """ + if target_locale and locale != target_locale: + pytest.skip(f"Skipping {locale}") + + generate_screenshots(locale) + + + +"""************************************************************************************** + Set up global test data that will be re-used across a variety of screenshots and for + all locales. +**************************************************************************************""" +BASE64_PSBT_1 = """cHNidP8BAP06AQIAAAAC5l4E3oEjI+H0im8t/K2nLmF5iJFdKEiuQs8ESveWJKcAAAAAAP3///8iBZMRhYIq4s/LmnTmKBi79M8ITirmsbO++63evK4utwAAAAAA/f///wZYQuoDAAAAACIAIAW5jm3UnC5fyjKCUZ8LTzjENtb/ioRTaBMXeSXsB3n+bK2fCgAAAAAWABReJY7akT1+d+jx475yBRWORdBd7VxbUgUAAAAAFgAU4wj9I/jB3GjNQudNZAca+7g9R16iWtYOAAAAABYAFIotPApLZlfscg8f3ppKqO3qA5nv7BnMFAAAAAAiACAs6SGc8qv4FwuNl0G0SpMZG8ODUEk5RXiWUcuzzw5iaRSfAhMAAAAAIgAgW0f5QxQIgVCGQqKzsvfkXZjUxdFop5sfez6Pt8mUbmZ1AgAAAAEAkgIAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////BQIRAgEB/////wJAvkAlAAAAACIAIIRPoo2LvkrwrhrYFhLhlP43izxbA4Eo6Y6iFFiQYdXRAAAAAAAAAAAmaiSqIant4vYcP3HR3v0/qZnfo2lTdVxpBol5mWK0i+vYNpdOjPkAAAAAAQErQL5AJQAAAAAiACCET6KNi75K8K4a2BYS4ZT+N4s8WwOBKOmOohRYkGHV0QEFR1EhArGhNdUqlR4BAOLGTMrY2ZJYTQNRudp7fU7i8crRJqgEIQNDxn7PjUzvsP6KYw4s7dmoZE0qO1K6MaM+2ScRZ7hyxFKuIgYCsaE11SqVHgEA4sZMytjZklhNA1G52nt9TuLxytEmqAQcc8XaCjAAAIABAACAAAAAgAIAAIAAAAAAAwAAACIGA0PGfs+NTO+w/opjDizt2ahkTSo7Uroxoz7ZJxFnuHLEHCK94akwAACAAQAAgAAAAIACAACAAAAAAAMAAAAAAQCSAgAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////8FAhACAQH/////AkC+QCUAAAAAIgAghE+ijYu+SvCuGtgWEuGU/jeLPFsDgSjpjqIUWJBh1dEAAAAAAAAAACZqJKohqe3i9hw/cdHe/T+pmd+jaVN1XGkGiXmZYrSL69g2l06M+QAAAAABAStAvkAlAAAAACIAIIRPoo2LvkrwrhrYFhLhlP43izxbA4Eo6Y6iFFiQYdXRAQVHUSECsaE11SqVHgEA4sZMytjZklhNA1G52nt9TuLxytEmqAQhA0PGfs+NTO+w/opjDizt2ahkTSo7Uroxoz7ZJxFnuHLEUq4iBgKxoTXVKpUeAQDixkzK2NmSWE0DUbnae31O4vHK0SaoBBxzxdoKMAAAgAEAAIAAAACAAgAAgAAAAAADAAAAIgYDQ8Z+z41M77D+imMOLO3ZqGRNKjtSujGjPtknEWe4csQcIr3hqTAAAIABAACAAAAAgAIAAIAAAAAAAwAAAAABAUdRIQJ5XLCBS0hdo4NANq4lNhimzhyHj7dvObmPAwNj8L2xASEC9mwwoH28/WHnxbb6z05sJ/lHuvrLs/wOooHgFn5ulI1SriICAnlcsIFLSF2jg0A2riU2GKbOHIePt285uY8DA2PwvbEBHCK94akwAACAAQAAgAAAAIACAACAAQAAAAEAAAAiAgL2bDCgfbz9YefFtvrPTmwn+Ue6+suz/A6igeAWfm6UjRxzxdoKMAAAgAEAAIAAAACAAgAAgAEAAAABAAAAAAAAAAEBR1EhAgpbWcEh7rgvRE5UaCcqzWL/TR1B/DS8UeZsKVEvuKLrIQOwLg0emiQbbxafIh69Xjtpj4eclsMhKq1y/7vYDdE7LVKuIgICCltZwSHuuC9ETlRoJyrNYv9NHUH8NLxR5mwpUS+4ouscc8XaCjAAAIABAACAAAAAgAIAAIAAAAAABQAAACICA7AuDR6aJBtvFp8iHr1eO2mPh5yWwyEqrXL/u9gN0TstHCK94akwAACAAQAAgAAAAIACAACAAAAAAAUAAAAAAQFHUSECk50GLh/YhZaLJkDq/dugU3H/WvE6rTgQuY6N57pI4ykhA/H8MdLVP9SA/Hg8l3hvibSaC1bCBzwz7kTW+rsEZ8uFUq4iAgKTnQYuH9iFlosmQOr926BTcf9a8TqtOBC5jo3nukjjKRxzxdoKMAAAgAEAAIAAAACAAgAAgAAAAAAGAAAAIgID8fwx0tU/1ID8eDyXeG+JtJoLVsIHPDPuRNb6uwRny4UcIr3hqTAAAIABAACAAAAAgAIAAIAAAAAABgAAAAA=""" +mnemonic_12b = ["abandon"] * 11 + ["about"] +seed_12b = Seed(mnemonic=mnemonic_12b, wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) + +def add_op_return_to_psbt(psbt: PSBT, raw_payload_data: bytes): + data = (compact.to_bytes(OPCODES.OP_RETURN) + + compact.to_bytes(OPCODES.OP_PUSHDATA1) + + compact.to_bytes(len(raw_payload_data)) + + raw_payload_data) + script = Script(data) + output = OutputScope() + output.script_pubkey = script + output.value = 0 + psbt.outputs.append(output) + return psbt.to_string() + +# Prep a PSBT with a human-readable OP_RETURN +raw_payload_data = "Chancellor on the brink of third bailout for banks".encode() +psbt = PSBT.from_base64(BASE64_PSBT_1) + +# Simplify the output side +output = psbt.outputs[-1] +psbt.outputs.clear() +psbt.outputs.append(output) +assert len(psbt.outputs) == 1 +BASE64_PSBT_WITH_OP_RETURN_TEXT = add_op_return_to_psbt(psbt, raw_payload_data) + +# Prep a PSBT with a (repeatably) random 80-byte OP_RETURN +random.seed(6102) +BASE64_PSBT_WITH_OP_RETURN_RAW_BYTES = add_op_return_to_psbt(PSBT.from_base64(BASE64_PSBT_1), random.randbytes(80)) + +mnemonic_12 = "forum undo fragile fade shy sign arrest garment culture tube off merit".split() +mnemonic_24 = "attack pizza motion avocado network gather crop fresh patrol unusual wild holiday candy pony ranch winter theme error hybrid van cereal salon goddess expire".split() +seed_12 = Seed(mnemonic=mnemonic_12, passphrase="cap*BRACKET3stove", wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) +seed_24 = Seed(mnemonic=mnemonic_24, passphrase="some-PASS*phrase9", wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) +seed_24_w_passphrase = Seed(mnemonic=mnemonic_24, passphrase="some-PASS*phrase9", wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) + +MULTISIG_WALLET_DESCRIPTOR = """wsh(sortedmulti(1,[22bde1a9/48h/1h/0h/2h]tpubDFfsBrmpj226ZYiRszYi2qK6iGvh2vkkghfGB2YiRUVY4rqqedHCFEgw12FwDkm7rUoVtq9wLTKc6BN2sxswvQeQgp7m8st4FP8WtP8go76/{0,1}/*,[73c5da0a/48h/1h/0h/2h]tpubDFH9dgzveyD8zTbPUFuLrGmCydNvxehyNdUXKJAQN8x4aZ4j6UZqGfnqFrD4NqyaTVGKbvEW54tsvPTK2UoSbCC1PJY8iCNiwTL3RWZEheQ/{0,1}/*))#3jhtf6yx""" + + + +def generate_screenshots(locale): """ The `Renderer` class is mocked so that calls in the normal code are ignored (necessary to avoid having it trying to wire up hardware dependencies). @@ -58,323 +123,358 @@ def test_generate_screenshots(target_locale): Renderer.configure_instance = Mock() Renderer.get_instance = Mock(return_value=screenshot_renderer) - # Additional mocks needed - PowerOffView.PowerOffThread = Mock() # Don't let this View actually send the `shutdown` command! - - controller = Controller.get_instance() - - # Set up some test data that we'll need in the `Controller` for certain Views - mnemonic_12 = "forum undo fragile fade shy sign arrest garment culture tube off merit".split() - mnemonic_24 = "attack pizza motion avocado network gather crop fresh patrol unusual wild holiday candy pony ranch winter theme error hybrid van cereal salon goddess expire".split() - mnemonic_12b = ["abandon"] * 11 + ["about"] - seed_12 = Seed(mnemonic=mnemonic_12, passphrase="cap*BRACKET3stove", wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) - seed_12b = Seed(mnemonic=mnemonic_12b, wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) - seed_24 = Seed(mnemonic=mnemonic_24, wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) - seed_24_w_passphrase = Seed(mnemonic=mnemonic_24, passphrase="some-PASS*phrase9", wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) - controller.storage.seeds.append(seed_12) - controller.storage.seeds.append(seed_12b) - controller.storage.seeds.append(seed_24) - controller.storage.set_pending_seed(seed_24_w_passphrase) - UnhandledExceptionViewFood = ["IndexError", "line 1, in some_buggy_code.py", "list index out of range"] - - # Pending mnemonic for ToolsCalcFinalWordShowFinalWordView - controller.storage.init_pending_mnemonic(num_words=12) - for i, word in enumerate(mnemonic_12[:11]): - controller.storage.update_pending_mnemonic(word=word, index=i) - controller.storage.update_pending_mnemonic(word="satoshi", index=11) # random last word; not supposed to be a valid checksum (yet) - - # Load a PSBT into memory - BASE64_PSBT_1 = """cHNidP8BAP06AQIAAAAC5l4E3oEjI+H0im8t/K2nLmF5iJFdKEiuQs8ESveWJKcAAAAAAP3///8iBZMRhYIq4s/LmnTmKBi79M8ITirmsbO++63evK4utwAAAAAA/f///wZYQuoDAAAAACIAIAW5jm3UnC5fyjKCUZ8LTzjENtb/ioRTaBMXeSXsB3n+bK2fCgAAAAAWABReJY7akT1+d+jx475yBRWORdBd7VxbUgUAAAAAFgAU4wj9I/jB3GjNQudNZAca+7g9R16iWtYOAAAAABYAFIotPApLZlfscg8f3ppKqO3qA5nv7BnMFAAAAAAiACAs6SGc8qv4FwuNl0G0SpMZG8ODUEk5RXiWUcuzzw5iaRSfAhMAAAAAIgAgW0f5QxQIgVCGQqKzsvfkXZjUxdFop5sfez6Pt8mUbmZ1AgAAAAEAkgIAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////BQIRAgEB/////wJAvkAlAAAAACIAIIRPoo2LvkrwrhrYFhLhlP43izxbA4Eo6Y6iFFiQYdXRAAAAAAAAAAAmaiSqIant4vYcP3HR3v0/qZnfo2lTdVxpBol5mWK0i+vYNpdOjPkAAAAAAQErQL5AJQAAAAAiACCET6KNi75K8K4a2BYS4ZT+N4s8WwOBKOmOohRYkGHV0QEFR1EhArGhNdUqlR4BAOLGTMrY2ZJYTQNRudp7fU7i8crRJqgEIQNDxn7PjUzvsP6KYw4s7dmoZE0qO1K6MaM+2ScRZ7hyxFKuIgYCsaE11SqVHgEA4sZMytjZklhNA1G52nt9TuLxytEmqAQcc8XaCjAAAIABAACAAAAAgAIAAIAAAAAAAwAAACIGA0PGfs+NTO+w/opjDizt2ahkTSo7Uroxoz7ZJxFnuHLEHCK94akwAACAAQAAgAAAAIACAACAAAAAAAMAAAAAAQCSAgAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////8FAhACAQH/////AkC+QCUAAAAAIgAghE+ijYu+SvCuGtgWEuGU/jeLPFsDgSjpjqIUWJBh1dEAAAAAAAAAACZqJKohqe3i9hw/cdHe/T+pmd+jaVN1XGkGiXmZYrSL69g2l06M+QAAAAABAStAvkAlAAAAACIAIIRPoo2LvkrwrhrYFhLhlP43izxbA4Eo6Y6iFFiQYdXRAQVHUSECsaE11SqVHgEA4sZMytjZklhNA1G52nt9TuLxytEmqAQhA0PGfs+NTO+w/opjDizt2ahkTSo7Uroxoz7ZJxFnuHLEUq4iBgKxoTXVKpUeAQDixkzK2NmSWE0DUbnae31O4vHK0SaoBBxzxdoKMAAAgAEAAIAAAACAAgAAgAAAAAADAAAAIgYDQ8Z+z41M77D+imMOLO3ZqGRNKjtSujGjPtknEWe4csQcIr3hqTAAAIABAACAAAAAgAIAAIAAAAAAAwAAAAABAUdRIQJ5XLCBS0hdo4NANq4lNhimzhyHj7dvObmPAwNj8L2xASEC9mwwoH28/WHnxbb6z05sJ/lHuvrLs/wOooHgFn5ulI1SriICAnlcsIFLSF2jg0A2riU2GKbOHIePt285uY8DA2PwvbEBHCK94akwAACAAQAAgAAAAIACAACAAQAAAAEAAAAiAgL2bDCgfbz9YefFtvrPTmwn+Ue6+suz/A6igeAWfm6UjRxzxdoKMAAAgAEAAIAAAACAAgAAgAEAAAABAAAAAAAAAAEBR1EhAgpbWcEh7rgvRE5UaCcqzWL/TR1B/DS8UeZsKVEvuKLrIQOwLg0emiQbbxafIh69Xjtpj4eclsMhKq1y/7vYDdE7LVKuIgICCltZwSHuuC9ETlRoJyrNYv9NHUH8NLxR5mwpUS+4ouscc8XaCjAAAIABAACAAAAAgAIAAIAAAAAABQAAACICA7AuDR6aJBtvFp8iHr1eO2mPh5yWwyEqrXL/u9gN0TstHCK94akwAACAAQAAgAAAAIACAACAAAAAAAUAAAAAAQFHUSECk50GLh/YhZaLJkDq/dugU3H/WvE6rTgQuY6N57pI4ykhA/H8MdLVP9SA/Hg8l3hvibSaC1bCBzwz7kTW+rsEZ8uFUq4iAgKTnQYuH9iFlosmQOr926BTcf9a8TqtOBC5jo3nukjjKRxzxdoKMAAAgAEAAIAAAACAAgAAgAAAAAAGAAAAIgID8fwx0tU/1ID8eDyXeG+JtJoLVsIHPDPuRNb6uwRny4UcIr3hqTAAAIABAACAAAAAgAIAAIAAAAAABgAAAAA=""" - decoder = DecodeQR() - decoder.add_data(BASE64_PSBT_1) - controller.psbt = decoder.get_psbt() - controller.psbt_seed = seed_12b - - def add_op_return_to_psbt(psbt: PSBT, raw_payload_data: bytes): - data = (compact.to_bytes(OPCODES.OP_RETURN) + - compact.to_bytes(OPCODES.OP_PUSHDATA1) + - compact.to_bytes(len(raw_payload_data)) + - raw_payload_data) - script = Script(data) - output = OutputScope() - output.script_pubkey = script - output.value = 0 - psbt.outputs.append(output) - return psbt.to_string() - - # Prep a PSBT with a human-readable OP_RETURN - raw_payload_data = "Chancellor on the brink of third bailout for banks".encode() - psbt = PSBT.from_base64(BASE64_PSBT_1) - - # Simplify the output side - output = psbt.outputs[-1] - psbt.outputs.clear() - psbt.outputs.append(output) - assert len(psbt.outputs) == 1 - BASE64_PSBT_WITH_OP_RETURN_TEXT = add_op_return_to_psbt(psbt, raw_payload_data) - - # Prep a PSBT with a (repeatably) random 80-byte OP_RETURN - random.seed(6102) - BASE64_PSBT_WITH_OP_RETURN_RAW_BYTES = add_op_return_to_psbt(PSBT.from_base64(BASE64_PSBT_1), random.randbytes(80)) - - # Multisig wallet descriptor for the multisig in the above PSBT - MULTISIG_WALLET_DESCRIPTOR = """wsh(sortedmulti(1,[22bde1a9/48h/1h/0h/2h]tpubDFfsBrmpj226ZYiRszYi2qK6iGvh2vkkghfGB2YiRUVY4rqqedHCFEgw12FwDkm7rUoVtq9wLTKc6BN2sxswvQeQgp7m8st4FP8WtP8go76/{0,1}/*,[73c5da0a/48h/1h/0h/2h]tpubDFH9dgzveyD8zTbPUFuLrGmCydNvxehyNdUXKJAQN8x4aZ4j6UZqGfnqFrD4NqyaTVGKbvEW54tsvPTK2UoSbCC1PJY8iCNiwTL3RWZEheQ/{0,1}/*))#3jhtf6yx""" - controller.multisig_wallet_descriptor = embit.descriptor.Descriptor.from_string(MULTISIG_WALLET_DESCRIPTOR) - - # Message signing data - derivation_path = "m/84h/0h/0h/0/0" - controller.sign_message_data = { - "seed_num": 0, - "derivation_path": derivation_path, - "message": "I attest that I control this bitcoin address blah blah blah", - "addr_format": embit_utils.parse_derivation_path(derivation_path) - } - - # Automatically populate all Settings options Views - settings_views_list = [] - settings_views_list.append(settings_views.SettingsMenuView) - settings_views_list.append(( - settings_views.SettingsMenuView, - dict( - visibility=SettingsConstants.VISIBILITY__ADVANCED, - selected_attr=SettingsConstants.SETTING__ELECTRUM_SEEDS, - initial_scroll=240, # Just guessing how many pixels to scroll down - ), - "SettingsMenuView__Advanced" - )) - - # so we get a choice for transcribe seed qr format - controller.settings.set_value( - attr_name=SettingsConstants.SETTING__COMPACT_SEEDQR, - value=SettingsConstants.OPTION__ENABLED - ) - for settings_entry in SettingsDefinition.settings_entries: - if settings_entry.visibility == SettingsConstants.VISIBILITY__HIDDEN: - continue - - settings_views_list.append((settings_views.SettingsEntryUpdateSelectionView, dict(attr_name=settings_entry.attr_name), f"SettingsEntryUpdateSelectionView_{settings_entry.attr_name}")) - - - settingsqr_data_persistent = "settings::v1 name=Total_noob_mode persistent=E coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E" - settingsqr_data_not_persistent = "settings::v1 name=Ephemeral_noob_mode persistent=D coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E" - - screenshot_sections = { - "Main Menu Views": [ - MainMenuView, - (MainMenuView, {}, 'MainMenuView_SDCardStateChangeToast_removed', SDCardStateChangeToastManagerThread(action=MicroSD.ACTION__REMOVED)), - (MainMenuView, {}, 'MainMenuView_SDCardStateChangeToast_inserted', SDCardStateChangeToastManagerThread(action=MicroSD.ACTION__INSERTED)), - (MainMenuView, {}, 'MainMenuView_RemoveSDCardToast', RemoveSDCardToastManagerThread(activation_delay=0)), - PowerOptionsView, - RestartView, - PowerOffView, - ], - "Seed Views": [ - seed_views.SeedsMenuView, - seed_views.LoadSeedView, - seed_views.SeedMnemonicEntryView, - seed_views.SeedMnemonicInvalidView, - seed_views.SeedFinalizeView, - seed_views.SeedAddPassphraseView, - seed_views.SeedAddPassphraseExitDialogView, - seed_views.SeedReviewPassphraseView, - - (seed_views.SeedOptionsView, dict(seed_num=0)), - (seed_views.SeedBackupView, dict(seed_num=0)), - (seed_views.SeedExportXpubSigTypeView, dict(seed_num=0)), - (seed_views.SeedExportXpubScriptTypeView, dict(seed_num=0, sig_type="msig")), - (seed_views.SeedExportXpubCustomDerivationView, dict(seed_num=0, sig_type="ss", script_type="")), - (seed_views.SeedExportXpubCoordinatorView, dict(seed_num=0, sig_type="ss", script_type="nat")), - (seed_views.SeedExportXpubWarningView, dict(seed_num=0, sig_type="msig", script_type="nes", coordinator="spd", custom_derivation="")), - (seed_views.SeedExportXpubDetailsView, dict(seed_num=0, sig_type="ss", script_type="nat", coordinator="bw", custom_derivation="")), - #SeedExportXpubQRDisplayView, - (seed_views.SeedWordsWarningView, dict(seed_num=0)), - (seed_views.SeedWordsView, dict(seed_num=0)), - (seed_views.SeedWordsView, dict(seed_num=0, page_index=2), "SeedWordsView_2"), - (seed_views.SeedBIP85ApplicationModeView, dict(seed_num=0)), - (seed_views.SeedBIP85SelectChildIndexView, dict(seed_num=0, num_words=24)), - (seed_views.SeedBIP85InvalidChildIndexView, dict(seed_num=0, num_words=12)), - (seed_views.SeedWordsBackupTestPromptView, dict(seed_num=0)), - (seed_views.SeedWordsBackupTestView, dict(seed_num=0)), - (seed_views.SeedWordsBackupTestMistakeView, dict(seed_num=0, cur_index=7, wrong_word="unlucky")), - (seed_views.SeedWordsBackupTestSuccessView, dict(seed_num=0)), - (seed_views.SeedTranscribeSeedQRFormatView, dict(seed_num=0)), - (seed_views.SeedTranscribeSeedQRWarningView, dict(seed_num=0)), - (seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=0, seedqr_format=QRType.SEED__COMPACTSEEDQR, num_modules=21), "SeedTranscribeSeedQRWholeQRView_12_Compact"), - (seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=0, seedqr_format=QRType.SEED__SEEDQR, num_modules=25), "SeedTranscribeSeedQRWholeQRView_12_Standard"), - (seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=2, seedqr_format=QRType.SEED__COMPACTSEEDQR, num_modules=25), "SeedTranscribeSeedQRWholeQRView_24_Compact"), - (seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=2, seedqr_format=QRType.SEED__SEEDQR, num_modules=29), "SeedTranscribeSeedQRWholeQRView_24_Standard"), - - # Screenshot doesn't render properly due to how the transparency mask is pre-rendered - # (seed_views.SeedTranscribeSeedQRZoomedInView, dict(seed_num=0, seedqr_format=QRType.SEED__SEEDQR)), - - (seed_views.SeedTranscribeSeedQRConfirmQRPromptView, dict(seed_num=0)), - - # Screenshot can't render live preview screens - # (seed_views.SeedTranscribeSeedQRConfirmScanView, dict(seed_num=0)), - - #(seed_views.AddressVerificationStartView, dict(address=, script_type="nat", network="M")), - #seed_views.AddressVerificationSigTypeView, - #seed_views.SeedSingleSigAddressVerificationSelectSeedView, - #seed_views.SeedAddressVerificationView, - #seed_views.AddressVerificationSuccessView, - - seed_views.LoadMultisigWalletDescriptorView, - seed_views.MultisigWalletDescriptorView, - (seed_views.SeedDiscardView, dict(seed_num=0)), - - seed_views.SeedSignMessageConfirmMessageView, - seed_views.SeedSignMessageConfirmAddressView, - - seed_views.SeedElectrumMnemonicStartView, - ], - "PSBT Views": [ - psbt_views.PSBTSelectSeedView, # this will fail, be rerun below - psbt_views.PSBTOverviewView, - psbt_views.PSBTUnsupportedScriptTypeWarningView, - psbt_views.PSBTNoChangeWarningView, - psbt_views.PSBTMathView, - (psbt_views.PSBTAddressDetailsView, dict(address_num=0)), - - (NotYetImplementedView, {}, "PSBTChangeDetailsView_multisig_unverified"), # Must manually re-run this below - (psbt_views.PSBTChangeDetailsView, dict(change_address_num=0), "PSBTChangeDetailsView_multisig_verified"), - - (NotYetImplementedView, {}, "PSBTOverviewView_op_return"), # Placeholder - (NotYetImplementedView, {}, "PSBTOpReturnView_text"), # Placeholder - (NotYetImplementedView, {}, "PSBTOpReturnView_raw_hex_data"), # Placeholder - - (psbt_views.PSBTAddressVerificationFailedView, dict(is_change=True, is_multisig=False), "PSBTAddressVerificationFailedView_singlesig_change"), - (psbt_views.PSBTAddressVerificationFailedView, dict(is_change=False, is_multisig=False), "PSBTAddressVerificationFailedView_singlesig_selftransfer"), - (psbt_views.PSBTAddressVerificationFailedView, dict(is_change=True, is_multisig=True), "PSBTAddressVerificationFailedView_multisig_change"), - (psbt_views.PSBTAddressVerificationFailedView, dict(is_change=False, is_multisig=True), "PSBTAddressVerificationFailedView_multisig_selftransfer"), - psbt_views.PSBTFinalizeView, - #PSBTSignedQRDisplayView - psbt_views.PSBTSigningErrorView, - ], - "Tools Views": [ - tools_views.ToolsMenuView, - #ToolsImageEntropyLivePreviewView - #ToolsImageEntropyFinalImageView - tools_views.ToolsImageEntropyMnemonicLengthView, - tools_views.ToolsDiceEntropyMnemonicLengthView, - (tools_views.ToolsDiceEntropyEntryView, dict(total_rolls=50)), - tools_views.ToolsCalcFinalWordNumWordsView, - tools_views.ToolsCalcFinalWordFinalizePromptView, - tools_views.ToolsCalcFinalWordCoinFlipsView, - (tools_views.ToolsCalcFinalWordShowFinalWordView, {}, "ToolsCalcFinalWordShowFinalWordView_pick_word"), - (tools_views.ToolsCalcFinalWordShowFinalWordView, dict(coin_flips="0010101"), "ToolsCalcFinalWordShowFinalWordView_coin_flips"), - #tools_views.ToolsCalcFinalWordDoneView, - tools_views.ToolsAddressExplorerSelectSourceView, - tools_views.ToolsAddressExplorerAddressTypeView, - tools_views.ToolsAddressExplorerAddressListView, - #tools_views.ToolsAddressExplorerAddressView, - ], - "Settings Views": settings_views_list + [ - settings_views.IOTestView, - settings_views.DonateView, - (settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_persistent), "SettingsIngestSettingsQRView_persistent"), - (settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_not_persistent), "SettingsIngestSettingsQRView_not_persistent"), - ], - "Misc Error Views": [ - NotYetImplementedView, - (UnhandledExceptionView, dict(error=UnhandledExceptionViewFood)), - NetworkMismatchErrorView, - (OptionDisabledView, dict(settings_attr=SettingsConstants.SETTING__MESSAGE_SIGNING)), - (ErrorView, dict( - title="Error", - status_headline="Unknown QR Type", - text="QRCode is invalid or is a data format not yet supported.", - button_text="Back", - )), - ] - } - - readme = f"""# SeedSigner Screenshots\n""" - - def screencap_view(view_cls: View, view_args: dict = {}, view_name: str = None, toast_thread: BaseToastOverlayManagerThread = None): - if not view_name: - view_name = view_cls.__name__ - screenshot_renderer.set_screenshot_filename(f"{view_name}.png") + def setup_screenshots(locale: str) -> dict[str, list[ScreenshotConfig]]: + """ Set up some test data that we'll need in the `Controller` for certain Views """ + # Must reset the Controller so each locale gets a fresh start + Controller.reset_instance() + controller = Controller.get_instance() + + controller.settings.set_value(SettingsConstants.SETTING__SIG_TYPES, [attr for attr, name in SettingsConstants.ALL_SIG_TYPES]) + controller.settings.set_value(SettingsConstants.SETTING__SCRIPT_TYPES, [attr for attr, name in SettingsConstants.ALL_SCRIPT_TYPES]) + + controller.storage.seeds.append(seed_12) + controller.storage.seeds.append(seed_12b) + controller.storage.seeds.append(seed_24) + controller.storage.set_pending_seed(seed_24_w_passphrase) + + # Pending mnemonic for ToolsCalcFinalWordShowFinalWordView + controller.storage.init_pending_mnemonic(num_words=12) + for i, word in enumerate(mnemonic_12[:11]): + controller.storage.update_pending_mnemonic(word=word, index=i) + controller.storage.update_pending_mnemonic(word="satoshi", index=11) # random last word; not supposed to be a valid checksum (yet) + + # Load a PSBT into memory + decoder = DecodeQR() + decoder.add_data(BASE64_PSBT_1) + controller.psbt = decoder.get_psbt() + controller.psbt_seed = seed_12b + + # Message signing data + derivation_path = "m/84h/0h/0h/0/0" + controller.sign_message_data = { + "seed_num": 0, + "derivation_path": derivation_path, + "message": "I attest that I control this bitcoin address blah blah blah", + "addr_format": embit_utils.parse_derivation_path(derivation_path) + } + + # Automatically populate all Settings options Views + settings_views_list = [] + settings_views_list.append(ScreenshotConfig(settings_views.SettingsMenuView)) + settings_views_list.append( + ScreenshotConfig( + settings_views.SettingsMenuView, + dict( + visibility=SettingsConstants.VISIBILITY__ADVANCED, + selected_attr=SettingsConstants.SETTING__ELECTRUM_SEEDS, + initial_scroll=240, # Just guessing how many pixels to scroll down + ), + screenshot_name="SettingsMenuView__Advanced" + ) + ) + + # so we get a choice for transcribe seed qr format + controller.settings.set_value( + attr_name=SettingsConstants.SETTING__COMPACT_SEEDQR, + value=SettingsConstants.OPTION__ENABLED + ) + for settings_entry in SettingsDefinition.settings_entries: + if settings_entry.visibility == SettingsConstants.VISIBILITY__HIDDEN: + continue + + settings_views_list.append(ScreenshotConfig(settings_views.SettingsEntryUpdateSelectionView, dict(attr_name=settings_entry.attr_name), screenshot_name=f"SettingsEntryUpdateSelectionView_{settings_entry.attr_name}")) + + settingsqr_data_persistent = f"settings::v1 name=English_noob_mode persistent=E coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E locale={locale}" + settingsqr_data_not_persistent = f"settings::v1 name=Mode_Ephemeral persistent=D coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E locale={locale}" + + + # Set up screenshot-specific callbacks to inject data before the View is run and + # reset data after the View is run. + def load_basic_psbt_cb(): + decoder = DecodeQR() + decoder.add_data(BASE64_PSBT_1) + controller.psbt = decoder.get_psbt() + controller.psbt_seed = seed_12b + controller.multisig_wallet_descriptor = None + + + def load_multisig_wallet_descriptor_cb(): + controller.multisig_wallet_descriptor = embit.descriptor.Descriptor.from_string(MULTISIG_WALLET_DESCRIPTOR) + + + def load_address_verification_data_cb(): + controller.unverified_address = dict( + # These are all totally fake data + address="bc1q6p00wazu4nnqac29fvky6vhjnnhku5u2g9njss62rvy7e0yuperq86f5ek", + network=SettingsConstants.MAINNET, + sig_type=SettingsConstants.SINGLE_SIG, + script_type=SettingsConstants.NATIVE_SEGWIT, + derivation_path = "m/84h/0h/0h", + verified_index=5, + verified_index_is_change=False + ) + + + def PSBTSelectSeedView_cb_before(): + # Have to ensure this is cleared out in order to get the seed selection screen + controller.psbt_seed = None + + + def PSBTOverviewView_op_return_cb_before(): + controller.psbt_seed = seed_12b + decoder = DecodeQR() + decoder.add_data(BASE64_PSBT_WITH_OP_RETURN_TEXT) + controller.psbt = decoder.get_psbt() + controller.psbt_parser = PSBTParser(p=controller.psbt, seed=seed_12b) + + + def PSBTOpReturnView_raw_hex_data_cb_before(): + decoder.add_data(BASE64_PSBT_WITH_OP_RETURN_RAW_BYTES) + controller.psbt = decoder.get_psbt() + controller.psbt_parser = PSBTParser(p=controller.psbt, seed=seed_12b) + + + screenshot_sections = { + "Main Menu Views": [ + ScreenshotConfig(OpeningSplashView, dict(is_screenshot_renderer=True, force_partner_logos=True)), + ScreenshotConfig(OpeningSplashView, dict(is_screenshot_renderer=True, force_partner_logos=False), screenshot_name="OpeningSplashView_no_partner_logos"), + ScreenshotConfig(MainMenuView), + ScreenshotConfig(MainMenuView, screenshot_name='MainMenuView_SDCardStateChangeToast_removed', toast_thread=SDCardStateChangeToastManagerThread(action=MicroSD.ACTION__REMOVED, activation_delay=0, duration=0)), + ScreenshotConfig(MainMenuView, screenshot_name='MainMenuView_SDCardStateChangeToast_inserted', toast_thread=SDCardStateChangeToastManagerThread(action=MicroSD.ACTION__INSERTED, activation_delay=0, duration=0)), + ScreenshotConfig(MainMenuView, screenshot_name='MainMenuView_RemoveSDCardToast', toast_thread=RemoveSDCardToastManagerThread(activation_delay=0, duration=0)), + ScreenshotConfig(PowerOptionsView), + ScreenshotConfig(RestartView), + ScreenshotConfig(PowerOffView), + ], + "Seed Views": [ + ScreenshotConfig(seed_views.SeedsMenuView), + ScreenshotConfig(seed_views.LoadSeedView), + ScreenshotConfig(seed_views.SeedMnemonicEntryView), + ScreenshotConfig(seed_views.SeedMnemonicInvalidView), + ScreenshotConfig(seed_views.SeedFinalizeView), + ScreenshotConfig(seed_views.SeedAddPassphraseView, screenshot_name="SeedAddPassphraseView_lowercase"), + ScreenshotConfig(seed_views.SeedAddPassphraseView, dict(initial_keyboard=SeedAddPassphraseScreen.KEYBOARD__UPPERCASE_BUTTON_TEXT), screenshot_name="SeedAddPassphraseView_uppercase"), + ScreenshotConfig(seed_views.SeedAddPassphraseView, dict(initial_keyboard=SeedAddPassphraseScreen.KEYBOARD__DIGITS_BUTTON_TEXT), screenshot_name="SeedAddPassphraseView_digits"), + ScreenshotConfig(seed_views.SeedAddPassphraseView, dict(initial_keyboard=SeedAddPassphraseScreen.KEYBOARD__SYMBOLS_1_BUTTON_TEXT), screenshot_name="SeedAddPassphraseView_symbols_1"), + ScreenshotConfig(seed_views.SeedAddPassphraseView, dict(initial_keyboard=SeedAddPassphraseScreen.KEYBOARD__SYMBOLS_2_BUTTON_TEXT), screenshot_name="SeedAddPassphraseView_symbols_2"), + ScreenshotConfig(seed_views.SeedAddPassphraseExitDialogView), + ScreenshotConfig(seed_views.SeedReviewPassphraseView), + + ScreenshotConfig(seed_views.SeedOptionsView, dict(seed_num=0)), + ScreenshotConfig(seed_views.SeedBackupView, dict(seed_num=0)), + ScreenshotConfig(seed_views.SeedExportXpubSigTypeView, dict(seed_num=0)), + ScreenshotConfig(seed_views.SeedExportXpubScriptTypeView, dict(seed_num=0, sig_type="msig")), + ScreenshotConfig(seed_views.SeedExportXpubCustomDerivationView, dict(seed_num=0, sig_type="ss", script_type="")), + ScreenshotConfig(seed_views.SeedExportXpubCoordinatorView, dict(seed_num=0, sig_type="ss", script_type="nat")), + ScreenshotConfig(seed_views.SeedExportXpubWarningView, dict(seed_num=0, sig_type="msig", script_type="nes", coordinator="spd", custom_derivation="")), + ScreenshotConfig(seed_views.SeedExportXpubDetailsView, dict(seed_num=0, sig_type="ss", script_type="nat", coordinator="bw", custom_derivation="")), + #ScreenshotConfig(SeedExportXpubQRDisplayView), + ScreenshotConfig(seed_views.SeedWordsWarningView, dict(seed_num=0)), + ScreenshotConfig(seed_views.SeedWordsView, dict(seed_num=0)), + ScreenshotConfig(seed_views.SeedWordsView, dict(seed_num=0, page_index=2), screenshot_name="SeedWordsView_2"), + ScreenshotConfig(seed_views.SeedBIP85ApplicationModeView, dict(seed_num=0)), + ScreenshotConfig(seed_views.SeedBIP85SelectChildIndexView, dict(seed_num=0, num_words=24)), + ScreenshotConfig(seed_views.SeedBIP85InvalidChildIndexView, dict(seed_num=0, num_words=12)), + ScreenshotConfig(seed_views.SeedWordsBackupTestPromptView, dict(seed_num=0)), + ScreenshotConfig(seed_views.SeedWordsBackupTestView, dict(seed_num=0, rand_seed=6102)), + ScreenshotConfig(seed_views.SeedWordsBackupTestMistakeView, dict(seed_num=0, cur_index=7, wrong_word="satoshi")), + ScreenshotConfig(seed_views.SeedWordsBackupTestSuccessView, dict(seed_num=0)), + ScreenshotConfig(seed_views.SeedTranscribeSeedQRFormatView, dict(seed_num=0)), + ScreenshotConfig(seed_views.SeedTranscribeSeedQRWarningView, dict(seed_num=0)), + ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=0, seedqr_format=QRType.SEED__COMPACTSEEDQR, num_modules=21), screenshot_name="SeedTranscribeSeedQRWholeQRView_12_Compact"), + ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=0, seedqr_format=QRType.SEED__SEEDQR, num_modules=25), screenshot_name="SeedTranscribeSeedQRWholeQRView_12_Standard"), + ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=2, seedqr_format=QRType.SEED__COMPACTSEEDQR, num_modules=25), screenshot_name="SeedTranscribeSeedQRWholeQRView_24_Compact"), + ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=2, seedqr_format=QRType.SEED__SEEDQR, num_modules=29), screenshot_name="SeedTranscribeSeedQRWholeQRView_24_Standard"), + ScreenshotConfig(seed_views.SeedTranscribeSeedQRZoomedInView, dict(seed_num=0, seedqr_format=QRType.SEED__COMPACTSEEDQR, initial_block_x=1, initial_block_y=1), screenshot_name="SeedTranscribeSeedQRZoomedInView_12_Compact"), + ScreenshotConfig(seed_views.SeedTranscribeSeedQRZoomedInView, dict(seed_num=0, seedqr_format=QRType.SEED__SEEDQR, initial_block_x=2, initial_block_y=2), screenshot_name="SeedTranscribeSeedQRZoomedInView_12_Standard"), + + ScreenshotConfig(seed_views.SeedTranscribeSeedQRConfirmQRPromptView, dict(seed_num=0)), + + # Screenshot can't render live preview screens + # ScreenshotConfig(seed_views.SeedTranscribeSeedQRConfirmScanView, dict(seed_num=0)), + + ScreenshotConfig(seed_views.SeedSelectSeedView, dict(flow=Controller.FLOW__VERIFY_SINGLESIG_ADDR), screenshot_name="SeedSelectSeedView_address_verification"), + ScreenshotConfig(seed_views.AddressVerificationSigTypeView), + ScreenshotConfig(seed_views.SeedAddressVerificationView, dict(seed_num=0), run_before=load_address_verification_data_cb), + ScreenshotConfig(seed_views.SeedAddressVerificationSuccessView, dict(seed_num=0)), # Relies on callback above + + ScreenshotConfig(seed_views.LoadMultisigWalletDescriptorView), + ScreenshotConfig(seed_views.MultisigWalletDescriptorView, run_before=load_multisig_wallet_descriptor_cb), + ScreenshotConfig(seed_views.SeedDiscardView, dict(seed_num=0)), + + ScreenshotConfig(seed_views.SeedSelectSeedView, dict(flow=Controller.FLOW__SIGN_MESSAGE), screenshot_name="SeedSelectSeedView_sign_message"), + ScreenshotConfig(seed_views.SeedSignMessageConfirmMessageView), + ScreenshotConfig(seed_views.SeedSignMessageConfirmAddressView), + + ScreenshotConfig(seed_views.SeedElectrumMnemonicStartView), + ], + "PSBT Views": [ + ScreenshotConfig(psbt_views.PSBTSelectSeedView, run_before=PSBTSelectSeedView_cb_before), + ScreenshotConfig(psbt_views.PSBTOverviewView, run_before=load_basic_psbt_cb), + ScreenshotConfig(psbt_views.PSBTUnsupportedScriptTypeWarningView), + ScreenshotConfig(psbt_views.PSBTNoChangeWarningView), + ScreenshotConfig(psbt_views.PSBTMathView), + ScreenshotConfig(psbt_views.PSBTAddressDetailsView, dict(address_num=0)), + + ScreenshotConfig(psbt_views.PSBTChangeDetailsView, dict(change_address_num=0), screenshot_name="PSBTChangeDetailsView_multisig_unverified", run_before=load_basic_psbt_cb), + ScreenshotConfig(psbt_views.PSBTChangeDetailsView, dict(change_address_num=0), screenshot_name="PSBTChangeDetailsView_multisig_verified", run_before=load_multisig_wallet_descriptor_cb), + ScreenshotConfig(psbt_views.PSBTOverviewView, screenshot_name="PSBTOverviewView_op_return", run_before=PSBTOverviewView_op_return_cb_before), + ScreenshotConfig(psbt_views.PSBTOpReturnView, screenshot_name="PSBTOpReturnView_text"), # Relies on callback above + ScreenshotConfig(psbt_views.PSBTOpReturnView, screenshot_name="PSBTOpReturnView_raw_hex_data", run_before=PSBTOpReturnView_raw_hex_data_cb_before), + ScreenshotConfig(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=True, is_multisig=False), screenshot_name="PSBTAddressVerificationFailedView_singlesig_change"), + ScreenshotConfig(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=False, is_multisig=False), screenshot_name="PSBTAddressVerificationFailedView_singlesig_selftransfer"), + ScreenshotConfig(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=True, is_multisig=True), screenshot_name="PSBTAddressVerificationFailedView_multisig_change"), + ScreenshotConfig(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=False, is_multisig=True), screenshot_name="PSBTAddressVerificationFailedView_multisig_selftransfer"), + ScreenshotConfig(psbt_views.PSBTFinalizeView), + #ScreenshotConfig(PSBTSignedQRDisplayViewScreenshotConfig), + ScreenshotConfig(psbt_views.PSBTSigningErrorView), + ], + "Tools Views": [ + ScreenshotConfig(tools_views.ToolsMenuView), + #ScreenshotConfig(ToolsImageEntropyLivePreviewView), + #ScreenshotConfig(ToolsImageEntropyFinalImageView), + ScreenshotConfig(tools_views.ToolsImageEntropyMnemonicLengthView), + ScreenshotConfig(tools_views.ToolsDiceEntropyMnemonicLengthView), + ScreenshotConfig(tools_views.ToolsDiceEntropyEntryView, dict(total_rolls=50)), + ScreenshotConfig(tools_views.ToolsCalcFinalWordNumWordsView), + ScreenshotConfig(tools_views.ToolsCalcFinalWordFinalizePromptView), + ScreenshotConfig(tools_views.ToolsCalcFinalWordCoinFlipsView), + ScreenshotConfig(tools_views.ToolsCalcFinalWordShowFinalWordView, screenshot_name="ToolsCalcFinalWordShowFinalWordView_pick_word"), + ScreenshotConfig(tools_views.ToolsCalcFinalWordShowFinalWordView, dict(coin_flips="0010101"), screenshot_name="ToolsCalcFinalWordShowFinalWordView_coin_flips"), + ScreenshotConfig(tools_views.ToolsCalcFinalWordDoneView), + ScreenshotConfig(tools_views.ToolsAddressExplorerSelectSourceView), + ScreenshotConfig(tools_views.ToolsAddressExplorerAddressTypeView), + ScreenshotConfig(tools_views.ToolsAddressExplorerAddressListView), + # ScreenshotConfig(tools_views.ToolsAddressExplorerAddressView), + ], + "Settings Views": settings_views_list + [ + ScreenshotConfig(settings_views.IOTestView), + ScreenshotConfig(settings_views.DonateView), + ScreenshotConfig(settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_persistent), screenshot_name="SettingsIngestSettingsQRView_persistent"), + ScreenshotConfig(settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_not_persistent), screenshot_name="SettingsIngestSettingsQRView_not_persistent"), + ], + "Misc Error Views": [ + ScreenshotConfig(NotYetImplementedView), + ScreenshotConfig(UnhandledExceptionView, dict(error=["IndexError", "line 1, in some_buggy_code.py", "list index out of range"])), + ScreenshotConfig(NetworkMismatchErrorView, dict(derivation_path="m/84'/1'/0'")), + ScreenshotConfig(OptionDisabledView, dict(settings_attr=SettingsConstants.SETTING__MESSAGE_SIGNING)), + ScreenshotConfig(ErrorView, dict( + title="Error", + status_headline="Unknown QR Type", + text="QRCode is invalid or is a data format not yet supported.", + button_text="Back", + )), + ] + } + + return screenshot_sections + + + def screencap_view(screenshot_config: ScreenshotConfig): + # Block until we have exclusive access to the screenshot renderer. Without this + # we were occasionally running into confusing race conditions where the next + # screenshot would begin rendering over the previous one. Claiming the lock + # guarantees that the previous screenshot has been fully rendered and saved. + with screenshot_renderer.lock: + screenshot_renderer.set_screenshot_filename(f"{screenshot_config.screenshot_name}.png") + + controller = Controller.get_instance() + toast_thread = screenshot_config.toast_thread try: - print(f"Running {view_name}") + print(f"Running {screenshot_config.screenshot_name}") try: - view_cls(**view_args).run() + screenshot_config.run_callback_before() + screenshot_config.View_cls(**screenshot_config.view_kwargs).run() except ScreenshotComplete: + # The target View has run and its Screen has rendered what it needs to if toast_thread is not None: + # Now run the Toast so it can render on top of the current image buffer controller.activate_toast(toast_thread) while controller.toast_notification_thread.is_alive(): - time.sleep(0.1) + # Give the Toast a moment to complete its work + + time.sleep(0.01) + + # TODO: Necessary now that the lock is in place? + # Whenever possible, clean up toast thread HERE before killing the + # main thread with ScreenshotComplete. + toast_thread.stop() + toast_thread.join() raise ScreenshotComplete() except ScreenshotComplete: # Slightly hacky way to exit ScreenshotRenderer as expected - pass - print(f"Completed {view_name}") + print(f"Completed {screenshot_config.screenshot_name}") except Exception as e: # Something else went wrong from traceback import print_exc print_exc() raise e finally: - if toast_thread: + if toast_thread and toast_thread.is_alive(): toast_thread.stop() + toast_thread.join() + + screenshot_config.run_callback_after() + + + # Parse the main `l10n/messages.pot` for overall stats + messages_source_path = os.path.join(pathlib.Path(__file__).parent.resolve().parent.resolve().parent.resolve(), "l10n", "messages.pot") + with open(messages_source_path, 'r') as messages_source_file: + num_source_messages = messages_source_file.read().count("msgid \"") - 1 + + locale_tuple_list = [locale_tuple for locale_tuple in SettingsConstants.ALL_LOCALES if locale_tuple[0] == locale] + if not locale_tuple_list: + raise Exception(f"Invalid locale: {locale}") + + locale, display_name = locale_tuple_list[0] + + Settings.get_instance().set_value(SettingsConstants.SETTING__LOCALE, value=locale) - for section_name, screenshot_list in screenshot_sections.items(): + locale_readme = f"""# SeedSigner Screenshots: {display_name}\n""" + + # Report the translation progress + if locale != SettingsConstants.LOCALE__ENGLISH: + try: + translated_messages_path = os.path.join(pathlib.Path(__file__).parent.resolve().parent.resolve().parent.resolve(), "src", "seedsigner", "resources", "seedsigner-translations", "l10n", locale, "LC_MESSAGES", "messages.po") + with open(translated_messages_path, 'r') as translation_file: + locale_translations = translation_file.read() + num_locale_translations = locale_translations.count("msgid \"") - locale_translations.count("""msgstr ""\n\n""") - 1 + + if locale != "en": + locale_readme += f"## Translation progress: {num_locale_translations / num_source_messages:.1%}\n\n" + locale_readme += "---\n\n" + except Exception as e: + from traceback import print_exc + print_exc() + + for section_name, screenshot_list in setup_screenshots(locale).items(): subdir = section_name.lower().replace(" ", "_") - screenshot_renderer.set_screenshot_path(os.path.join(screenshot_root, subdir)) - readme += "\n\n---\n\n" - readme += f"## {section_name}\n\n" - readme += """""" - readme += f"""
\n""" - for screenshot in screenshot_list: - if type(screenshot) == tuple: - if len(screenshot) == 2: - view_cls, view_args = screenshot - view_name = view_cls.__name__ - elif len(screenshot) == 3: - view_cls, view_args, view_name = screenshot - elif len(screenshot) == 4: - view_cls, view_args, view_name, toast_thread = screenshot - else: - view_cls = screenshot - view_args = {} - view_name = view_cls.__name__ - toast_thread = None - - screencap_view(view_cls, view_args=view_args, view_name=view_name, toast_thread=toast_thread) - readme += """ """ - readme += f"""""" - readme += """
{view_name}

\n""" - - readme += "
" - - # Re-render some screens that require more manual intervention / setup than the above - # scripting can support. - screenshot_renderer.set_screenshot_path(os.path.join(screenshot_root, "psbt_views")) - - # Render the PSBTChangeDetailsView_multisig_unverified screenshot - decoder = DecodeQR() - decoder.add_data(BASE64_PSBT_1) - controller.psbt = decoder.get_psbt() - controller.psbt_seed = seed_12b - controller.multisig_wallet_descriptor = None - screencap_view(psbt_views.PSBTChangeDetailsView, view_name='PSBTChangeDetailsView_multisig_unverified', view_args=dict(change_address_num=0)) - - controller.psbt_seed = None - screencap_view(psbt_views.PSBTSelectSeedView, view_name='PSBTSelectSeedView') - - # Render OP_RETURN screens for real - controller.psbt_seed = seed_12b - decoder = DecodeQR() - decoder.add_data(BASE64_PSBT_WITH_OP_RETURN_TEXT) - controller.psbt = decoder.get_psbt() - controller.psbt_parser = PSBTParser(p=controller.psbt, seed=seed_12b) - screencap_view(psbt_views.PSBTOverviewView, view_name='PSBTOverviewView_op_return') - screencap_view(psbt_views.PSBTOpReturnView, view_name="PSBTOpReturnView_text") - - decoder.add_data(BASE64_PSBT_WITH_OP_RETURN_RAW_BYTES) - controller.psbt = decoder.get_psbt() - controller.psbt_parser = PSBTParser(p=controller.psbt, seed=seed_12b) - screencap_view(psbt_views.PSBTOpReturnView, view_name="PSBTOpReturnView_raw_hex_data") + screenshot_renderer.set_screenshot_path(os.path.join(screenshot_root, locale, subdir)) + locale_readme += "\n\n---\n\n" + locale_readme += f"## {section_name}\n\n" + locale_readme += """""" + locale_readme += f"""
""" + for screenshot_config in screenshot_list: + screencap_view(screenshot_config) + locale_readme += """ """ + locale_readme += f"""""" + locale_readme += """
{screenshot_config.screenshot_name}

\n""" + + locale_readme += "
" + + with open(os.path.join(screenshot_root, locale, "README.md"), 'w') as readme_file: + readme_file.write(locale_readme) + + print(f"Done with locale: {locale}.") + + # Write the main README; ensure it writes all locales, not just the one that may + # have been specified for this run. + with open(os.path.join("tests", "screenshot_generator", "template.md"), 'r') as readme_template: + main_readme = readme_template.read() + + for locale, display_name in SettingsConstants.ALL_LOCALES: + main_readme += f"* [{display_name}]({locale}/README.md)\n" with open(os.path.join(screenshot_root, "README.md"), 'w') as readme_file: - readme_file.write(readme) + readme_file.write(main_readme) diff --git a/tests/screenshot_generator/template.md b/tests/screenshot_generator/template.md new file mode 100644 index 000000000..ac0f6e238 --- /dev/null +++ b/tests/screenshot_generator/template.md @@ -0,0 +1,16 @@ +# SeedSigner Screenshots + +SeedSigner screenshots can be freely used in any tutorial, article, video, etc. As a courtesy, please link back to this repo or the SeedSigner website in your attribution. + +![](en/main_menu_views/MainMenuView.png) ![](en/psbt_views/PSBTOverviewView.png) + +![](en/seed_views/SeedOptionsView.png) ![](en/tools_views/ToolsMenuView.png) + + +## Generating screenshots +The screenshot generator is integrated into the SeedSigner test suite and requires a local copy of the SeedSigner repo with the test suite dependencies installed. + +see: https://github.com/SeedSigner/seedsigner/blob/dev/tests/screenshot_generator/README.md + + +## Currently supported or in-progress languages diff --git a/tests/screenshot_generator/utils.py b/tests/screenshot_generator/utils.py index 703bbf61a..d767bdf2a 100644 --- a/tests/screenshot_generator/utils.py +++ b/tests/screenshot_generator/utils.py @@ -1,6 +1,11 @@ import os + +from dataclasses import dataclass from PIL import Image, ImageDraw + from seedsigner.gui.renderer import Renderer +from seedsigner.gui.toast import BaseToastOverlayManagerThread +from seedsigner.views.view import View @@ -37,7 +42,7 @@ def set_screenshot_path(self, path): self.screenshot_path = path - def show_image(self, image=None, alpha_overlay=None, is_background_thread: bool = False): + def show_image(self, image=None, alpha_overlay=None, is_background_thread: bool = False): if is_background_thread: return @@ -53,3 +58,30 @@ def show_image(self, image=None, alpha_overlay=None, is_background_thread: bool self.canvas.save(os.path.join(self.screenshot_path, self.screenshot_filename)) raise ScreenshotComplete() + + +@dataclass +class ScreenshotConfig: + View_cls: View + view_kwargs: dict = None + screenshot_name: str = None + toast_thread: BaseToastOverlayManagerThread = None + run_before: callable = None + run_after: callable = None + + + def __post_init__(self): + if not self.view_kwargs: + self.view_kwargs = {} + if not self.screenshot_name: + self.screenshot_name = self.View_cls.__name__ + + + def run_callback_before(self): + if self.run_before: + self.run_before() + + + def run_callback_after(self): + if self.run_after: + self.run_after() diff --git a/tests/test_bip85.py b/tests/test_bip85.py index 737be5697..795d9c380 100644 --- a/tests/test_bip85.py +++ b/tests/test_bip85.py @@ -1,10 +1,4 @@ -import pytest -from unittest.mock import MagicMock from seedsigner.models.seed import Seed -from embit import bip39 - -from seedsigner.models.settings import SettingsConstants - def test_derive_child_mnemonic(): diff --git a/tests/test_controller.py b/tests/test_controller.py index 375ae9ff3..d01a54cf9 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -88,7 +88,7 @@ def test_missing_settings_get_defaults(self): controller = Controller.get_instance() # Settings defaults - assert controller.settings.get_value(SettingsConstants.SETTING__LANGUAGE) == SettingsConstants.LANGUAGE__ENGLISH + assert controller.settings.get_value(SettingsConstants.SETTING__LOCALE) == SettingsConstants.LOCALE__ENGLISH assert controller.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE) == SettingsConstants.WORDLIST_LANGUAGE__ENGLISH assert controller.settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__DISABLED assert controller.settings.get_value(SettingsConstants.SETTING__COORDINATORS) == [i for i,j in SettingsConstants.ALL_COORDINATORS if i!="kpr"] diff --git a/tests/test_encodepsbtqr.py b/tests/test_encodepsbtqr.py index 14feb1e03..2d864c12a 100644 --- a/tests/test_encodepsbtqr.py +++ b/tests/test_encodepsbtqr.py @@ -68,12 +68,26 @@ def test_ur_xpub_qr(): e = UrXpubQrEncoder( seed=Seed(mnemonic.split(), passphrase="pass"), - network=SettingsConstants.TESTNET, + network=SettingsConstants.MAINNET, derivation="m/48h/1h/0h/2h", qr_density=SettingsConstants.DENSITY__MEDIUM ) - + assert e.next_part() == "UR:CRYPTO-ACCOUNT/1-4/LPADAACSKPCYMOMNLGRYHDCKOEADCYSSMECPONAOLYTAADMETAADDLOXAXHDCLAOKSRLNLKPUEGYATHPMNSNIYMUECBY" assert e.next_part() == "UR:CRYPTO-ACCOUNT/2-4/LPAOAACSKPCYMOMNLGRYHDCKKKGHZMLUZORPVDGUOTECSTTKTOLPCWPTNTLKZTTIZTBEAAHDCXVDTPMYRSTDMOPSCXFZ" assert e.next_part() == "UR:CRYPTO-ACCOUNT/3-4/LPAXAACSKPCYMOMNLGRYHDCKSPZSBZSPGERLGDATUYNLPYBTGYIYYKBTWTAOSWKSVTSGCHBYDKYAVDAMTAADMONDGDFD" - assert e.next_part() == "UR:CRYPTO-ACCOUNT/4-4/LPAAAACSKPCYMOMNLGRYHDCKDYOTADLOCSDYYKADYKAEYKAOYKAOCYSSMECPONAXAAAYCYIOREKKJKAEAEAEWZWDMYON" + assert e.next_part() == "UR:CRYPTO-ACCOUNT/4-4/LPAAAACSKPCYMOMNLGRYHDCKDYOTADLOCSDYYKADYKAEYKAOYKAOCYSSMECPONAXAAAYCYIOREKKJKAEAEAEWZWDMYON" + + + e = UrXpubQrEncoder( + seed=Seed(mnemonic.split(), passphrase="pass"), + network=SettingsConstants.TESTNET, + derivation="m/48h/1h/0h/2h", + qr_density=SettingsConstants.DENSITY__MEDIUM + ) + + assert e.next_part() == "UR:CRYPTO-ACCOUNT/1-5/LPADAHCSKECYRTPEDKMOHDCFOEADCYSSMECPONAOLYTAADMETAADDLONAXHDCLAOKSRLNLKPUENSAHBTHS" + assert e.next_part() == "UR:CRYPTO-ACCOUNT/2-5/LPAOAHCSKECYRTPEDKMOHDCFGYATHPMNSNKKGHZMLUZORPVDGUOTECSTTKTOLPCWPTNTLKZTTIZTNDJSCF" + assert e.next_part() == "UR:CRYPTO-ACCOUNT/3-5/LPAXAHCSKECYRTPEDKMOHDCFZTBEAAHDCXVDTPMYRSTDSPZSBZSPGERLGDATUYNLPYBTGYIYYKBDFGWPKE" + assert e.next_part() == "UR:CRYPTO-ACCOUNT/4-5/LPAAAHCSKECYRTPEDKMOHDCFBTWTAOSWKSVTSGCHBYDKYAVDAHTAADEHOYAOADAMTAADDYOTADGYBKBWFE" + assert e.next_part() == "UR:CRYPTO-ACCOUNT/5-5/LPAHAHCSKECYRTPEDKMOHDCFLOCSDYYKADYKAEYKAOYKAOCYSSMECPONAXAAAYCYIOREKKJKAETODLFYWP" diff --git a/tests/test_flows_l10n.py b/tests/test_flows_l10n.py new file mode 100644 index 000000000..8ebe8aee4 --- /dev/null +++ b/tests/test_flows_l10n.py @@ -0,0 +1,28 @@ +from gettext import gettext as _ + +# Must import test base before the Controller +from base import FlowTest, FlowStep + +from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, ButtonOption +from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition +from seedsigner.views import settings_views +from seedsigner.views.view import MainMenuView + + + +class TestL10nFlows(FlowTest): + def test_change_locale(self): + settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__LOCALE) + spanish_display_name = [locale_tuple[1] for locale_tuple in SettingsConstants.ALL_LOCALES if locale_tuple[0] == SettingsConstants.LOCALE__SPANISH][0] + + # Initially we get English + assert _(MainMenuView.SCAN.button_label) == "Scan" + + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS), + FlowStep(settings_views.SettingsMenuView, button_data_selection=ButtonOption(settings_entry.display_name)), + FlowStep(settings_views.SettingsEntryUpdateSelectionView, button_data_selection=ButtonOption(spanish_display_name)), + ]) + + # Now we don't get English + assert _(MainMenuView.SCAN.button_label) != "Scan" diff --git a/tests/test_flows_psbt.py b/tests/test_flows_psbt.py index 964b0d67b..b86618005 100644 --- a/tests/test_flows_psbt.py +++ b/tests/test_flows_psbt.py @@ -115,7 +115,7 @@ def load_seed_into_decoder(view: scan_views.ScanView): FlowStep(psbt_views.PSBTSigningErrorView, button_data_selection=psbt_views.PSBTSigningErrorView.SELECT_DIFF_SEED), FlowStep(psbt_views.PSBTSelectSeedView, button_data_selection=psbt_views.PSBTSelectSeedView.SCAN_SEED), FlowStep(scan_views.ScanSeedQRView, before_run=load_seed_into_decoder), - FlowStep(seed_views.SeedFinalizeView, button_data_selection=SettingsConstants.LABEL__BIP39_PASSPHRASE), + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.PASSPHRASE), FlowStep(seed_views.SeedAddPassphraseView, screen_return_value=dict(passphrase="abc")), FlowStep(seed_views.SeedReviewPassphraseView, button_data_selection=seed_views.SeedReviewPassphraseView.DONE), FlowStep(seed_views.SeedOptionsView, is_redirect=True), diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index f6ef501d4..490e9f15b 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -5,7 +5,7 @@ from base import BaseTest, FlowTest, FlowStep from base import FlowTestInvalidButtonDataSelectionException -from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON +from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, ButtonOption from seedsigner.models.settings import Settings, SettingsConstants from seedsigner.models.seed import ElectrumSeed, Seed from seedsigner.views.view import ErrorView, MainMenuView, OptionDisabledView, View, NetworkMismatchErrorView @@ -40,10 +40,10 @@ def test_passphrase_entry_flow(self): self.run_sequence([ FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN), FlowStep(scan_views.ScanView, before_run=load_seed_into_decoder), # simulate read SeedQR; ret val is ignored - FlowStep(seed_views.SeedFinalizeView, button_data_selection=SettingsConstants.LABEL__BIP39_PASSPHRASE), + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.PASSPHRASE), FlowStep(seed_views.SeedAddPassphraseView, screen_return_value=dict(passphrase="muhpassphrase", is_back_button=True)), FlowStep(seed_views.SeedAddPassphraseExitDialogView, button_data_selection=seed_views.SeedAddPassphraseExitDialogView.DISCARD), - FlowStep(seed_views.SeedFinalizeView, button_data_selection=SettingsConstants.LABEL__BIP39_PASSPHRASE), + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.PASSPHRASE), FlowStep(seed_views.SeedAddPassphraseView, screen_return_value=dict(passphrase="muhpassphrase", is_back_button=True)), FlowStep(seed_views.SeedAddPassphraseExitDialogView, button_data_selection=seed_views.SeedAddPassphraseExitDialogView.EDIT), FlowStep(seed_views.SeedAddPassphraseView, screen_return_value=dict(passphrase="muhpassphrase")), @@ -183,15 +183,18 @@ def test_export_xpub_standard_flow(self): """ Selecting "Export XPUB" from the SeedOptionsView should enter the Export XPUB flow and end at the MainMenuView """ - def flowtest_standard_xpub(sig_tuple, script_tuple, coord_tuple): + if sig_tuple[0] == SettingsConstants.SINGLE_SIG: + sig_selection = seed_views.SeedExportXpubSigTypeView.SINGLE_SIG + else: + sig_selection = seed_views.SeedExportXpubSigTypeView.MULTISIG self.run_sequence( initial_destination_view_args=dict(seed_num=0), sequence=[ FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), - FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=sig_tuple[1]), - FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=script_tuple[1]), - FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=coord_tuple[1]), + FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=sig_selection), + FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=ButtonOption(script_tuple[1], return_data=script_tuple[0])), + FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=ButtonOption(coord_tuple[1], return_data=coord_tuple[0])), FlowStep(seed_views.SeedExportXpubWarningView, screen_return_value=0), FlowStep(seed_views.SeedExportXpubDetailsView, screen_return_value=0), FlowStep(seed_views.SeedExportXpubQRDisplayView, screen_return_value=0), @@ -303,10 +306,18 @@ def test_export_xpub_custom_derivation_flow(self): SettingsConstants.CUSTOM_DERIVATION ]) - # get display names to access button choices in the views (ugh: hardcoding, is there a better way?) - sig_type = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SIG_TYPES)[0] # single sig - script_type = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SCRIPT_TYPES)[2] # custom derivation - coordinator = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS)[3] # specter + # Ensure that all coordinators are enabled + self.settings.set_value(SettingsConstants.SETTING__COORDINATORS, [x for x, y in SettingsConstants.ALL_COORDINATORS]) + + # Set up button_data selections + sig_type = seed_views.SeedExportXpubSigTypeView.SINGLE_SIG + + custom_derivation = SettingsConstants.CUSTOM_DERIVATION + script_type = ButtonOption(self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SCRIPT_TYPES)[2], return_data=custom_derivation) + + specter = SettingsConstants.COORDINATOR__SPECTER_DESKTOP + assert SettingsConstants.ALL_COORDINATORS[3][0] == specter + coordinator = ButtonOption(self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS)[3], return_data=specter) self.run_sequence( initial_destination_view_args=dict(seed_num=0), @@ -377,7 +388,7 @@ def test_export_xpub_electrum_seed_flow(self): # Skips past the script type options via redirect FlowStep(seed_views.SeedExportXpubScriptTypeView, is_redirect=True), - FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS)[0]), + FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=ButtonOption(self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS)[0], return_data=SettingsConstants.ALL_COORDINATORS[0][0])), FlowStep(seed_views.SeedExportXpubWarningView, screen_return_value=0), FlowStep(seed_views.SeedExportXpubDetailsView, screen_return_value=0), FlowStep(seed_views.SeedExportXpubQRDisplayView, screen_return_value=0), diff --git a/tests/test_flows_settings.py b/tests/test_flows_settings.py index c449f679f..37adf0066 100644 --- a/tests/test_flows_settings.py +++ b/tests/test_flows_settings.py @@ -8,7 +8,7 @@ from seedsigner.models.settings import Settings from seedsigner.models.settings_definition import SettingsDefinition, SettingsConstants -from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON +from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, ButtonOption from seedsigner.hardware.microsd import MicroSD from seedsigner.views.view import MainMenuView from seedsigner.views import scan_views, settings_views @@ -26,8 +26,8 @@ def test_persistent_settings(self): self.run_sequence([ FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS), - FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_entry.display_name), - FlowStep(settings_views.SettingsEntryUpdateSelectionView, button_data_selection=settings_entry.get_selection_option_display_name_by_value(SettingsConstants.OPTION__ENABLED)), + FlowStep(settings_views.SettingsMenuView, button_data_selection=ButtonOption(settings_entry.display_name)), + FlowStep(settings_views.SettingsEntryUpdateSelectionView, button_data_selection=ButtonOption(settings_entry.get_selection_option_display_name_by_value(SettingsConstants.OPTION__ENABLED))), FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=RET_CODE__BACK_BUTTON), FlowStep(settings_views.SettingsMenuView), ]) @@ -43,7 +43,7 @@ def test_multiselect(self): self.run_sequence([ FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS), - FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_entry.display_name), + FlowStep(settings_views.SettingsMenuView, button_data_selection=ButtonOption(settings_entry.display_name)), FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=0), # select/deselect first option FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=1), # select/deselect second option FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=1), # select/deselect second option diff --git a/tests/test_flows_tools.py b/tests/test_flows_tools.py index 588859bea..968b72fc9 100644 --- a/tests/test_flows_tools.py +++ b/tests/test_flows_tools.py @@ -2,7 +2,7 @@ from base import FlowTest, FlowStep from seedsigner.controller import Controller -from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON +from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, ButtonOption from seedsigner.models.seed import Seed from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition from seedsigner.views.view import ErrorView, MainMenuView @@ -25,7 +25,7 @@ def test__address_explorer__flow(self): FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.ADDRESS_EXPLORER), FlowStep(tools_views.ToolsAddressExplorerSelectSourceView, screen_return_value=0), # ret 1st onboard seed - FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__SCRIPT_TYPES).get_selection_option_display_name_by_value(SettingsConstants.NATIVE_SEGWIT)), + FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=ButtonOption(SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__SCRIPT_TYPES).get_selection_option_display_name_by_value(SettingsConstants.NATIVE_SEGWIT), return_data=SettingsConstants.NATIVE_SEGWIT)), FlowStep(tools_views.ToolsAddressExplorerAddressTypeView, button_data_selection=tools_views.ToolsAddressExplorerAddressTypeView.RECEIVE), FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=10), # ret NEXT page of addrs FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=4), # ret a specific addr from the list @@ -62,7 +62,7 @@ def load_seed_into_decoder(view: scan_views.ScanView): # Finalize the new seed w/passphrase self.run_sequence( sequence=[ - FlowStep(seed_views.SeedFinalizeView, button_data_selection=SettingsConstants.LABEL__BIP39_PASSPHRASE), + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.PASSPHRASE), FlowStep(seed_views.SeedAddPassphraseView, screen_return_value=dict(passphrase="mypassphrase")), FlowStep(seed_views.SeedReviewPassphraseView, button_data_selection=seed_views.SeedReviewPassphraseView.DONE), FlowStep(seed_views.SeedOptionsView, is_redirect=True), @@ -242,6 +242,31 @@ def load_descriptor_into_decoder(view: scan_views.ScanView): FlowStep(scan_views.ScanWalletDescriptorView, before_run=load_descriptor_into_decoder), # simulate read descriptor QR FlowStep(seed_views.MultisigWalletDescriptorView, screen_return_value=0), FlowStep(seed_views.SeedAddressVerificationView), - FlowStep(seed_views.AddressVerificationSuccessView), + FlowStep(seed_views.SeedAddressVerificationSuccessView), ]) + + def test__verify_address__singlesig__flow(self): + """ + Address Explorer should be able to scan a singlesig address and + verify it against a loaded key. + """ + controller = Controller.get_instance() + controller.storage.set_pending_seed(Seed(mnemonic=["abandon "* 11 + "about"])) + controller.storage.finalize_pending_seed() + settings = controller.settings + settings.set_value(SettingsConstants.SETTING__NETWORK, SettingsConstants.REGTEST) + + def load_address_into_decoder(view: scan_views.ScanView): + # Native segwit regtest receive addr @ index 6 + view.decoder.add_data("bcrt1q4e9q5taxnsvc6m0uxv6h75mkzvnkxeqk6l90u2") + + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.VERIFY_ADDRESS), + FlowStep(scan_views.ScanAddressView, before_run=load_address_into_decoder), # simulate read address QR + FlowStep(seed_views.AddressVerificationStartView, is_redirect=True), + FlowStep(seed_views.SeedSelectSeedView, screen_return_value=0), + FlowStep(seed_views.SeedAddressVerificationView), + FlowStep(seed_views.SeedAddressVerificationSuccessView), + ]) diff --git a/tests/test_flows_view.py b/tests/test_flows_view.py index 08aa8a418..05a744296 100644 --- a/tests/test_flows_view.py +++ b/tests/test_flows_view.py @@ -28,14 +28,6 @@ def test_power_off_flow(self): """ Basic flow from MainMenuView to PowerOffView """ - with patch('seedsigner.views.view.PowerOffView.PowerOffThread'): - self.run_sequence([ - FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON), - FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.POWER_OFF), - FlowStep(PowerOffView), - ]) - - # And again, but this time as if we were in the SeedSigner OS Settings.HOSTNAME = Settings.SEEDSIGNER_OS self.run_sequence([ FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON), diff --git a/tests/test_l10n.py b/tests/test_l10n.py new file mode 100644 index 000000000..a23582788 --- /dev/null +++ b/tests/test_l10n.py @@ -0,0 +1,104 @@ +from gettext import gettext as _ + +from base import BaseTest +from seedsigner.gui.screens.screen import ButtonOption +from seedsigner.helpers.l10n import mark_for_translation as _mft +from seedsigner.models.settings import Settings +from seedsigner.models.settings_definition import SettingsConstants +from seedsigner.views.view import MainMenuView + + + +class TestGettext(BaseTest): + def test_english_as_default(self): + # Key is available in other languages, but we get English back + assert _("Home") == "Home" + + def test_missing_key_returns_english_key(self): + test_str = "This is not in our translation library" + assert _(test_str) == test_str + + + def test_basic_spanish(self): + settings = Settings.get_instance() + settings.set_value(SettingsConstants.SETTING__LOCALE, SettingsConstants.LOCALE__SPANISH) + assert _("Home") != "Home" + + + def test_locale_changes(self): + settings = Settings.get_instance() + + settings.set_value(SettingsConstants.SETTING__LOCALE, SettingsConstants.LOCALE__SPANISH) + spanish_str = _("Home") + + settings.set_value(SettingsConstants.SETTING__LOCALE, SettingsConstants.LOCALE__ENGLISH) + assert spanish_str != _("Home") + assert _("Home") == "Home" + + + +class TestButtonOption(BaseTest): + def test_english_key_not_translated(self): + """ ButtonOption should always return its English key, regardless of current locale setting. """ + button_option = ButtonOption("Home") + settings = Settings.get_instance() + settings.set_value(SettingsConstants.SETTING__LOCALE, SettingsConstants.LOCALE__SPANISH) + + assert button_option.button_label == "Home" + + brand_new_button_option = ButtonOption("Tools") + assert brand_new_button_option.button_label == "Tools" + + + def test_class_level_button_option_english_key_not_translated(self): + settings = Settings.get_instance() + settings.set_value(SettingsConstants.SETTING__LOCALE, SettingsConstants.LOCALE__SPANISH) + + class FooClass: + HOME = ButtonOption("Home") + + assert FooClass.HOME.button_label == "Home" + + + def test_gettext_translates_class_level_button_option(self): + settings = Settings.get_instance() + settings.set_value(SettingsConstants.SETTING__LOCALE, SettingsConstants.LOCALE__SPANISH) + + class BarClass: + HOME = ButtonOption("Home") + + assert _(BarClass.HOME.button_label) != "Home" + + + +class TestMarkForTranslation(BaseTest): + def test_english_key_not_translated(self): + """ _mft() should always return its English key, regardless of current locale setting. """ + mft_attr = _mft("Home") + settings = Settings.get_instance() + settings.set_value(SettingsConstants.SETTING__LOCALE, SettingsConstants.LOCALE__SPANISH) + + assert mft_attr == "Home" + + brand_new_mft_attr = _mft("Tools") + assert brand_new_mft_attr == "Tools" + + + def test_class_level_mft_attr_english_key_not_translated(self): + settings = Settings.get_instance() + settings.set_value(SettingsConstants.SETTING__LOCALE, SettingsConstants.LOCALE__SPANISH) + + class FooClass: + home = _mft("Home") + + assert FooClass.home == "Home" + + + def test_gettext_translates_class_level_mft_attr(self): + settings = Settings.get_instance() + settings.set_value(SettingsConstants.SETTING__LOCALE, SettingsConstants.LOCALE__SPANISH) + + class BarClass: + home = _mft("Home") + + assert _(BarClass.home) != "Home"