diff --git a/src/seedsigner/gui/components.py b/src/seedsigner/gui/components.py index 6c0f68ae..a7e7bb46 100644 --- a/src/seedsigner/gui/components.py +++ b/src/seedsigner/gui/components.py @@ -48,31 +48,32 @@ class GUIConstants: ICON_TOAST_FONT_SIZE = 30 ICON_PRIMARY_SCREEN_SIZE = 50 - 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", + BASE_LOCALE_FONTS = { + "default": "OpenSans-Regular", + # SettingsConstants.LOCALE__ARABIC: "NotoSansAR-Regular", + SettingsConstants.LOCALE__CHINESE_SIMPLIFIED: "NotoSansSC-Regular", + # SettingsConstants.LOCALE__CHINESE_TRADITIONAL: "NotoSansTC-Regular", + SettingsConstants.LOCALE__JAPANESE: "NotoSansJP-Regular", + SettingsConstants.LOCALE__KOREAN: "NotoSansKR-Regular", } + + TOP_NAV_TITLE_FONT_NAME = BASE_LOCALE_FONTS.copy() + TOP_NAV_TITLE_FONT_NAME["default"] = "OpenSans-SemiBold" TOP_NAV_TITLE_FONT_SIZE = { "default": 20, + SettingsConstants.LOCALE__JAPANESE: 22, # Titles won't render below 22px + SettingsConstants.LOCALE__KOREAN: 23, # Titles won't render below 23px + SettingsConstants.LOCALE__CHINESE_SIMPLIFIED: 23, # Some chars won't render below 23px } TOP_NAV_HEIGHT = 48 TOP_NAV_BUTTON_SIZE = 32 - 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_NAME = BASE_LOCALE_FONTS.copy() BODY_FONT_SIZE = { "default": 17, - # "ar": 16, + SettingsConstants.LOCALE__JAPANESE: 18, + SettingsConstants.LOCALE__KOREAN: 18, + SettingsConstants.LOCALE__CHINESE_SIMPLIFIED: 18, } BODY_FONT_MAX_SIZE = TOP_NAV_TITLE_FONT_SIZE["default"] BODY_FONT_MIN_SIZE = 15 @@ -85,18 +86,14 @@ class GUIConstants: LABEL_FONT_SIZE = BODY_FONT_MIN_SIZE LABEL_FONT_COLOR = "#777777" - 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_NAME = BASE_LOCALE_FONTS.copy() + BUTTON_FONT_NAME["default"] = "OpenSans-SemiBold" BUTTON_FONT_SIZE = { "default": 18, # "ar": 16, - # "ja": 16, + SettingsConstants.LOCALE__JAPANESE: 20, + SettingsConstants.LOCALE__KOREAN: 20, + SettingsConstants.LOCALE__CHINESE_SIMPLIFIED: 20, } BUTTON_FONT_COLOR = "#FCFCFC" BUTTON_BACKGROUND_COLOR = "#2C2C2C" @@ -107,8 +104,9 @@ class GUIConstants: @staticmethod - def get_body_font_name(): - locale = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE) + def get_body_font_name(locale=None): + if not locale: + locale = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE) if locale in GUIConstants.BODY_FONT_NAME: return GUIConstants.BODY_FONT_NAME[locale] else: @@ -116,8 +114,9 @@ class GUIConstants: @staticmethod - def get_body_font_size(): - locale = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE) + def get_body_font_size(locale=None): + if not locale: + locale = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE) if locale in GUIConstants.BODY_FONT_SIZE: return GUIConstants.BODY_FONT_SIZE[locale] else: @@ -143,8 +142,9 @@ class GUIConstants: @staticmethod - def get_button_font_name(): - locale = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE) + def get_button_font_name(locale=None): + if not locale: + locale = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE) if locale in GUIConstants.BUTTON_FONT_NAME: return GUIConstants.BUTTON_FONT_NAME[locale] else: @@ -152,8 +152,9 @@ class GUIConstants: @staticmethod - def get_button_font_size(): - locale = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE) + def get_button_font_size(locale=None): + if not locale: + locale = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE) if locale in GUIConstants.BUTTON_FONT_SIZE: return GUIConstants.BUTTON_FONT_SIZE[locale] else: @@ -1839,6 +1840,17 @@ def reflow_text_for_width(text: str, # Fudge factor for imprecise width calcs w/out libraqm full_text_width = int(full_text_width * 1.05) + # Assume we can break Asian text on any character + treat_chars_as_words = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE) in [ + SettingsConstants.LOCALE__CHINESE_SIMPLIFIED, + SettingsConstants.LOCALE__CHINESE_TRADITIONAL, + SettingsConstants.LOCALE__JAPANESE, + SettingsConstants.LOCALE__KOREAN, + ] + if treat_chars_as_words: + # Relax UI constraints even if the result isn't optimal + allow_text_overflow = True + # Stores each line of text and its rendering starting x-coord text_lines = [] def _add_text_line(text, text_width): @@ -1850,7 +1862,7 @@ def reflow_text_for_width(text: str, else: # Have to calc how to break text into multiple lines - def _binary_len_search(min_index, max_index): + def _binary_len_search(min_index, max_index, word_spacer): # Try the middle of the range index = math.ceil((max_index + min_index) / 2) if index == 0: @@ -1858,7 +1870,7 @@ def reflow_text_for_width(text: str, index = 1 # Measure rendered width from "left" anchor (anchor="l_") - (left, top, right, bottom) = font.getbbox(" ".join(words[0:index]), anchor="ls") + (left, top, right, bottom) = font.getbbox(word_spacer.join(words[0:index]), anchor="ls") line_width = right - left if not ImageFont.core.HAVE_RAQM: @@ -1877,28 +1889,45 @@ def reflow_text_for_width(text: str, # There's still room to back down the min_index in the next # round. index -= 1 - return _binary_len_search(min_index=min_index, max_index=index) + return _binary_len_search(min_index=min_index, max_index=index, word_spacer=word_spacer) elif index == max_index: # We have converged return (index, line_width) else: # Candidate line is possibly shorter than necessary. - return _binary_len_search(min_index=index, max_index=max_index) + return _binary_len_search(min_index=index, max_index=max_index, word_spacer=word_spacer) - if len(text.split()) == 1 and not allow_text_overflow: + if len(text.split()) == 1 and not allow_text_overflow and not treat_chars_as_words: # No whitespace chars to split on! raise TextDoesNotFitException("Text cannot fit in target rect with this font+size") # Now we're ready to go line-by-line into our line break binary search! for line in text.split("\n"): - words = line.split() + if treat_chars_as_words: + # Each char in `line` will be considered a word; lets us make line breaks + # at any char. + words = line + + # When re-joining words, no additional spacer is used + word_spacer = "" + + # TODO: Don't break before 、。「」() etc. + # TODO: If English terms are embedded, don't break mid-word + + else: + # Separate words by any whitespace (spaces, line breaks, etc) + words = line.split() + + # When re-joining words, separate with a space char + word_spacer = " " + if not words: # It's a blank line _add_text_line("", 0) else: while words: - (index, tw) = _binary_len_search(0, len(words)) - _add_text_line(" ".join(words[0:index]), tw) + (index, tw) = _binary_len_search(0, len(words), word_spacer=word_spacer) + _add_text_line(word_spacer.join(words[0:index]), tw) words = words[index:] return text_lines diff --git a/src/seedsigner/gui/screens/screen.py b/src/seedsigner/gui/screens/screen.py index 47f6645e..36a54c5a 100644 --- a/src/seedsigner/gui/screens/screen.py +++ b/src/seedsigner/gui/screens/screen.py @@ -1,3 +1,4 @@ +import math import logging import time @@ -277,6 +278,8 @@ class ButtonOption: button_label_color: str = None return_data: Any = None active_button_label: str = None # Changes displayed button label when button is active + font_name: str = None # Optional override + font_size: int = None # Optional override @@ -294,7 +297,8 @@ class ButtonListScreen(BaseTopNavScreen): Button_cls = Button checked_buttons: List[int] = None - # Enables returning w/buttons rendered at the same place + # Enables returning w/buttons rendered at the same place; default behavior will + # ensure the screen is at least scrolled to reveal the `selected_button`. scroll_y_initial_offset: int = None @@ -322,40 +326,37 @@ class ButtonListScreen(BaseTopNavScreen): button_list_y = self.top_nav.height self.has_scroll_arrows = True + # How many buttons fit on the screen before we need to start scrolling? + num_buttons_pre_scroll = math.floor((self.canvas_height - button_list_y - GUIConstants.EDGE_PADDING) / (button_height + GUIConstants.LIST_ITEM_PADDING)) + + # Force a scroll offset when necessary if none was provided + if self.selected_button + 1 > num_buttons_pre_scroll and not self.scroll_y_initial_offset: + # Scroll far enough to expose the selected button; +1 to account for the + # height of the target button itself! + self.scroll_y_initial_offset = (button_height + GUIConstants.LIST_ITEM_PADDING) * (self.selected_button - num_buttons_pre_scroll + 1) + self.buttons: List[Button] = [] for i, button_option in enumerate(self.button_data): - icon_name = None - icon_color = None - right_icon_name = None - button_label_color = None - - 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: + if type(button_option) != ButtonOption: raise Exception("Refactor to ButtonOption approach needed!") + # TODO: Refactor `Button` to optionally use ButtonOption directly? button_kwargs = dict( - 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, + text=_(button_option.button_label), # Wrap here for just-in-time translations + active_text=_(button_option.active_button_label), # Wrap here for just-in-time translations + icon_name=button_option.icon_name, + icon_color=button_option.icon_color if button_option.icon_color else GUIConstants.BUTTON_FONT_COLOR, is_icon_inline=True, - right_icon_name=right_icon_name, + right_icon_name=button_option.right_icon_name, screen_x=GUIConstants.EDGE_PADDING, screen_y=button_list_y + i * (button_height + GUIConstants.LIST_ITEM_PADDING), scroll_y=self.scroll_y_initial_offset if self.scroll_y_initial_offset is not None else 0, width=self.canvas_width - (2 * GUIConstants.EDGE_PADDING), height=button_height, is_text_centered=self.is_button_text_centered, - 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, + font_name=button_option.font_name if button_option.font_name else self.button_font_name, + font_size=button_option.font_size if button_option.font_size else self.button_font_size, + font_color=button_option.button_label_color if button_option.button_label_color else GUIConstants.BUTTON_FONT_COLOR, selected_color=self.button_selected_color, is_scrollable_text=True, # We need to use the ScrollableText class for long button labels ) diff --git a/src/seedsigner/gui/screens/settings_screens.py b/src/seedsigner/gui/screens/settings_screens.py index c41eadfc..10e8bd06 100644 --- a/src/seedsigner/gui/screens/settings_screens.py +++ b/src/seedsigner/gui/screens/settings_screens.py @@ -21,7 +21,6 @@ class SettingsEntryUpdateSelectionScreen(ButtonListScreen): help_text: str = None checked_buttons: List[int] = None settings_entry_type: str = SettingsConstants.TYPE__ENABLED_DISABLED - selected_button: int = 0 def __post_init__(self): self.title = _("Settings") diff --git a/src/seedsigner/gui/screens/tools_screens.py b/src/seedsigner/gui/screens/tools_screens.py index 8fab9a16..2ae2df35 100644 --- a/src/seedsigner/gui/screens/tools_screens.py +++ b/src/seedsigner/gui/screens/tools_screens.py @@ -183,9 +183,11 @@ class ToolsCalcFinalWordFinalizePromptScreen(ButtonListScreen): self.is_button_text_centered = True super().__post_init__() + # TRANSLATOR_NOTE: Final word calc. `mnemonic_length` = 12 or 24. `num_bits` = 7 or 3 (bits of entropy in final word). + text=_("The {mnemonic_length}th word is built from {num_bits} more entropy bits plus auto-calculated checksum.").format(mnemonic_length=self.mnemonic_length, num_bits=self.num_entropy_bits) + self.components.append(TextArea( - # TRANSLATOR_NOTE: Final word calc. `mnemonic_length` = 12 or 24. `num_bits` = 7 or 3 (bits of entropy in final word). - text=_("The {mnemonic_length}th word is built from {num_bits} more entropy bits plus auto-calculated checksum.").format(mnemonic_length=self.mnemonic_length, num_bits=self.num_entropy_bits), + text=text, screen_y=self.top_nav.height + int(GUIConstants.COMPONENT_PADDING/2), )) diff --git a/src/seedsigner/models/settings_definition.py b/src/seedsigner/models/settings_definition.py index 08652519..f554b930 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -1,8 +1,13 @@ +import os from dataclasses import dataclass from typing import Any, List from seedsigner.helpers.l10n import mark_for_translation as _mft +import logging +logger = logging.getLogger(__name__) + + class SettingsConstants: # Basic defaults @@ -46,31 +51,166 @@ class SettingsConstants: (COORDINATOR__KEEPER, "Keeper"), ] + # Over-specifying current and possible future locales to reduce/eliminate main repo + # changes when adding/testing new languages. LOCALE__ARABIC = "ar" + LOCALE__BENGALI = "bn" + LOCALE__BULGARIAN = "bg" + LOCALE__CATALAN = "ca" + LOCALE__CHINESE_SIMPLIFIED = "zh_Hans_CN" + LOCALE__CHINESE_TRADITIONAL = "zh_Hant_TW" + LOCALE__CROATIAN = "hr" LOCALE__CZECH = "cs" + LOCALE__DANISH = "da" + LOCALE__DUTCH = "nl" + LOCALE__EGYPTIAN = "ar_EG" LOCALE__ENGLISH = "en" + LOCALE__ESTONIAN = "et" + LOCALE__FINNISH = "fi" LOCALE__FRENCH = "fr" + LOCALE__GAELIC = "gd" LOCALE__GERMAN = "de" + LOCALE__GREEK = "el" + LOCALE__GUJARATI = "gu" + LOCALE__HAUSA = "ha" LOCALE__HEBREW = "he" + LOCALE__HINDI = "hi" + LOCALE__HUNGARIAN = "hu" + LOCALE__INDONESIAN = "id" + LOCALE__ITALIAN = "it" LOCALE__JAPANESE = "ja" - LOCALE__KOREAN = "kr" - LOCALE__PORTUGUESE = "pt" + LOCALE__JAVANESE = "jv" + LOCALE__KOREAN = "ko" + LOCALE__LAO = "lo" + LOCALE__LATVIAN = "lv" + LOCALE__LITHUANIAN = "lt" + LOCALE__MALAY = "ms" + LOCALE__MALTESE = "mt" + LOCALE__MARATHI = "mr" + LOCALE__NORWEGIAN = "no" + LOCALE__PERSIAN = "fa" + LOCALE__POLISH = "pl" + LOCALE__PORTUGUESE_BR = "pt_BR" + LOCALE__PORTUGUESE_PT = "pt_PT" + LOCALE__PUNJABI = "pa" + LOCALE__ROMANIAN = "ro" LOCALE__RUSSIAN = "ru" + LOCALE__SLOVAK = "sk" + LOCALE__SLOVENIAN = "sl" 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"), - ] + LOCALE__SWEDISH = "sv" + LOCALE__TAGALOG = "tl" + LOCALE__TAMIL = "ta" + LOCALE__TELUGU = "te" + LOCALE__THAI = "th" + LOCALE__TURKISH = "tr" + LOCALE__UKRANIAN = "uk" + LOCALE__URDU = "ur" + LOCALE__VIETNAMESE = "vi" + + # Do not wrap for translation. Present each language in its native form (i.e. either + # using its native chars or how they write it in Latin chars; e.g. Spanish is listed + # and sorted as "Español"). + # Sort fully-vetted languages first, then beta languages, then the "placeholders / + # coming soon" languages. + # Sort by native form when written in Latin chars, otherwise sort by English name. + # Include English name in parens for languages that don't use Latin chars. + # Include region/country in parens for specific dialects (e.g. "Português (Brasil)"). + # Note that dicts preserve insertion order as of Python 3.7. + ALL_LOCALES = { + # --------- Fully supported languages ------------------------------------------- + LOCALE__CATALAN: "Català", + LOCALE__GERMAN: "Deutsch", + LOCALE__ENGLISH: "English", + LOCALE__SPANISH: "Español", + LOCALE__FRENCH: "Français", + LOCALE__DUTCH: "Nederlands", + + # --------- Beta languages ------------------------------------------------------ + LOCALE__JAPANESE: "(beta) 日本語 (Japanese)", + LOCALE__KOREAN: "(beta) 한국어 (Korean)", + + # --------- Placeholders / Coming soon ------------------------------------------ + # Commented out options require explicit additional font support. + # ------------------------------------------------------------------------------- + # LOCALE__ARABIC: "العربية (Arabic)", + # LOCALE__BENGALI: "বাংলা (Bengali)", + LOCALE__BULGARIAN: "български (Bulgarian)", # OpenSans includes cyrillic chars + LOCALE__CZECH: "čeština", + LOCALE__CHINESE_SIMPLIFIED: "简体中文 (Chinese Simplified)", # Font already included in anticipation of translations being ready soon + # LOCALE__CHINESE_TRADITIONAL: "繁體中文 (Chinese Traditional)", + LOCALE__DANISH: "Dansk", + LOCALE__ESTONIAN: "Eesti", + # LOCALE__EGYPTIAN: "مصرى (Egyptian)", + LOCALE__GAELIC: "Gaeilge", + LOCALE__GREEK: "Ελληνικά (Greek)", # OpenSans includes Greek chars + # LOCALE__GUJARATI: "ગુજરાતી (Gujarati)", + LOCALE__HAUSA: "Hausa", + # LOCALE__HEBREW: "עברית (Hebrew)", + # LOCALE__HINDI: "हिन्दी (Hindi)", + LOCALE__CROATIAN: "Hrvatski", + LOCALE__ITALIAN: "Italiano", + LOCALE__INDONESIAN: "Indonesia", + LOCALE__JAVANESE: "Jawa (Javanese)", + # LOCALE__LAO: "ລາວ (Lao)", + LOCALE__LATVIAN: "Latviešu", + LOCALE__LITHUANIAN: "Lietuvių", + LOCALE__HUNGARIAN: "Magyar", + LOCALE__MALAY: "Melayu", + LOCALE__MALTESE: "Malti", + # LOCALE__MARATHI: "मराठी (Marathi)", + LOCALE__NORWEGIAN: "Norsk", + # LOCALE__PERSIAN: "فارسی (Persian)", + LOCALE__POLISH: "Polski", + LOCALE__PORTUGUESE_BR: "Português (Brasil)", + LOCALE__PORTUGUESE_PT: "Português (Portugal)", + # LOCALE__PUNJABI: "ਪੰਜਾਬੀ (Punjabi)", + LOCALE__ROMANIAN: "Română", + LOCALE__RUSSIAN: "русский (Russian)", # OpenSans includes cyrillic chars + LOCALE__SLOVAK: "Slovenčina", + LOCALE__SLOVENIAN: "Slovenščina", + LOCALE__FINNISH: "Suomi", + LOCALE__SWEDISH: "Svenska", + LOCALE__TAGALOG: "Tagalog", + # LOCALE__TAMIL: "தமிழ் (Tamil)", + # LOCALE__TELUGU: "తెలుగు (Telugu)", + # LOCALE__THAI: "ไทย (Thai)", + LOCALE__TURKISH: "Türkçe", + LOCALE__UKRANIAN: "українська (Ukranian)", # OpenSans includes cyrillic chars + # LOCALE__URDU: "اردو (Urdu)", + LOCALE__VIETNAMESE: "Tiếng Việt (Vietnamese)", + } + + @classmethod + def get_detected_languages(cls) -> list[tuple[str, str]]: + """ + Return a list of tuples of language codes and their native names. + + Scans the filesystem to autodiscover which language codes are onboard. + """ + # Will normally be the launch dir (where main.py is located)... + cwd = os.getcwd() + + # ...except when running the tests which happens one dir higher + if "src" not in cwd: + cwd = os.path.join(cwd, "src") + + # Pre-load English since there's no "en" entry in the translations folder; also + # it should always appear first in the list anyway. + detected_languages = [(cls.LOCALE__ENGLISH, cls.ALL_LOCALES[cls.LOCALE__ENGLISH])] + + locales_present = set() + for root, dirs, files in os.walk(os.path.join(cwd, "seedsigner", "resources", "seedsigner-translations", "l10n")): + for file in [f for f in files if f.endswith(".mo")]: + # `root` will be [...]seedsigner/resources/seedsigner-translations/l10n/pt_BR/LC_MESSAGES + locales_present.add(root.split(f"l10n{ os.sep }")[1].split(os.sep)[0]) + + for locale in cls.ALL_LOCALES.keys(): + if locale in locales_present: + detected_languages.append((locale, cls.ALL_LOCALES[locale])) + + return detected_languages + BTC_DENOMINATION__BTC = "btc" BTC_DENOMINATION__SATS = "sats" @@ -172,7 +312,6 @@ class SettingsConstants: # (WORDLIST_LANGUAGE__PORTUGUESE, "Português"), ] - # Individual SettingsEntry attr_names # Note: attr_names are internal constants; do not wrap for translation SETTING__LOCALE = "locale" @@ -382,7 +521,7 @@ class SettingsDefinition: abbreviated_name="lang", display_name=_mft("Language"), type=SettingsConstants.TYPE__SELECT_1, - selection_options=SettingsConstants.ALL_LOCALES, + selection_options=SettingsConstants.get_detected_languages(), default_value=SettingsConstants.LOCALE__ENGLISH), # TODO: Support other bip-39 wordlist languages! Until then, type == HIDDEN diff --git a/src/seedsigner/resources/fonts/NotoSansJP-Regular.ttf b/src/seedsigner/resources/fonts/NotoSansJP-Regular.ttf new file mode 100644 index 00000000..b2dad730 Binary files /dev/null and b/src/seedsigner/resources/fonts/NotoSansJP-Regular.ttf differ diff --git a/src/seedsigner/resources/fonts/NotoSansKR-Regular.ttf b/src/seedsigner/resources/fonts/NotoSansKR-Regular.ttf new file mode 100644 index 00000000..1b14d324 Binary files /dev/null and b/src/seedsigner/resources/fonts/NotoSansKR-Regular.ttf differ diff --git a/src/seedsigner/resources/fonts/NotoSansSC-Regular.ttf b/src/seedsigner/resources/fonts/NotoSansSC-Regular.ttf new file mode 100644 index 00000000..7056f5e9 Binary files /dev/null and b/src/seedsigner/resources/fonts/NotoSansSC-Regular.ttf differ diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index b4fb86a6..17db700e 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -1,7 +1,7 @@ import logging from gettext import gettext as _ -from seedsigner.gui.components import SeedSignerIconConstants +from seedsigner.gui.components import GUIConstants, SeedSignerIconConstants from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen, settings_screens) from seedsigner.gui.screens.screen import ButtonOption from seedsigner.models.settings import Settings, SettingsConstants, SettingsDefinition @@ -83,17 +83,60 @@ class SettingsMenuView(View): elif selected_menu_num == len(settings_entries): return next_destination - elif len(button_data) > selected_menu_num and button_data[selected_menu_num] == self.IO_TEST: + elif button_data[selected_menu_num] == self.IO_TEST: return Destination(IOTestView) - elif len(button_data) > selected_menu_num and button_data[selected_menu_num] == self.DONATE: + elif button_data[selected_menu_num] == self.DONATE: return Destination(DonateView) + elif settings_entries[selected_menu_num].attr_name == SettingsConstants.SETTING__LOCALE: + return Destination(LocaleSelectionView) + else: return Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=settings_entries[selected_menu_num].attr_name, parent_initial_scroll=initial_scroll)) +class LocaleSelectionView(View): + def run(self): + cur_language_code = self.settings.get_value(SettingsConstants.SETTING__LOCALE) + + selected_button = 0 + button_data: list[ButtonOption] = [] + for i, (language_code, display_name) in enumerate(SettingsConstants.get_detected_languages()): + button_data.append( + # Unique to this View: override each button's font so we can display each + # language name in its native script. + ButtonOption( + button_label=display_name, + return_data=language_code, + font_name=GUIConstants.get_button_font_name(language_code), + font_size=GUIConstants.get_button_font_size(language_code), + ) + ) + + if language_code == cur_language_code: + # Highlight the current selection + selected_button = i + + selected_menu_num = self.run_screen( + settings_screens.SettingsEntryUpdateSelectionScreen, + display_name=_(SettingsDefinition.get_settings_entry(attr_name=SettingsConstants.SETTING__LOCALE).display_name), + button_data=button_data, + selected_button=selected_button, + checked_buttons=[selected_button], + ) + + if selected_menu_num == RET_CODE__BACK_BUTTON: + return Destination(SettingsMenuView) + + # Set the new language + self.settings.set_value(SettingsConstants.SETTING__LOCALE, button_data[selected_menu_num].return_data) + + return Destination(SettingsMenuView) + + + class SettingsEntryUpdateSelectionView(View): """ Handles changes to all selection-type settings (Multiselect, SELECT_1, diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index 27ea2e18..7e744632 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -48,7 +48,7 @@ import warnings; warnings.warn = lambda *args, **kwargs: None # Dynamically generate a pytest test run for each locale -@pytest.mark.parametrize("locale", [x for x, y in SettingsConstants.ALL_LOCALES]) +@pytest.mark.parametrize("locale", [x for x, y in SettingsConstants.get_detected_languages()]) def test_generate_all(locale, target_locale): """ `target_locale` is a fixture created in conftest.py via the `--locale` command line arg. @@ -428,7 +428,7 @@ def generate_screenshots(locale): 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] + locale_tuple_list = [locale_tuple for locale_tuple in SettingsConstants.get_detected_languages() if locale_tuple[0] == locale] if not locale_tuple_list: raise Exception(f"Invalid locale: {locale}") @@ -478,7 +478,7 @@ def generate_screenshots(locale): with open(os.path.join("tests", "screenshot_generator", "template.md"), 'r') as readme_template: main_readme = readme_template.read() - for locale, display_name in SettingsConstants.ALL_LOCALES: + for locale, display_name in SettingsConstants.get_detected_languages(): main_readme += f"* [{display_name}]({locale}/README.md)\n" with open(os.path.join(screenshot_root, "README.md"), 'w') as readme_file: diff --git a/tests/test_flows_l10n.py b/tests/test_flows_l10n.py index 8ebe8aee..71ce1fb6 100644 --- a/tests/test_flows_l10n.py +++ b/tests/test_flows_l10n.py @@ -13,7 +13,7 @@ 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] + spanish_display_name = SettingsConstants.ALL_LOCALES[SettingsConstants.LOCALE__SPANISH] # Initially we get English assert _(MainMenuView.SCAN.button_label) == "Scan" @@ -21,7 +21,7 @@ class TestL10nFlows(FlowTest): 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)), + FlowStep(settings_views.LocaleSelectionView, screen_return_value=1), # Any index > 0 (English) ]) # Now we don't get English diff --git a/tests/test_settings_definition.py b/tests/test_settings_definition.py new file mode 100644 index 00000000..f95a6c8b --- /dev/null +++ b/tests/test_settings_definition.py @@ -0,0 +1,38 @@ +import os +from unittest.mock import Mock + +from base import BaseTest +from seedsigner.models.settings_definition import SettingsConstants + + +class TestSettingsDefinition(BaseTest): + @classmethod + def setup_class(cls): + super().setup_class() + + + def test__get_detected_languages(self): + """ Should auto-detect onboard languages based on the supported locales list """ + detected_languages = [lang_tuple[0] for lang_tuple in SettingsConstants.get_detected_languages()] + + # Find an unused language code; avoiding hard coding a language code to keep + # this test future proof. + absent_language_code = None + for language_code in SettingsConstants.ALL_LOCALES.keys(): + if language_code not in detected_languages: + absent_language_code = language_code + break + + # Should only fail if we've absolutely crushed the global translations!!! + assert absent_language_code is not None + + root = os.path.join(os.getcwd(), "src", "seedsigner", "resources", "seedsigner-translations", "l10n") + + # We're going to mock the `root` results to include the absent language code's .mo file + mocked_results = [(os.path.join(root, "en", "LC_MESSAGES"), [], ["messages.po", "messages.mo"])] + mocked_results.append((os.path.join(root, absent_language_code, "LC_MESSAGES"), [], ["messages.po", "messages.mo"])) + os.walk = Mock(return_value=mocked_results) + + # Recheck w/our mocked dir listing: + detected_languages = [lang_tuple[0] for lang_tuple in SettingsConstants.get_detected_languages()] + assert absent_language_code in detected_languages