diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 294e20be..9fba7af5 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 f2fbb160..9c70709f 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 00000000..d4ffa6ec --- /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 = dev +[submodule "seedsigner-screenshots"] + path = seedsigner-screenshots + url = https://github.com/SeedSigner/seedsigner-screenshots.git + branch = dev + update = none diff --git a/l10n/README.md b/l10n/README.md new file mode 100644 index 00000000..561bb49a --- /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 00000000..cdbd2e77 --- /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 00000000..b83c7a02 --- /dev/null +++ b/l10n/requirements-l10n.txt @@ -0,0 +1 @@ +Babel==2.16.0 diff --git a/pyproject.toml b/pyproject.toml index 25345e0d..7f34e326 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" [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 00000000..23cf46e1 --- /dev/null +++ b/seedsigner-screenshots @@ -0,0 +1 @@ +Subproject commit 23cf46e1482b49ed429b9ff31d29457af3ea07be diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..d2b32822 --- /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 00000000..1dff9efb --- /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 5ac33d3a..7505408c 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" # Declare class member vars with type hints to enable richer IDE support throughout # the code. @@ -147,20 +147,20 @@ class Controller(Singleton): else: # Instantiate the one and only Controller instance return cls.configure_instance() + + + @classmethod + def reset_instance(cls): + """ + Currently used by the screenshot generator, but could potentially be used to + wipe and reset the state of the device. + """ + cls._instance = None + cls.configure_instance() @classmethod - def configure_instance(cls, disable_hardware=False): - """ - - `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: - - RuntimeError: Conflicting edge detection already enabled for this GPIO channel - - each time you try to re-initialize a Controller. - """ + def configure_instance(cls): from seedsigner.gui.renderer import Renderer from seedsigner.hardware.microsd import MicroSD @@ -248,10 +248,10 @@ class Controller(Singleton): 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 9193e451..00edb498 100644 --- a/src/seedsigner/gui/components.py +++ b/src/seedsigner/gui/components.py @@ -3,20 +3,24 @@ import math 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 @@ class Fonts(Singleton): 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 @@ class BaseComponent: 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 @@ class TextArea(BaseComponent): 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 @@ class TextArea(BaseComponent): # 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 @@ class TextArea(BaseComponent): 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 @@ class TextArea(BaseComponent): 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 @@ class Icon(BaseComponent): 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 @@ class IconTextLine(BaseComponent): 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 @@ class BtcAmount(BaseComponent): 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 @@ class BtcAmount(BaseComponent): 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 @@ class BtcAmount(BaseComponent): 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 @@ class BtcAmount(BaseComponent): 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 @@ class BtcAmount(BaseComponent): 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 @@ class BtcAmount(BaseComponent): @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 @@ class Button(BaseComponent): 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 @@ class Button(BaseComponent): # 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 @@ class Button(BaseComponent): 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 @@ class Button(BaseComponent): ) 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 @@ class TopNav(BaseComponent): 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 @@ class TopNav(BaseComponent): 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 @@ class TopNav(BaseComponent): 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 reflow_text_for_width(text: str, (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 reflow_text_for_width(text: str, 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 96f83441..b92b062b 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 @@ from seedsigner.hardware.buttons import HardwareButtonsConstants 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,39 +32,43 @@ 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": 2, } + # 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, } @@ -158,7 +167,7 @@ class Keyboard: 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" @@ -208,6 +217,7 @@ class Keyboard: # 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 9534a774..db0a4eb5 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 1a6b6506..c64855c6 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 @@ class PSBTOverviewScreen(ButtonListScreen): 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 @@ class PSBTOverviewScreen(ButtonListScreen): # 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 @@ class PSBTOverviewScreen(ButtonListScreen): # 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 @@ class PSBTOverviewScreen(ButtonListScreen): 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 @@ class PSBTOverviewScreen(ButtonListScreen): # 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 @@ class PSBTOverviewScreen(ButtonListScreen): 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 @@ class PSBTMathScreen(ButtonListScreen): # 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 @@ class PSBTMathScreen(ButtonListScreen): 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 @@ class PSBTMathScreen(ButtonListScreen): # 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 @@ class PSBTMathScreen(ButtonListScreen): 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 @@ class PSBTMathScreen(ButtonListScreen): 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 @@ class PSBTMathScreen(ButtonListScreen): 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 @@ class PSBTAddressDetailsScreen(ButtonListScreen): @dataclass class PSBTChangeDetailsScreen(ButtonListScreen): - title: str = "Your Change" amount: int = 0 address: str = None is_multisig: bool = False @@ -662,10 +675,20 @@ class PSBTChangeDetailsScreen(ButtonListScreen): )) 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 @@ class PSBTChangeDetailsScreen(ButtonListScreen): 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 @@ class PSBTOpReturnScreen(ButtonListScreen): # 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 @@ class PSBTOpReturnScreen(ButtonListScreen): 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 @@ class PSBTOpReturnScreen(ButtonListScreen): 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 @@ class PSBTOpReturnScreen(ButtonListScreen): 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 @@ class PSBTOpReturnScreen(ButtonListScreen): 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 @@ class PSBTFinalizeScreen(ButtonListScreen): 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 e1af8e15..c030fef4 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 @@ class ScanScreen(BaseScreen): # 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 @@ class ScanScreen(BaseScreen): 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 @@ class ScanScreen(BaseScreen): 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 @@ class ScanScreen(BaseScreen): 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 @@ class ScanScreen(BaseScreen): 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 @@ class ScanScreen(BaseScreen): 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 a77997ab..821424d1 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 @@ class BaseScreen(BaseComponent): # 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 @@ class BaseScreen(BaseComponent): 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 @@ class BaseScreen(BaseComponent): 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. + on its own. Only when the user clicks to make a selection would _run() exit + and return 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 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). - 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 + 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 @@ class LoadingScreenThread(BaseThread): 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 @@ class LoadingScreenThread(BaseThread): 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 @@ class BaseTopNavScreen(BaseScreen): 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 @@ class BaseTopNavScreen(BaseScreen): +@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 @@ class ButtonListScreen(BaseTopNavScreen): 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 @@ class ButtonListScreen(BaseTopNavScreen): 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 @@ class ButtonListScreen(BaseTopNavScreen): 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 @@ class ButtonListScreen(BaseTopNavScreen): @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 @@ class LargeButtonScreen(BaseTopNavScreen): 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 @@ class LargeButtonScreen(BaseTopNavScreen): 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 @@ class QRDisplayScreen(BaseScreen): # 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 @@ class QRDisplayScreen(BaseScreen): 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 @@ class QRDisplayScreen(BaseScreen): ) 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 @@ class QRDisplayScreen(BaseScreen): 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 @@ class QRDisplayScreen(BaseScreen): 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 @@ class QRDisplayScreen(BaseScreen): @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 @@ class LargeIconStatusScreen(ButtonListScreen): 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 @@ class WarningEdgesMixin: @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 @@ class ResetScreen(BaseTopNavScreen): @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 @@ class PowerOffScreen(BaseTopNavScreen): @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 @@ -1053,6 +1101,9 @@ class KeyboardScreen(BaseTopNavScreen): initial_value: str = "" 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: @@ -1132,7 +1183,7 @@ class KeyboardScreen(BaseTopNavScreen): self.text_entry_display.render() self.renderer.show_image() - + def _run(self): self.cursor_position = len(self.user_input) @@ -1144,84 +1195,85 @@ class KeyboardScreen(BaseTopNavScreen): 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: @@ -1229,7 +1281,7 @@ class KeyboardScreen(BaseTopNavScreen): 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 64fb7ea9..6856f578 100644 --- a/src/seedsigner/gui/screens/seed_screens.py +++ b/src/seedsigner/gui/screens/seed_screens.py @@ -3,6 +3,7 @@ import logging 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.gui.renderer import Renderer 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) @@ -85,11 +86,12 @@ class SeedMnemonicEntryScreen(BaseTopNavScreen): 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 @@ -114,7 +116,7 @@ class SeedMnemonicEntryScreen(BaseTopNavScreen): 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 @@ -147,7 +149,7 @@ class SeedMnemonicEntryScreen(BaseTopNavScreen): """ 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: @@ -195,14 +197,14 @@ class SeedMnemonicEntryScreen(BaseTopNavScreen): 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, @@ -247,183 +249,183 @@ class SeedMnemonicEntryScreen(BaseTopNavScreen): 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 + 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 in [HardwareButtonsConstants.KEY_RIGHT, HardwareButtonsConstants.KEY_LEFT]: + # no action in this context + continue + + 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() - 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 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 input in [HardwareButtonsConstants.KEY_RIGHT, HardwareButtonsConstants.KEY_LEFT]: - # no action in this context - continue + # 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() - ret_val = self.keyboard.update_from_input(input) + 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(" ") - 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() + # 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 - 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(" ") + 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 - # Reactivate keys after deleting last letter + 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() - - # 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(" ") + 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 - # 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) + # 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() + + # Now issue one call to send the pixels to the screen 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 - - # 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() - - # 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 ) @@ -433,7 +435,6 @@ class SeedFinalizeScreen(ButtonListScreen): @dataclass class SeedOptionsScreen(ButtonListScreen): - # Customize defaults fingerprint: str = None has_passphrase: bool = False @@ -448,6 +449,24 @@ class SeedOptionsScreen(ButtonListScreen): +@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 @@ -458,6 +477,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) @@ -468,7 +489,7 @@ class SeedWordsScreen(WarningEdgesMixin, ButtonListScreen): # 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 @@ -478,7 +499,7 @@ class SeedWordsScreen(WarningEdgesMixin, ButtonListScreen): 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 @@ -523,7 +544,7 @@ class SeedWordsScreen(WarningEdgesMixin, ButtonListScreen): 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))) @@ -532,7 +553,7 @@ class SeedWordsScreen(WarningEdgesMixin, ButtonListScreen): @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 @@ -548,13 +569,13 @@ class SeedBIP85SelectChildIndexScreen(KeyboardScreen): @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, )) @@ -564,7 +585,7 @@ class SeedWordsBackupTestPromptScreen(ButtonListScreen): @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 @@ -580,17 +601,16 @@ class SeedExportXpubCustomDerivationScreen(KeyboardScreen): @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__() @@ -599,7 +619,8 @@ class SeedExportXpubDetailsScreen(WarningEdgesMixin, ButtonListScreen): 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, @@ -609,7 +630,8 @@ class SeedExportXpubDetailsScreen(WarningEdgesMixin, ButtonListScreen): 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), @@ -619,10 +641,11 @@ class SeedExportXpubDetailsScreen(WarningEdgesMixin, ButtonListScreen): 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), ) @@ -632,9 +655,11 @@ class SeedExportXpubDetailsScreen(WarningEdgesMixin, ButtonListScreen): @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" @@ -643,6 +668,7 @@ class SeedAddPassphraseScreen(BaseTopNavScreen): def __post_init__(self): + self.title = _("BIP-39 Passphrase") super().__post_init__() keys_lower = "abcdefghijklmnopqrstuvwxyz" @@ -791,20 +817,22 @@ class SeedAddPassphraseScreen(BaseTopNavScreen): 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( @@ -813,24 +841,44 @@ class SeedAddPassphraseScreen(BaseTopNavScreen): 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 @@ -845,169 +893,171 @@ class SeedAddPassphraseScreen(BaseTopNavScreen): 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) + 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) + 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() + # 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 + # 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 + + elif input == HardwareButtonsConstants.KEY2: + # First light up key2 + self.hw_button2.is_selected = True + self.hw_button2.render() + self.renderer.show_image() + + # 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() + + # Show the changes; this loop will have two renders + self.renderer.show_image() + + keyboard_swap = True + ret_val = None - 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() + # 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() - # Show the changes; this loop will have two renders - self.renderer.show_image() + # 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 - keyboard_swap = True - ret_val = None + ret_val = cur_keyboard.update_from_input(input) - elif input == HardwareButtonsConstants.KEY2: - # First light up key2 - self.hw_button2.is_selected = True - self.hw_button2.render() - self.renderer.show_image() - - # 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() - - # Show the changes; this loop will have two renders - self.renderer.show_image() - - 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 + # 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 + 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:] - ret_val = cur_keyboard.update_from_input(input) + cursor_position -= 1 - # 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 == Keyboard.KEY_CURSOR_LEFT["code"]: + cursor_position -= 1 + if cursor_position < 0: + cursor_position = 0 - 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:] + elif ret_val == Keyboard.KEY_CURSOR_RIGHT["code"]: + cursor_position += 1 + if cursor_position > len(self.passphrase): + cursor_position = len(self.passphrase) - cursor_position -= 1 + 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 - elif ret_val == Keyboard.KEY_CURSOR_LEFT["code"]: - cursor_position -= 1 - if cursor_position < 0: - cursor_position = 0 + # Update the text entry display and cursor + self.text_entry_display.render(self.passphrase, cursor_position) - 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 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 + 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() - # 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() - - self.renderer.show_image() + self.renderer.show_image() + @@ -1019,7 +1069,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__() @@ -1027,17 +1077,18 @@ class SeedReviewPassphraseScreen(ButtonListScreen): 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] @@ -1070,6 +1121,7 @@ class SeedReviewPassphraseScreen(ButtonListScreen): 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 @@ -1085,16 +1137,20 @@ class SeedTranscribeSeedQRFormatScreen(ButtonListScreen): 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, @@ -1108,8 +1164,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__() @@ -1134,6 +1192,8 @@ class SeedTranscribeSeedQRWholeQRScreen(WarningEdgesMixin, ButtonListScreen): 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__() @@ -1186,39 +1246,31 @@ class SeedTranscribeSeedQRZoomedInScreen(BaseScreen): 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"] @@ -1228,8 +1280,8 @@ class SeedTranscribeSeedQRZoomedInScreen(BaseScreen): 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 @@ -1241,7 +1293,7 @@ class SeedTranscribeSeedQRZoomedInScreen(BaseScreen): 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( @@ -1255,63 +1307,66 @@ class SeedTranscribeSeedQRZoomedInScreen(BaseScreen): 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) + block_labels = self.draw_block_labels() - 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 + 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 @@ -1322,7 +1377,7 @@ class SeedTranscribeSeedQRConfirmQRPromptScreen(ButtonListScreen): 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, )) @@ -1379,10 +1434,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__() @@ -1443,9 +1497,10 @@ class SeedAddressVerificationScreen(ButtonListScreen): 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 ) @@ -1460,12 +1515,12 @@ class SeedAddressVerificationScreen(ButtonListScreen): @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, )) @@ -1478,27 +1533,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, )) @@ -1530,12 +1585,12 @@ class SeedSignMessageConfirmMessageScreen(ButtonListScreen): 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( @@ -1554,16 +1609,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 55fa5dd0..082ad497 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 @@ class SettingsEntryUpdateSelectionScreen(ButtonListScreen): 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 @@ class SettingsEntryUpdateSelectionScreen(ButtonListScreen): @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 @@ class IOTestScreen(BaseTopNavScreen): 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 @@ class IOTestScreen(BaseTopNavScreen): 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 @@ class IOTestScreen(BaseTopNavScreen): 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 @@ class IOTestScreen(BaseTopNavScreen): 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 @@ class IOTestScreen(BaseTopNavScreen): 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 @@ class IOTestScreen(BaseTopNavScreen): ) 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 @@ class IOTestScreen(BaseTopNavScreen): @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 @@ class DonateScreen(BaseTopNavScreen): @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 @@ class SettingsQRConfirmationScreen(ButtonListScreen): 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 13fa33be..5b40f442 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 @@ -15,10 +16,6 @@ from seedsigner.models.settings_definition import SettingsConstants, SettingsDef @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() @@ -29,10 +26,9 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen): # 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() @@ -53,6 +49,27 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen): 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( @@ -60,8 +77,8 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen): 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, @@ -69,25 +86,6 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen): ) self.renderer.show_image() - return preview_images - - # If we're still here, it's just another preview frame loop - self.renderer.canvas.paste(frame) - - self.renderer.draw.text( - xy=( - int(self.renderer.canvas_width/2), - self.renderer.canvas_height - GUIConstants.EDGE_PADDING - ), - text="< back | click joystick", - fill=GUIConstants.BODY_FONT_COLOR, - font=instructions_font, - stroke_width=4, - stroke_fill=GUIConstants.BACKGROUND_COLOR, - anchor="ms" - ) - 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. @@ -101,22 +99,29 @@ class ToolsImageEntropyFinalImageScreen(BaseScreen): final_image: Image = None def _run(self): - 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()) - 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() + 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=" < " + 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: @@ -126,9 +131,10 @@ class ToolsImageEntropyFinalImageScreen(BaseScreen): @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) # Specify the keys in the keyboard self.rows = 3 @@ -159,7 +165,7 @@ class ToolsDiceEntropyEntryScreen(KeyboardScreen): 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 @@ -170,13 +176,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), )) @@ -186,29 +194,33 @@ class ToolsCalcFinalWordFinalizePromptScreen(ButtonListScreen): 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) # 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 @@ -225,7 +237,7 @@ class ToolsCalcFinalWordScreen(ButtonListScreen): 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") @@ -249,8 +261,10 @@ class ToolsCalcFinalWordScreen(ButtonListScreen): # 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 )) @@ -285,7 +299,8 @@ class ToolsCalcFinalWordScreen(ButtonListScreen): # 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, )) @@ -321,7 +336,8 @@ class ToolsCalcFinalWordScreen(ButtonListScreen): # 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 )) @@ -361,15 +377,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, )) @@ -377,7 +398,8 @@ class ToolsCalcFinalWordDoneScreen(ButtonListScreen): 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, @@ -393,7 +415,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__() @@ -401,7 +424,8 @@ class ToolsAddressExplorerAddressTypeScreen(ButtonListScreen): 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, @@ -410,7 +434,8 @@ class ToolsAddressExplorerAddressTypeScreen(ButtonListScreen): 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, @@ -418,7 +443,8 @@ class ToolsAddressExplorerAddressTypeScreen(ButtonListScreen): 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, @@ -426,7 +452,8 @@ class ToolsAddressExplorerAddressTypeScreen(ButtonListScreen): 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 d21ff02c..baefa16b 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 @@ class BaseToastOverlayManagerThread(BaseThread): 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 @@ class SDCardStateChangeToastManagerThread(BaseToastOverlayManagerThread): 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 6fecc10f..f37dfa80 100644 --- a/src/seedsigner/hardware/buttons.py +++ b/src/seedsigner/hardware/buttons.py @@ -65,6 +65,15 @@ class HardwareButtons(Singleton): 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 59ff4101..ab4f700f 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 @@ class Camera(Singleton): 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 @@ class Camera(Singleton): 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 7e260aa4..1754b080 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 00000000..a971da58 --- /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 5377a1a7..75291efe 100644 --- a/src/seedsigner/helpers/qr.py +++ b/src/seedsigner/helpers/qr.py @@ -102,6 +102,6 @@ class QR: # 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/seed.py b/src/seedsigner/models/seed.py index 2218ae6c..82ed9eb2 100644 --- a/src/seedsigner/models/seed.py +++ b/src/seedsigner/models/seed.py @@ -78,6 +78,11 @@ class Seed: 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 ae5b4ffe..3c26d797 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 @@ class Settings(Singleton): 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 @@ class Settings(Singleton): # 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 @@ class Settings(Singleton): 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 @@ class Settings(Singleton): 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 @@ class Settings(Singleton): 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 089a94b2..7a64ae02 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 @@ class SettingsConstants: 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 @@ class SettingsConstants: 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 @@ class SettingsConstants: # 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 @@ class SettingsConstants: 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 @@ class SettingsEntry: 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 e472e08f..9635106b 100644 --- a/src/seedsigner/models/singleton.py +++ b/src/seedsigner/models/singleton.py @@ -21,7 +21,7 @@ class ConfigurableSingleton(Singleton): 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 00000000..3de3aefb --- /dev/null +++ b/src/seedsigner/resources/seedsigner-translations @@ -0,0 +1 @@ +Subproject commit 3de3aefb96779b1cdac6c5a432c6a4ae9b8ef05d diff --git a/src/seedsigner/views/psbt_views.py b/src/seedsigner/views/psbt_views.py index b0a32cd9..636751d1 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 @@ class PSBTSelectSeedView(View): 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 @@ class PSBTSelectSeedView(View): 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 @@ class PSBTOverviewView(View): # 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 @@ class PSBTOverviewView(View): 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 @@ class PSBTOverviewView(View): 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 @@ class PSBTUnsupportedScriptTypeWarningView(View): 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 @@ class PSBTAddressDetailsView(View): 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 @@ class PSBTAddressDetailsView(View): 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 @@ class PSBTChangeDetailsView(View): 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 @@ class PSBTChangeDetailsView(View): 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 @@ class PSBTChangeDetailsView(View): 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 @@ class PSBTChangeDetailsView(View): 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 @@ class PSBTAddressVerificationFailedView(View): 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 @@ class PSBTOpReturnView(View): 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 @@ class PSBTFinalizeView(View): 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 @@ class PSBTSignedQRDisplayView(View): 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 @@ class PSBTSigningErrorView(View): # 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 1b923efa..aa83c6bd 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 @@ class ScanView(View): # 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 @@ class ScanView(View): 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 @@ class ScanView(View): 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 @@ class ScanView(View): # 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 @@ class ScanView(View): 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 @@ class ScanPSBTView(ScanView): 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 @@ class ScanSeedQRView(ScanView): 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 @@ class ScanWalletDescriptorView(ScanView): 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 84476da4..d96f7f5d 100644 --- a/src/seedsigner/views/screensaver.py +++ b/src/seedsigner/views/screensaver.py @@ -3,16 +3,19 @@ import os 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 @@ class LogoScreen(BaseScreen): 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 @@ class OpeningSplashScreen(LogoScreen): 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 9483c111..aeb3d728 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 embit.descriptor import Descriptor -from embit.networks import NETWORKS -from typing import List +from gettext import gettext as _ + +from embit.descriptor import Descriptor -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 @@ from seedsigner.views.view import NotYetImplementedView, OptionDisabledView, Vie 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 @@ class SeedsMenuView(View): 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 @@ class SeedSelectSeedView(View): 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 @@ class SeedSelectSeedView(View): 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 @@ class LoadSeedView(View): 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 @@ class SeedMnemonicEntryView(View): 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 @@ class SeedMnemonicEntryView(View): ) 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 @@ class SeedMnemonicEntryView(View): 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 @@ class SeedMnemonicInvalidView(View): 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 @@ class SeedFinalizeView(View): 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 @@ class SeedAddPassphraseView(View): 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 @@ class SeedAddPassphraseExitDialogView(View): 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 @@ class SeedReviewPassphraseView(View): 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 @@ class SeedDiscardView(View): 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 @@ class SeedElectrumMnemonicStartView(View): 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 @@ class SeedOptionsView(View): def run(self): + from seedsigner.controller import Controller from seedsigner.views.psbt_views import PSBTOverviewView if self.controller.unverified_address: @@ -536,6 +558,7 @@ class SeedOptionsView(View): 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 @@ class SeedOptionsView(View): 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 @@ class SeedOptionsView(View): 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 @@ class SeedBackupView(View): 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 @@ class SeedBackupView(View): 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 @@ class SeedExportXpubSigTypeView(View): # 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 @@ class SeedExportXpubScriptTypeView(View): 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 @@ class SeedExportXpubScriptTypeView(View): 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 @@ class SeedExportXpubScriptTypeView(View): 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 @@ class SeedExportXpubCustomDerivationView(View): 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 @@ class SeedExportXpubCoordinatorView(View): 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 @@ class SeedExportXpubCoordinatorView(View): 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 @@ class SeedExportXpubWarningView(View): 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 @@ class SeedExportXpubDetailsView(View): 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 @@ class SeedExportXpubDetailsView(View): 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"] ) @@ -975,6 +1002,7 @@ class SeedExportXpubQRDisplayView(View): def run(self): + from seedsigner.gui.screens.screen import QRDisplayScreen self.run_screen( QRDisplayScreen, qr_encoder=self.qr_encoder @@ -1010,7 +1038,7 @@ class SeedWordsWarningView(View): 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 +1051,9 @@ class SeedWordsWarningView(View): 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 +1066,24 @@ class SeedWordsView(View): 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 +1096,7 @@ class SeedWordsView(View): 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 +1108,7 @@ class SeedWordsView(View): 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 +1128,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 +1162,8 @@ class SeedBIP85ApplicationModeView(View): -# 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 +1171,7 @@ class SeedBIP85SelectChildIndexView(View): 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 +1205,11 @@ class SeedBIP85InvalidChildIndexView(View): 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 +1227,9 @@ class SeedBIP85InvalidChildIndexView(View): 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 +1237,18 @@ class SeedWordsBackupTestPromptView(View): 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 +1257,11 @@ class SeedWordsBackupTestPromptView(View): 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 +1280,32 @@ class SeedWordsBackupTestView(View): 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 +1335,7 @@ class SeedWordsBackupTestView(View): 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 +1343,10 @@ class SeedWordsBackupTestView(View): 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 +1356,29 @@ class SeedWordsBackupTestMistakeView(View): 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 +1397,13 @@ class SeedWordsBackupTestSuccessView(View): 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 +1425,11 @@ class SeedTranscribeSeedQRFormatView(View): 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 +1438,7 @@ class SeedTranscribeSeedQRFormatView(View): 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 +1446,7 @@ class SeedTranscribeSeedQRFormatView(View): button_data = [STANDARD, COMPACT] selected_menu_num = seed_screens.SeedTranscribeSeedQRFormatScreen( - title="SeedQR Format", + title=_("SeedQR Format"), button_data=button_data, ).display() @@ -1409,10 +1455,10 @@ class SeedTranscribeSeedQRFormatView(View): 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 +1495,8 @@ class SeedTranscribeSeedQRWarningView(View): 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 +1547,18 @@ class SeedTranscribeSeedQRWholeQRView(View): 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 +1584,8 @@ class SeedTranscribeSeedQRZoomedInView(View): 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 +1593,9 @@ class SeedTranscribeSeedQRZoomedInView(View): 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 +1603,20 @@ class SeedTranscribeSeedQRConfirmQRPromptView(View): 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 +1629,16 @@ class SeedTranscribeSeedQRConfirmScanView(View): 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 +1646,23 @@ class SeedTranscribeSeedQRConfirmScanView(View): # 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 +1670,11 @@ class SeedTranscribeSeedQRConfirmScanView(View): 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 +1695,13 @@ class AddressVerificationStartView(View): 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 +1720,7 @@ class AddressVerificationStartView(View): 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 +1740,17 @@ class AddressVerificationStartView(View): 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 +1794,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 +1805,7 @@ class SeedAddressVerificationView(View): 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 +1851,7 @@ class SeedAddressVerificationView(View): # 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 +1893,17 @@ class SeedAddressVerificationView(View): 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 +1937,7 @@ class SeedAddressVerificationView(View): 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 +1969,7 @@ class SeedAddressVerificationView(View): -class AddressVerificationSuccessView(View): +class SeedAddressVerificationSuccessView(View): def __init__(self, seed_num: int): super().__init__() self.seed_num = seed_num @@ -1910,19 +1978,34 @@ class AddressVerificationSuccessView(View): 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 +2014,11 @@ class AddressVerificationSuccessView(View): 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 +2030,7 @@ class LoadMultisigWalletDescriptorView(View): 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 +2039,10 @@ class LoadMultisigWalletDescriptorView(View): 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 +2053,16 @@ class MultisigWalletDescriptorView(View): 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 +2083,7 @@ class MultisigWalletDescriptorView(View): 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 +2106,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 +2125,7 @@ class SeedSignMessageStartView(View): # 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 +2147,7 @@ class SeedSignMessageStartView(View): # 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 +2157,6 @@ class SeedSignMessageConfirmMessageView(View): 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 +2184,7 @@ class SeedSignMessageConfirmMessageView(View): 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 +2200,7 @@ class SeedSignMessageConfirmAddressView(View): 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 +2208,7 @@ class SeedSignMessageConfirmAddressView(View): 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 +2242,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 +2255,7 @@ class SeedSignMessageSignedMessageQRView(View): 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 e2a742ca..b4fb86a6 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -1,19 +1,20 @@ import logging +from gettext import gettext as _ + from seedsigner.gui.components import SeedSignerIconConstants -from seedsigner.hardware.microsd import MicroSD +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 -from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen, settings_screens) -from seedsigner.models.settings import Settings, SettingsConstants, SettingsDefinition - 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 @@ class SettingsMenuView(View): 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 @@ class SettingsMenuView(View): 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 @@ class SettingsMenuView(View): 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 @@ class SettingsEntryUpdateSelectionView(View): 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 @@ class SettingsEntryUpdateSelectionView(View): 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 @@ class SettingsIngestSettingsQRView(View): 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 eabb1171..a6828fe7 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 @@ from .view import View, Destination, BackStackView 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 @@ class ToolsMenuView(View): ****************************************************************************""" 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 @@ class ToolsImageEntropyLivePreviewView(View): 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 @@ class ToolsImageEntropyFinalImageView(View): ).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 @@ class ToolsImageEntropyFinalImageView(View): 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 @@ class ToolsImageEntropyMnemonicLengthView(View): ****************************************************************************""" 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 @@ class ToolsDiceEntropyEntryView(View): 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 @@ class ToolsDiceEntropyEntryView(View): 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 @@ class ToolsCalcFinalWordNumWordsView(View): 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)) - - 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)) + 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") + 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 @@ class ToolsCalcFinalWordFinalizePromptView(View): 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 @@ class ToolsCalcFinalWordFinalizePromptView(View): 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 @@ class ToolsCalcFinalWordFinalizePromptView(View): 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 @@ class ToolsCalcFinalWordCoinFlipsView(View): 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 @@ class ToolsCalcFinalWordShowFinalWordView(View): 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 @@ class ToolsCalcFinalWordShowFinalWordView(View): 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 @@ class ToolsCalcFinalWordDoneView(View): 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 @@ class ToolsAddressExplorerSelectSourceView(View): 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 @@ class ToolsAddressExplorerSelectSourceView(View): 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 @@ class ToolsAddressExplorerAddressTypeView(View): 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 @@ class ToolsAddressExplorerAddressTypeView(View): 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 @@ class ToolsAddressExplorerAddressListView(View): 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 @@ class ToolsAddressExplorerAddressListView(View): 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 @@ class ToolsAddressExplorerAddressListView(View): 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 @@ class ToolsAddressExplorerAddressListView(View): 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 @@ class ToolsAddressExplorerAddressListView(View): 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 @@ class ToolsAddressExplorerAddressView(View): 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 a7c83d49..e7673eef 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 @@ class Destination: # ######################################################################################### 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 @@ class MainMenuView(View): 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 @@ class RestartView(View): 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 @@ class NotYetImplementedView(View): @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 @@ class ErrorView(View): 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 @@ class UnhandledExceptionView(View): @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 @@ class OptionDisabledView(View): 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 12f321d3..adc596c8 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 00000000..5df87b96 --- /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 f25b163d..123c8e52 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 4c0c092a..114f5c68 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 -def test_generate_screenshots(target_locale): +# 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. + + 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() + 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() - # 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"] + 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]) - # 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) + 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) - # 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 + # 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) - 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() + # Load a PSBT into memory + decoder = DecodeQR() + decoder.add_data(BASE64_PSBT_1) + controller.psbt = decoder.get_psbt() + controller.psbt_seed = seed_12b - # 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) + # 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) + } - # 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) + # 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" + ) + ) - # 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)) + # 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}" - # 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) - } + # 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 - # 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 + def load_multisig_wallet_descriptor_cb(): + controller.multisig_wallet_descriptor = embit.descriptor.Descriptor.from_string(MULTISIG_WALLET_DESCRIPTOR) - 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" + 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 + ) - 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)), + def PSBTSelectSeedView_cb_before(): + # Have to ensure this is cleared out in order to get the seed selection screen + controller.psbt_seed = None - (seed_views.SeedTranscribeSeedQRConfirmQRPromptView, dict(seed_num=0)), - # Screenshot can't render live preview screens - # (seed_views.SeedTranscribeSeedQRConfirmScanView, dict(seed_num=0)), + 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) + - #(seed_views.AddressVerificationStartView, dict(address=, script_type="nat", network="M")), - #seed_views.AddressVerificationSigTypeView, - #seed_views.SeedSingleSigAddressVerificationSelectSeedView, - #seed_views.SeedAddressVerificationView, - #seed_views.AddressVerificationSuccessView, + 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) - seed_views.LoadMultisigWalletDescriptorView, - seed_views.MultisigWalletDescriptorView, - (seed_views.SeedDiscardView, dict(seed_num=0)), - seed_views.SeedSignMessageConfirmMessageView, - seed_views.SeedSignMessageConfirmAddressView, + 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"), - 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)), + ScreenshotConfig(seed_views.SeedTranscribeSeedQRConfirmQRPromptView, dict(seed_num=0)), - (NotYetImplementedView, {}, "PSBTChangeDetailsView_multisig_unverified"), # Must manually re-run this below - (psbt_views.PSBTChangeDetailsView, dict(change_address_num=0), "PSBTChangeDetailsView_multisig_verified"), + # Screenshot can't render live preview screens + # ScreenshotConfig(seed_views.SeedTranscribeSeedQRConfirmScanView, dict(seed_num=0)), - (NotYetImplementedView, {}, "PSBTOverviewView_op_return"), # Placeholder - (NotYetImplementedView, {}, "PSBTOpReturnView_text"), # Placeholder - (NotYetImplementedView, {}, "PSBTOpReturnView_raw_hex_data"), # Placeholder + 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 - (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", - )), - ] - } + ScreenshotConfig(seed_views.LoadMultisigWalletDescriptorView), + ScreenshotConfig(seed_views.MultisigWalletDescriptorView, run_before=load_multisig_wallet_descriptor_cb), + ScreenshotConfig(seed_views.SeedDiscardView, dict(seed_num=0)), - readme = f"""# SeedSigner Screenshots\n""" + ScreenshotConfig(seed_views.SeedSelectSeedView, dict(flow=Controller.FLOW__SIGN_MESSAGE), screenshot_name="SeedSelectSeedView_sign_message"), + ScreenshotConfig(seed_views.SeedSignMessageConfirmMessageView), + ScreenshotConfig(seed_views.SeedSignMessageConfirmAddressView), - 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") + 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() - for section_name, screenshot_list in screenshot_sections.items(): + 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) + + 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 + 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""" - screencap_view(view_cls, view_args=view_args, view_name=view_name, toast_thread=toast_thread) - readme += """ """ - readme += f"""""" - readme += """
{view_name}

\n""" + locale_readme += "
" - readme += "
" + with open(os.path.join(screenshot_root, locale, "README.md"), 'w') as readme_file: + readme_file.write(locale_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")) + print(f"Done with locale: {locale}.") - # 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)) + # 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() - 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") + 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 00000000..ac0f6e23 --- /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 703bbf61..d767bdf2 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 @@ class ScreenshotRenderer(Renderer): 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 @@ class ScreenshotRenderer(Renderer): 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 737be569..795d9c38 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 375ae9ff..d01a54cf 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -88,7 +88,7 @@ class TestController(BaseTest): 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_flows_l10n.py b/tests/test_flows_l10n.py new file mode 100644 index 00000000..8ebe8aee --- /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 964b0d67..b8661800 100644 --- a/tests/test_flows_psbt.py +++ b/tests/test_flows_psbt.py @@ -115,7 +115,7 @@ class TestPSBTFlows(FlowTest): 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 f6ef501d..490e9f15 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -5,7 +5,7 @@ import pytest 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 @@ class TestSeedFlows(FlowTest): 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 @@ class TestSeedFlows(FlowTest): """ 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 @@ class TestSeedFlows(FlowTest): 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 @@ class TestSeedFlows(FlowTest): # 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 c449f679..37adf006 100644 --- a/tests/test_flows_settings.py +++ b/tests/test_flows_settings.py @@ -8,7 +8,7 @@ from base import FlowTest, FlowStep 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 @@ class TestSettingsFlows(FlowTest): 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 @@ class TestSettingsFlows(FlowTest): 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 588859be..968b72fc 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 @@ class TestToolsFlows(FlowTest): 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 @@ class TestToolsFlows(FlowTest): # 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 @@ class TestToolsFlows(FlowTest): 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 08aa8a41..05a74429 100644 --- a/tests/test_flows_view.py +++ b/tests/test_flows_view.py @@ -28,14 +28,6 @@ class TestViewFlows(FlowTest): """ 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 00000000..a2358278 --- /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"