incorporating changes to support Asian languages, other improvements

This commit is contained in:
kdmukai
2025-03-23 09:32:09 -05:00
parent 53f2a78f63
commit b99548a294
6 changed files with 206 additions and 97 deletions
+72 -41
View File
@@ -48,31 +48,33 @@ 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 +87,15 @@ 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 +106,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 +116,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 +144,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 +154,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 +1842,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 +1864,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 +1872,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 +1891,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
+12 -22
View File
@@ -277,6 +277,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
@@ -324,38 +326,26 @@ class ButtonListScreen(BaseTopNavScreen):
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
)
+45 -30
View File
@@ -108,63 +108,76 @@ class SettingsConstants:
LOCALE__URDU = "ur"
LOCALE__VIETNAMESE = "vi"
# Do not wrap for translation. Present each language in its native form. Sort by
# native form when in Western chars, otherwise sort by English name.
# 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 = {
LOCALE__ARABIC: "العربية (Arabic)",
LOCALE__BENGALI: "বাংলা (Bengali)",
LOCALE__BULGARIAN: "български (Bulgarian)",
# --------- Fully supported languages -------------------------------------------
LOCALE__CATALAN: "Català",
LOCALE__CHINESE_SIMPLIFIED: "简体中文 (Chinese Simplified)",
LOCALE__CHINESE_TRADITIONAL: "繁體中文 (Chinese Traditional)",
LOCALE__CZECH: "čeština",
LOCALE__DANISH: "Dansk",
LOCALE__GERMAN: "Deutsch",
LOCALE__ESTONIAN: "Eesti",
LOCALE__EGYPTIAN: "مصرى (Egyptian)",
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)",
LOCALE__GUJARATI: "ગુજરાતી (Gujarati)",
LOCALE__GREEK: "Ελληνικά (Greek)", # OpenSans includes Greek chars
# LOCALE__GUJARATI: "ગુજરાતી (Gujarati)",
LOCALE__HAUSA: "Hausa",
LOCALE__HEBREW: "עברית (Hebrew)",
LOCALE__HINDI: "हिन्दी (Hindi)",
# LOCALE__HEBREW: "עברית (Hebrew)",
# LOCALE__HINDI: "हिन्दी (Hindi)",
LOCALE__CROATIAN: "Hrvatski",
LOCALE__ITALIAN: "Italiano",
LOCALE__INDONESIAN: "Indonesia",
LOCALE__JAPANESE: "日本語 (Japanese)",
LOCALE__JAVANESE: "Jawa (Javanese)",
LOCALE__LAO: "ລາວ (Lao)",
# LOCALE__LAO: "ລາວ (Lao)",
LOCALE__LATVIAN: "Latviešu",
LOCALE__LITHUANIAN: "Lietuvių",
LOCALE__HUNGARIAN: "Magyar",
LOCALE__MALAY: "Melayu",
LOCALE__MALTESE: "Malti",
LOCALE__MARATHI: "मराठी (Marathi)",
LOCALE__KOREAN: "한국어 (Korean)",
LOCALE__DUTCH: "Nederlands",
# LOCALE__MARATHI: "मराठी (Marathi)",
LOCALE__NORWEGIAN: "Norsk",
LOCALE__PERSIAN: "فارسی (Persian)",
# LOCALE__PERSIAN: "فارسی (Persian)",
LOCALE__POLISH: "Polski",
LOCALE__PORTUGUESE_BR: "Português (Brasil)",
LOCALE__PORTUGUESE_PT: "Português (Portugal)",
LOCALE__PUNJABI: "ਪੰਜਾਬੀ (Punjabi)",
# LOCALE__PUNJABI: "ਪੰਜਾਬੀ (Punjabi)",
LOCALE__ROMANIAN: "Română",
LOCALE__RUSSIAN: "русский (Russian)",
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__TAMIL: "தமிழ் (Tamil)",
# LOCALE__TELUGU: "తెలుగు (Telugu)",
# LOCALE__THAI: "ไทย (Thai)",
LOCALE__TURKISH: "Türkçe",
LOCALE__UKRANIAN: "українська (Ukranian)",
LOCALE__URDU: "اردو (Urdu)",
LOCALE__UKRANIAN: "українська (Ukranian)", # OpenSans includes cyrillic chars
# LOCALE__URDU: "اردو (Urdu)",
LOCALE__VIETNAMESE: "Tiếng Việt (Vietnamese)",
}
@@ -178,16 +191,18 @@ class SettingsConstants:
# Will normally be the launch dir (where main.py is located)...
cwd = os.getcwd()
# ...except when running the tests
# ...except when running the tests which happens one dir higher
if "src" not in cwd:
cwd = os.path.join(cwd, "src")
# Always list English first (sorry, world)
# 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():
+38 -3
View File
@@ -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,52 @@ 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):
button_data: list[ButtonOption] = []
for language_code, display_name in 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),
)
)
selected_menu_num = self.run_screen(
ButtonListScreen,
button_data=button_data,
title=_(SettingsDefinition.get_settings_entry(attr_name=SettingsConstants.SETTING__LOCALE).display_name),
is_button_text_centered=False,
)
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,
+1 -1
View File
@@ -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
+38
View File
@@ -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