mirror of
https://github.com/SeedSigner/seedsigner.git
synced 2026-10-05 15:08:25 +00:00
Merge pull request #686 from kdmukai/dynamic_locales
[l10n] Enable new language code autodiscovery; add Asian fonts
This commit is contained in:
@@ -48,31 +48,32 @@ class GUIConstants:
|
|||||||
ICON_TOAST_FONT_SIZE = 30
|
ICON_TOAST_FONT_SIZE = 30
|
||||||
ICON_PRIMARY_SCREEN_SIZE = 50
|
ICON_PRIMARY_SCREEN_SIZE = 50
|
||||||
|
|
||||||
TOP_NAV_TITLE_FONT_NAME = {
|
BASE_LOCALE_FONTS = {
|
||||||
"default": "OpenSans-SemiBold",
|
"default": "OpenSans-Regular",
|
||||||
# "ar": "multilanguage/NotoSansAR-Regular",
|
# SettingsConstants.LOCALE__ARABIC: "NotoSansAR-Regular",
|
||||||
# "he": "multilanguage/NotoSansHE-Regular",
|
SettingsConstants.LOCALE__CHINESE_SIMPLIFIED: "NotoSansSC-Regular",
|
||||||
# "ja": "multilanguage/NotoSansJP-Regular",
|
# SettingsConstants.LOCALE__CHINESE_TRADITIONAL: "NotoSansTC-Regular",
|
||||||
# "kr": "multilanguage/NotoSansKR-Regular",
|
SettingsConstants.LOCALE__JAPANESE: "NotoSansJP-Regular",
|
||||||
# "ru": "multilanguage/NotoSans-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 = {
|
TOP_NAV_TITLE_FONT_SIZE = {
|
||||||
"default": 20,
|
"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_HEIGHT = 48
|
||||||
TOP_NAV_BUTTON_SIZE = 32
|
TOP_NAV_BUTTON_SIZE = 32
|
||||||
|
|
||||||
BODY_FONT_NAME = {
|
BODY_FONT_NAME = BASE_LOCALE_FONTS.copy()
|
||||||
"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 = {
|
BODY_FONT_SIZE = {
|
||||||
"default": 17,
|
"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_MAX_SIZE = TOP_NAV_TITLE_FONT_SIZE["default"]
|
||||||
BODY_FONT_MIN_SIZE = 15
|
BODY_FONT_MIN_SIZE = 15
|
||||||
@@ -85,18 +86,14 @@ class GUIConstants:
|
|||||||
LABEL_FONT_SIZE = BODY_FONT_MIN_SIZE
|
LABEL_FONT_SIZE = BODY_FONT_MIN_SIZE
|
||||||
LABEL_FONT_COLOR = "#777777"
|
LABEL_FONT_COLOR = "#777777"
|
||||||
|
|
||||||
BUTTON_FONT_NAME = {
|
BUTTON_FONT_NAME = BASE_LOCALE_FONTS.copy()
|
||||||
"default": "OpenSans-SemiBold",
|
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 = {
|
BUTTON_FONT_SIZE = {
|
||||||
"default": 18,
|
"default": 18,
|
||||||
# "ar": 16,
|
# "ar": 16,
|
||||||
# "ja": 16,
|
SettingsConstants.LOCALE__JAPANESE: 20,
|
||||||
|
SettingsConstants.LOCALE__KOREAN: 20,
|
||||||
|
SettingsConstants.LOCALE__CHINESE_SIMPLIFIED: 20,
|
||||||
}
|
}
|
||||||
BUTTON_FONT_COLOR = "#FCFCFC"
|
BUTTON_FONT_COLOR = "#FCFCFC"
|
||||||
BUTTON_BACKGROUND_COLOR = "#2C2C2C"
|
BUTTON_BACKGROUND_COLOR = "#2C2C2C"
|
||||||
@@ -107,8 +104,9 @@ class GUIConstants:
|
|||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_body_font_name():
|
def get_body_font_name(locale=None):
|
||||||
locale = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE)
|
if not locale:
|
||||||
|
locale = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE)
|
||||||
if locale in GUIConstants.BODY_FONT_NAME:
|
if locale in GUIConstants.BODY_FONT_NAME:
|
||||||
return GUIConstants.BODY_FONT_NAME[locale]
|
return GUIConstants.BODY_FONT_NAME[locale]
|
||||||
else:
|
else:
|
||||||
@@ -116,8 +114,9 @@ class GUIConstants:
|
|||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_body_font_size():
|
def get_body_font_size(locale=None):
|
||||||
locale = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE)
|
if not locale:
|
||||||
|
locale = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE)
|
||||||
if locale in GUIConstants.BODY_FONT_SIZE:
|
if locale in GUIConstants.BODY_FONT_SIZE:
|
||||||
return GUIConstants.BODY_FONT_SIZE[locale]
|
return GUIConstants.BODY_FONT_SIZE[locale]
|
||||||
else:
|
else:
|
||||||
@@ -143,8 +142,9 @@ class GUIConstants:
|
|||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_button_font_name():
|
def get_button_font_name(locale=None):
|
||||||
locale = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE)
|
if not locale:
|
||||||
|
locale = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE)
|
||||||
if locale in GUIConstants.BUTTON_FONT_NAME:
|
if locale in GUIConstants.BUTTON_FONT_NAME:
|
||||||
return GUIConstants.BUTTON_FONT_NAME[locale]
|
return GUIConstants.BUTTON_FONT_NAME[locale]
|
||||||
else:
|
else:
|
||||||
@@ -152,8 +152,9 @@ class GUIConstants:
|
|||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_button_font_size():
|
def get_button_font_size(locale=None):
|
||||||
locale = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE)
|
if not locale:
|
||||||
|
locale = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE)
|
||||||
if locale in GUIConstants.BUTTON_FONT_SIZE:
|
if locale in GUIConstants.BUTTON_FONT_SIZE:
|
||||||
return GUIConstants.BUTTON_FONT_SIZE[locale]
|
return GUIConstants.BUTTON_FONT_SIZE[locale]
|
||||||
else:
|
else:
|
||||||
@@ -1839,6 +1840,17 @@ def reflow_text_for_width(text: str,
|
|||||||
# Fudge factor for imprecise width calcs w/out libraqm
|
# Fudge factor for imprecise width calcs w/out libraqm
|
||||||
full_text_width = int(full_text_width * 1.05)
|
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
|
# Stores each line of text and its rendering starting x-coord
|
||||||
text_lines = []
|
text_lines = []
|
||||||
def _add_text_line(text, text_width):
|
def _add_text_line(text, text_width):
|
||||||
@@ -1850,7 +1862,7 @@ def reflow_text_for_width(text: str,
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
# Have to calc how to break text into multiple lines
|
# 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
|
# Try the middle of the range
|
||||||
index = math.ceil((max_index + min_index) / 2)
|
index = math.ceil((max_index + min_index) / 2)
|
||||||
if index == 0:
|
if index == 0:
|
||||||
@@ -1858,7 +1870,7 @@ def reflow_text_for_width(text: str,
|
|||||||
index = 1
|
index = 1
|
||||||
|
|
||||||
# Measure rendered width from "left" anchor (anchor="l_")
|
# 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
|
line_width = right - left
|
||||||
|
|
||||||
if not ImageFont.core.HAVE_RAQM:
|
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
|
# There's still room to back down the min_index in the next
|
||||||
# round.
|
# round.
|
||||||
index -= 1
|
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:
|
elif index == max_index:
|
||||||
# We have converged
|
# We have converged
|
||||||
return (index, line_width)
|
return (index, line_width)
|
||||||
else:
|
else:
|
||||||
# Candidate line is possibly shorter than necessary.
|
# 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!
|
# No whitespace chars to split on!
|
||||||
raise TextDoesNotFitException("Text cannot fit in target rect with this font+size")
|
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!
|
# Now we're ready to go line-by-line into our line break binary search!
|
||||||
for line in text.split("\n"):
|
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:
|
if not words:
|
||||||
# It's a blank line
|
# It's a blank line
|
||||||
_add_text_line("", 0)
|
_add_text_line("", 0)
|
||||||
else:
|
else:
|
||||||
while words:
|
while words:
|
||||||
(index, tw) = _binary_len_search(0, len(words))
|
(index, tw) = _binary_len_search(0, len(words), word_spacer=word_spacer)
|
||||||
_add_text_line(" ".join(words[0:index]), tw)
|
_add_text_line(word_spacer.join(words[0:index]), tw)
|
||||||
words = words[index:]
|
words = words[index:]
|
||||||
|
|
||||||
return text_lines
|
return text_lines
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import math
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -277,6 +278,8 @@ class ButtonOption:
|
|||||||
button_label_color: str = None
|
button_label_color: str = None
|
||||||
return_data: Any = None
|
return_data: Any = None
|
||||||
active_button_label: str = None # Changes displayed button label when button is active
|
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
|
Button_cls = Button
|
||||||
checked_buttons: List[int] = None
|
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
|
scroll_y_initial_offset: int = None
|
||||||
|
|
||||||
|
|
||||||
@@ -322,40 +326,37 @@ class ButtonListScreen(BaseTopNavScreen):
|
|||||||
button_list_y = self.top_nav.height
|
button_list_y = self.top_nav.height
|
||||||
self.has_scroll_arrows = True
|
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] = []
|
self.buttons: List[Button] = []
|
||||||
for i, button_option in enumerate(self.button_data):
|
for i, button_option in enumerate(self.button_data):
|
||||||
icon_name = None
|
if type(button_option) != ButtonOption:
|
||||||
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:
|
|
||||||
raise Exception("Refactor to ButtonOption approach needed!")
|
raise Exception("Refactor to ButtonOption approach needed!")
|
||||||
|
|
||||||
|
# TODO: Refactor `Button` to optionally use ButtonOption directly?
|
||||||
button_kwargs = dict(
|
button_kwargs = dict(
|
||||||
text=_(button_label), # Wrap here for just-in-time translations
|
text=_(button_option.button_label), # Wrap here for just-in-time translations
|
||||||
active_text=_(active_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=icon_name,
|
icon_name=button_option.icon_name,
|
||||||
icon_color=icon_color if icon_color else GUIConstants.BUTTON_FONT_COLOR,
|
icon_color=button_option.icon_color if button_option.icon_color else GUIConstants.BUTTON_FONT_COLOR,
|
||||||
is_icon_inline=True,
|
is_icon_inline=True,
|
||||||
right_icon_name=right_icon_name,
|
right_icon_name=button_option.right_icon_name,
|
||||||
screen_x=GUIConstants.EDGE_PADDING,
|
screen_x=GUIConstants.EDGE_PADDING,
|
||||||
screen_y=button_list_y + i * (button_height + GUIConstants.LIST_ITEM_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,
|
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),
|
width=self.canvas_width - (2 * GUIConstants.EDGE_PADDING),
|
||||||
height=button_height,
|
height=button_height,
|
||||||
is_text_centered=self.is_button_text_centered,
|
is_text_centered=self.is_button_text_centered,
|
||||||
font_name=self.button_font_name,
|
font_name=button_option.font_name if button_option.font_name else self.button_font_name,
|
||||||
font_size=self.button_font_size,
|
font_size=button_option.font_size if button_option.font_size else self.button_font_size,
|
||||||
font_color=button_label_color if button_label_color else GUIConstants.BUTTON_FONT_COLOR,
|
font_color=button_option.button_label_color if button_option.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
|
is_scrollable_text=True, # We need to use the ScrollableText class for long button labels
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ class SettingsEntryUpdateSelectionScreen(ButtonListScreen):
|
|||||||
help_text: str = None
|
help_text: str = None
|
||||||
checked_buttons: List[int] = None
|
checked_buttons: List[int] = None
|
||||||
settings_entry_type: str = SettingsConstants.TYPE__ENABLED_DISABLED
|
settings_entry_type: str = SettingsConstants.TYPE__ENABLED_DISABLED
|
||||||
selected_button: int = 0
|
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
self.title = _("Settings")
|
self.title = _("Settings")
|
||||||
|
|||||||
@@ -183,9 +183,11 @@ class ToolsCalcFinalWordFinalizePromptScreen(ButtonListScreen):
|
|||||||
self.is_button_text_centered = True
|
self.is_button_text_centered = True
|
||||||
super().__post_init__()
|
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(
|
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=text,
|
||||||
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),
|
|
||||||
screen_y=self.top_nav.height + int(GUIConstants.COMPONENT_PADDING/2),
|
screen_y=self.top_nav.height + int(GUIConstants.COMPONENT_PADDING/2),
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, List
|
from typing import Any, List
|
||||||
|
|
||||||
from seedsigner.helpers.l10n import mark_for_translation as _mft
|
from seedsigner.helpers.l10n import mark_for_translation as _mft
|
||||||
|
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class SettingsConstants:
|
class SettingsConstants:
|
||||||
# Basic defaults
|
# Basic defaults
|
||||||
@@ -46,31 +51,166 @@ class SettingsConstants:
|
|||||||
(COORDINATOR__KEEPER, "Keeper"),
|
(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__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__CZECH = "cs"
|
||||||
|
LOCALE__DANISH = "da"
|
||||||
|
LOCALE__DUTCH = "nl"
|
||||||
|
LOCALE__EGYPTIAN = "ar_EG"
|
||||||
LOCALE__ENGLISH = "en"
|
LOCALE__ENGLISH = "en"
|
||||||
|
LOCALE__ESTONIAN = "et"
|
||||||
|
LOCALE__FINNISH = "fi"
|
||||||
LOCALE__FRENCH = "fr"
|
LOCALE__FRENCH = "fr"
|
||||||
|
LOCALE__GAELIC = "gd"
|
||||||
LOCALE__GERMAN = "de"
|
LOCALE__GERMAN = "de"
|
||||||
|
LOCALE__GREEK = "el"
|
||||||
|
LOCALE__GUJARATI = "gu"
|
||||||
|
LOCALE__HAUSA = "ha"
|
||||||
LOCALE__HEBREW = "he"
|
LOCALE__HEBREW = "he"
|
||||||
|
LOCALE__HINDI = "hi"
|
||||||
|
LOCALE__HUNGARIAN = "hu"
|
||||||
|
LOCALE__INDONESIAN = "id"
|
||||||
|
LOCALE__ITALIAN = "it"
|
||||||
LOCALE__JAPANESE = "ja"
|
LOCALE__JAPANESE = "ja"
|
||||||
LOCALE__KOREAN = "kr"
|
LOCALE__JAVANESE = "jv"
|
||||||
LOCALE__PORTUGUESE = "pt"
|
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__RUSSIAN = "ru"
|
||||||
|
LOCALE__SLOVAK = "sk"
|
||||||
|
LOCALE__SLOVENIAN = "sl"
|
||||||
LOCALE__SPANISH = "es"
|
LOCALE__SPANISH = "es"
|
||||||
# Do not wrap for translation; always present each language in its native form
|
LOCALE__SWEDISH = "sv"
|
||||||
ALL_LOCALES = [
|
LOCALE__TAGALOG = "tl"
|
||||||
# (LOCALE__ARABIC, "Arabic"),
|
LOCALE__TAMIL = "ta"
|
||||||
# (LOCALE__CZECH, "čeština"),
|
LOCALE__TELUGU = "te"
|
||||||
(LOCALE__ENGLISH, "English"),
|
LOCALE__THAI = "th"
|
||||||
# (LOCALE__FRENCH, "Français"),
|
LOCALE__TURKISH = "tr"
|
||||||
# (LOCALE__GERMAN, "Deutsch"),
|
LOCALE__UKRANIAN = "uk"
|
||||||
# (LOCALE__HEBREW, "Hebrew"),
|
LOCALE__URDU = "ur"
|
||||||
# (LOCALE__JAPANESE, "Japanese"),
|
LOCALE__VIETNAMESE = "vi"
|
||||||
# (LOCALE__KOREAN, "Korean"),
|
|
||||||
# (LOCALE__PORTUGUESE, "Português"),
|
# Do not wrap for translation. Present each language in its native form (i.e. either
|
||||||
# (LOCALE__RUSSIAN, "русский"),
|
# using its native chars or how they write it in Latin chars; e.g. Spanish is listed
|
||||||
(LOCALE__SPANISH, "Español"),
|
# 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__BTC = "btc"
|
||||||
BTC_DENOMINATION__SATS = "sats"
|
BTC_DENOMINATION__SATS = "sats"
|
||||||
@@ -172,7 +312,6 @@ class SettingsConstants:
|
|||||||
# (WORDLIST_LANGUAGE__PORTUGUESE, "Português"),
|
# (WORDLIST_LANGUAGE__PORTUGUESE, "Português"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# Individual SettingsEntry attr_names
|
# Individual SettingsEntry attr_names
|
||||||
# Note: attr_names are internal constants; do not wrap for translation
|
# Note: attr_names are internal constants; do not wrap for translation
|
||||||
SETTING__LOCALE = "locale"
|
SETTING__LOCALE = "locale"
|
||||||
@@ -382,7 +521,7 @@ class SettingsDefinition:
|
|||||||
abbreviated_name="lang",
|
abbreviated_name="lang",
|
||||||
display_name=_mft("Language"),
|
display_name=_mft("Language"),
|
||||||
type=SettingsConstants.TYPE__SELECT_1,
|
type=SettingsConstants.TYPE__SELECT_1,
|
||||||
selection_options=SettingsConstants.ALL_LOCALES,
|
selection_options=SettingsConstants.get_detected_languages(),
|
||||||
default_value=SettingsConstants.LOCALE__ENGLISH),
|
default_value=SettingsConstants.LOCALE__ENGLISH),
|
||||||
|
|
||||||
# TODO: Support other bip-39 wordlist languages! Until then, type == HIDDEN
|
# TODO: Support other bip-39 wordlist languages! Until then, type == HIDDEN
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,7 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from gettext import gettext as _
|
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 import (RET_CODE__BACK_BUTTON, ButtonListScreen, settings_screens)
|
||||||
from seedsigner.gui.screens.screen import ButtonOption
|
from seedsigner.gui.screens.screen import ButtonOption
|
||||||
from seedsigner.models.settings import Settings, SettingsConstants, SettingsDefinition
|
from seedsigner.models.settings import Settings, SettingsConstants, SettingsDefinition
|
||||||
@@ -83,17 +83,60 @@ class SettingsMenuView(View):
|
|||||||
elif selected_menu_num == len(settings_entries):
|
elif selected_menu_num == len(settings_entries):
|
||||||
return next_destination
|
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)
|
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)
|
return Destination(DonateView)
|
||||||
|
|
||||||
|
elif settings_entries[selected_menu_num].attr_name == SettingsConstants.SETTING__LOCALE:
|
||||||
|
return Destination(LocaleSelectionView)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=settings_entries[selected_menu_num].attr_name, parent_initial_scroll=initial_scroll))
|
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):
|
class SettingsEntryUpdateSelectionView(View):
|
||||||
"""
|
"""
|
||||||
Handles changes to all selection-type settings (Multiselect, SELECT_1,
|
Handles changes to all selection-type settings (Multiselect, SELECT_1,
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ import warnings; warnings.warn = lambda *args, **kwargs: None
|
|||||||
|
|
||||||
|
|
||||||
# Dynamically generate a pytest test run for each locale
|
# 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):
|
def test_generate_all(locale, target_locale):
|
||||||
"""
|
"""
|
||||||
`target_locale` is a fixture created in conftest.py via the `--locale` command line arg.
|
`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:
|
with open(messages_source_path, 'r') as messages_source_file:
|
||||||
num_source_messages = messages_source_file.read().count("msgid \"") - 1
|
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:
|
if not locale_tuple_list:
|
||||||
raise Exception(f"Invalid locale: {locale}")
|
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:
|
with open(os.path.join("tests", "screenshot_generator", "template.md"), 'r') as readme_template:
|
||||||
main_readme = readme_template.read()
|
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"
|
main_readme += f"* [{display_name}]({locale}/README.md)\n"
|
||||||
|
|
||||||
with open(os.path.join(screenshot_root, "README.md"), 'w') as readme_file:
|
with open(os.path.join(screenshot_root, "README.md"), 'w') as readme_file:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from seedsigner.views.view import MainMenuView
|
|||||||
class TestL10nFlows(FlowTest):
|
class TestL10nFlows(FlowTest):
|
||||||
def test_change_locale(self):
|
def test_change_locale(self):
|
||||||
settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__LOCALE)
|
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
|
# Initially we get English
|
||||||
assert _(MainMenuView.SCAN.button_label) == "Scan"
|
assert _(MainMenuView.SCAN.button_label) == "Scan"
|
||||||
@@ -21,7 +21,7 @@ class TestL10nFlows(FlowTest):
|
|||||||
self.run_sequence([
|
self.run_sequence([
|
||||||
FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS),
|
FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS),
|
||||||
FlowStep(settings_views.SettingsMenuView, button_data_selection=ButtonOption(settings_entry.display_name)),
|
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
|
# Now we don't get English
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user