mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-30 19:33:45 +08:00
raylib: multilang (#36195)
* fix multilang dialog height
* split to file
* stash
* Revert "stash"
This reverts commit deb4239fe69f0260420fad03f2350e622e31542f.
* add updater
* add files
* stuff
* try
rev
* stash
* works!
* works!
* this should be the flow?
* cursor wrapping -- it missed entire sections, changed formatting, and didn't use trn properly!!!!!!!!!!!!!!!!!
* update translations
* learned my lesson
* this should be the one thing it's good at
* update trans
* onroad wrap
* spanish
* rename
* clean up
* load all
* Revert "load all"
This reverts commit 6f2a45861c914ffb9d40a5edd15751afd798d614.
* jp translations
* try jp
* Revert "try jp"
This reverts commit d0524b10110104baafcdc1ec385c3d57bc5ef901.
* remove languages we can't add rn
* tr
* pt and fr
* ai cannot be trusted
* ai cannot be trusted
* missing trans
* add fonts
* Revert "remove languages we can't add rn"
This reverts commit 73dc75fae2b9e347d867b6636dab6e2b5fe59da7.
* painfully slow to startup
* only load what we need
* Reapply "remove languages we can't add rn"
This reverts commit 52cb48f3b838520a421f9b90e5ea4409c27d4bd0.
* add system
* that's sick that this just works (dynamic)
* fix description falling back to first str + support callable titles in list items
* device is now live!
* make firehose live
* developer
* network live
* software live
* and that
* toggles live
* regen
* start to clean up gpt
* revert op sans
* bruh
* update translations
* rm old script
* add noops for descriptions to fix translating away from non-english after startup
* missing de
* do filtering in multilang.py
* clean up
clean up
* codespell: ignore po
* fix update
* should not depend
* more live
* sidebar and offroad alert panel live
* fix issues with offroad alerts
* fix firehose live
* fix weird tr("") behavior
* sh key live bugfix
* setup.py live
* update
* update
* no fuzzy matching -- breaks dynamic translations
* rm this
* fix calib desc live trans
* change onroad
* rm dfonts
* clean up device
* missing live
* update
* op lint
* not true
* add to gitignore
* speed up startup by reducing chars by ~half
* fix scons
* fix crash going from qt
* preserve original lang
* cancel kb live translate
* no preserve
* fix lint
This commit is contained in:
@@ -14,6 +14,7 @@ from typing import NamedTuple
|
||||
from importlib.resources import as_file, files
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.system.hardware import HARDWARE, PC, TICI
|
||||
from openpilot.system.ui.lib.multilang import TRANSLATIONS_DIR, multilang
|
||||
from openpilot.common.realtime import Ratekeeper
|
||||
|
||||
_DEFAULT_FPS = int(os.getenv("FPS", 20 if TICI else 60))
|
||||
@@ -352,8 +353,17 @@ class GuiApplication:
|
||||
all_chars = set()
|
||||
for layout in KEYBOARD_LAYOUTS.values():
|
||||
all_chars.update(key for row in layout for key in row)
|
||||
all_chars |= set("–‑✓×°§•")
|
||||
|
||||
# Load only the characters used in translations
|
||||
for language in multilang.codes:
|
||||
try:
|
||||
with open(os.path.join(TRANSLATIONS_DIR, f"app_{language}.po")) as f:
|
||||
all_chars |= set(f.read())
|
||||
except FileNotFoundError:
|
||||
cloudlog.warning(f"Translation file for language '{language}' not found when loading fonts.")
|
||||
|
||||
all_chars = "".join(all_chars)
|
||||
all_chars += "–✓×°§•"
|
||||
|
||||
codepoint_count = rl.ffi.new("int *", 1)
|
||||
codepoints = rl.load_codepoints(all_chars, codepoint_count)
|
||||
|
||||
@@ -1,10 +1,76 @@
|
||||
import os
|
||||
import json
|
||||
import gettext
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.basedir import BASEDIR
|
||||
|
||||
SYSTEM_UI_DIR = os.path.join(BASEDIR, "system", "ui")
|
||||
UI_DIR = os.path.join(BASEDIR, "selfdrive", "ui")
|
||||
TRANSLATIONS_DIR = os.path.join(UI_DIR, "translations")
|
||||
LANGUAGES_FILE = os.path.join(TRANSLATIONS_DIR, "languages.json")
|
||||
|
||||
tr = gettext.gettext
|
||||
trn = gettext.ngettext
|
||||
SUPPORTED_LANGUAGES = [
|
||||
"en",
|
||||
"de",
|
||||
"fr",
|
||||
"pt-BR",
|
||||
"es",
|
||||
"tr",
|
||||
]
|
||||
|
||||
|
||||
class Multilang:
|
||||
def __init__(self):
|
||||
self._params = Params()
|
||||
self.languages = {}
|
||||
self.codes = {}
|
||||
self._translation: gettext.NullTranslations | gettext.GNUTranslations = gettext.NullTranslations()
|
||||
self._load_languages()
|
||||
|
||||
@property
|
||||
def language(self) -> str:
|
||||
lang = str(self._params.get("LanguageSetting")).strip("main_")
|
||||
if lang not in SUPPORTED_LANGUAGES:
|
||||
lang = "en"
|
||||
return lang
|
||||
|
||||
def setup(self):
|
||||
language = self.language
|
||||
try:
|
||||
with open(os.path.join(TRANSLATIONS_DIR, f'app_{language}.mo'), 'rb') as fh:
|
||||
translation = gettext.GNUTranslations(fh)
|
||||
translation.install()
|
||||
self._translation = translation
|
||||
print(f"Loaded translations for language: {language}")
|
||||
except FileNotFoundError:
|
||||
print(f"No translation file found for language: {language}, using default.")
|
||||
gettext.install('app')
|
||||
self._translation = gettext.NullTranslations()
|
||||
return None
|
||||
|
||||
def change_language(self, language_code: str) -> None:
|
||||
# Reinstall gettext with the selected language
|
||||
self._params.put("LanguageSetting", language_code)
|
||||
self.setup()
|
||||
|
||||
def tr(self, text: str) -> str:
|
||||
return self._translation.gettext(text)
|
||||
|
||||
def trn(self, singular: str, plural: str, n: int) -> str:
|
||||
return self._translation.ngettext(singular, plural, n)
|
||||
|
||||
def _load_languages(self):
|
||||
with open(LANGUAGES_FILE, encoding='utf-8') as f:
|
||||
self.languages = {k: v for k, v in json.load(f).items() if v in SUPPORTED_LANGUAGES}
|
||||
self.codes = {v: k for k, v in self.languages.items() if v in SUPPORTED_LANGUAGES}
|
||||
|
||||
|
||||
multilang = Multilang()
|
||||
multilang.setup()
|
||||
|
||||
tr, trn = multilang.tr, multilang.trn
|
||||
|
||||
|
||||
# no-op marker for static strings translated later
|
||||
def tr_noop(s: str) -> str:
|
||||
return s
|
||||
|
||||
@@ -79,7 +79,7 @@ BUTTON_DISABLED_BACKGROUND_COLORS = {
|
||||
|
||||
class Button(Widget):
|
||||
def __init__(self,
|
||||
text: str,
|
||||
text: str | Callable[[], str],
|
||||
click_callback: Callable[[], None] | None = None,
|
||||
font_size: int = DEFAULT_BUTTON_FONT_SIZE,
|
||||
font_weight: FontWeight = FontWeight.MEDIUM,
|
||||
|
||||
@@ -78,7 +78,7 @@ class Keyboard(Widget):
|
||||
self._backspace_last_repeat: float = 0.0
|
||||
|
||||
self._render_return_status = -1
|
||||
self._cancel_button = Button(tr("Cancel"), self._cancel_button_callback)
|
||||
self._cancel_button = Button(lambda: tr("Cancel"), self._cancel_button_callback)
|
||||
|
||||
self._eye_button = Button("", self._eye_button_callback, button_style=ButtonStyle.TRANSPARENT)
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from collections.abc import Callable
|
||||
from itertools import zip_longest
|
||||
from typing import Union
|
||||
import pyray as rl
|
||||
@@ -12,6 +13,13 @@ from openpilot.system.ui.widgets import Widget
|
||||
ICON_PADDING = 15
|
||||
|
||||
|
||||
# TODO: make this common
|
||||
def _resolve_value(value, default=""):
|
||||
if callable(value):
|
||||
return value()
|
||||
return value if value is not None else default
|
||||
|
||||
|
||||
# TODO: This should be a Widget class
|
||||
def gui_label(
|
||||
rect: rl.Rectangle,
|
||||
@@ -90,7 +98,7 @@ def gui_text_box(
|
||||
# Non-interactive text area. Can render emojis and an optional specified icon.
|
||||
class Label(Widget):
|
||||
def __init__(self,
|
||||
text: str,
|
||||
text: str | Callable[[], str],
|
||||
font_size: int = DEFAULT_TEXT_SIZE,
|
||||
font_weight: FontWeight = FontWeight.NORMAL,
|
||||
text_alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
|
||||
@@ -130,7 +138,7 @@ class Label(Widget):
|
||||
def _update_text(self, text):
|
||||
self._emojis = []
|
||||
self._text_size = []
|
||||
self._text_wrapped = wrap_text(self._font, text, self._font_size, round(self._rect.width - (self._text_padding * 2)))
|
||||
self._text_wrapped = wrap_text(self._font, _resolve_value(text), self._font_size, round(self._rect.width - (self._text_padding * 2)))
|
||||
for t in self._text_wrapped:
|
||||
self._emojis.append(find_emoji(t))
|
||||
self._text_size.append(measure_text_cached(self._font, t, self._font_size))
|
||||
|
||||
@@ -171,11 +171,9 @@ class TextAction(ItemAction):
|
||||
|
||||
|
||||
class DualButtonAction(ItemAction):
|
||||
def __init__(self, left_text: str, right_text: str, left_callback: Callable = None,
|
||||
def __init__(self, left_text: str | Callable[[], str], right_text: str | Callable[[], str], left_callback: Callable = None,
|
||||
right_callback: Callable = None, enabled: bool | Callable[[], bool] = True):
|
||||
super().__init__(width=0, enabled=enabled) # Width 0 means use full width
|
||||
self.left_text, self.right_text = left_text, right_text
|
||||
|
||||
self.left_button = Button(left_text, click_callback=left_callback, button_style=ButtonStyle.NORMAL, text_padding=0)
|
||||
self.right_button = Button(right_text, click_callback=right_callback, button_style=ButtonStyle.DANGER, text_padding=0)
|
||||
|
||||
@@ -206,7 +204,7 @@ class DualButtonAction(ItemAction):
|
||||
|
||||
|
||||
class MultipleButtonAction(ItemAction):
|
||||
def __init__(self, buttons: list[str], button_width: int, selected_index: int = 0, callback: Callable = None):
|
||||
def __init__(self, buttons: list[str | Callable[[], str]], button_width: int, selected_index: int = 0, callback: Callable = None):
|
||||
super().__init__(width=len(buttons) * button_width + (len(buttons) - 1) * RIGHT_ITEM_PADDING, enabled=True)
|
||||
self.buttons = buttons
|
||||
self.button_width = button_width
|
||||
@@ -225,7 +223,7 @@ class MultipleButtonAction(ItemAction):
|
||||
spacing = RIGHT_ITEM_PADDING
|
||||
button_y = rect.y + (rect.height - BUTTON_HEIGHT) / 2
|
||||
|
||||
for i, text in enumerate(self.buttons):
|
||||
for i, _text in enumerate(self.buttons):
|
||||
button_x = rect.x + i * (self.button_width + spacing)
|
||||
button_rect = rl.Rectangle(button_x, button_y, self.button_width, BUTTON_HEIGHT)
|
||||
|
||||
@@ -249,6 +247,7 @@ class MultipleButtonAction(ItemAction):
|
||||
rl.draw_rectangle_rounded(button_rect, 1.0, 20, bg_color)
|
||||
|
||||
# Draw text
|
||||
text = _resolve_value(_text, "")
|
||||
text_size = measure_text_cached(self._font, text, 40)
|
||||
text_x = button_x + (self.button_width - text_size.x) / 2
|
||||
text_y = button_y + (BUTTON_HEIGHT - text_size.y) / 2
|
||||
@@ -258,7 +257,7 @@ class MultipleButtonAction(ItemAction):
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
spacing = RIGHT_ITEM_PADDING
|
||||
button_y = self._rect.y + (self._rect.height - BUTTON_HEIGHT) / 2
|
||||
for i, _text in enumerate(self.buttons):
|
||||
for i, _ in enumerate(self.buttons):
|
||||
button_x = self._rect.x + i * (self.button_width + spacing)
|
||||
button_rect = rl.Rectangle(button_x, button_y, self.button_width, BUTTON_HEIGHT)
|
||||
if rl.check_collision_point_rec(mouse_pos, button_rect):
|
||||
@@ -268,11 +267,11 @@ class MultipleButtonAction(ItemAction):
|
||||
|
||||
|
||||
class ListItem(Widget):
|
||||
def __init__(self, title: str = "", icon: str | None = None, description: str | Callable[[], str] | None = None,
|
||||
def __init__(self, title: str | Callable[[], str] = "", icon: str | None = None, description: str | Callable[[], str] | None = None,
|
||||
description_visible: bool = False, callback: Callable | None = None,
|
||||
action_item: ItemAction | None = None):
|
||||
super().__init__()
|
||||
self.title = title
|
||||
self._title = title
|
||||
self.set_icon(icon)
|
||||
self._description = description
|
||||
self.description_visible = description_visible
|
||||
@@ -285,7 +284,7 @@ class ListItem(Widget):
|
||||
|
||||
self._html_renderer = HtmlRenderer(text="", text_size={ElementType.P: ITEM_DESC_FONT_SIZE},
|
||||
text_color=ITEM_DESC_TEXT_COLOR)
|
||||
self.set_description(self.description)
|
||||
self._parse_description(self.description)
|
||||
|
||||
# Cached properties for performance
|
||||
self._prev_description: str | None = self.description
|
||||
@@ -332,7 +331,7 @@ class ListItem(Widget):
|
||||
# Detect changes if description is callback
|
||||
new_description = self.description
|
||||
if new_description != self._prev_description:
|
||||
self.set_description(new_description)
|
||||
self._parse_description(new_description)
|
||||
|
||||
def _render(self, _):
|
||||
if not self.is_visible:
|
||||
@@ -385,10 +384,15 @@ class ListItem(Widget):
|
||||
|
||||
def set_description(self, description: str | Callable[[], str] | None):
|
||||
self._description = description
|
||||
new_desc = self.description
|
||||
|
||||
def _parse_description(self, new_desc):
|
||||
self._html_renderer.parse_html_content(new_desc)
|
||||
self._prev_description = new_desc
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
return _resolve_value(self._title, "")
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
return _resolve_value(self._description, "")
|
||||
@@ -423,35 +427,35 @@ class ListItem(Widget):
|
||||
|
||||
|
||||
# Factory functions
|
||||
def simple_item(title: str, callback: Callable | None = None) -> ListItem:
|
||||
def simple_item(title: str | Callable[[], str], callback: Callable | None = None) -> ListItem:
|
||||
return ListItem(title=title, callback=callback)
|
||||
|
||||
|
||||
def toggle_item(title: str, description: str | Callable[[], str] | None = None, initial_state: bool = False,
|
||||
def toggle_item(title: str | Callable[[], str], description: str | Callable[[], str] | None = None, initial_state: bool = False,
|
||||
callback: Callable | None = None, icon: str = "", enabled: bool | Callable[[], bool] = True) -> ListItem:
|
||||
action = ToggleAction(initial_state=initial_state, enabled=enabled, callback=callback)
|
||||
return ListItem(title=title, description=description, action_item=action, icon=icon)
|
||||
|
||||
|
||||
def button_item(title: str, button_text: str | Callable[[], str], description: str | Callable[[], str] | None = None,
|
||||
def button_item(title: str | Callable[[], str], button_text: str | Callable[[], str], description: str | Callable[[], str] | None = None,
|
||||
callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem:
|
||||
action = ButtonAction(text=button_text, enabled=enabled)
|
||||
return ListItem(title=title, description=description, action_item=action, callback=callback)
|
||||
|
||||
|
||||
def text_item(title: str, value: str | Callable[[], str], description: str | Callable[[], str] | None = None,
|
||||
def text_item(title: str | Callable[[], str], value: str | Callable[[], str], description: str | Callable[[], str] | None = None,
|
||||
callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem:
|
||||
action = TextAction(text=value, color=ITEM_TEXT_VALUE_COLOR, enabled=enabled)
|
||||
return ListItem(title=title, description=description, action_item=action, callback=callback)
|
||||
|
||||
|
||||
def dual_button_item(left_text: str, right_text: str, left_callback: Callable = None, right_callback: Callable = None,
|
||||
def dual_button_item(left_text: str | Callable[[], str], right_text: str | Callable[[], str], left_callback: Callable = None, right_callback: Callable = None,
|
||||
description: str | Callable[[], str] | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem:
|
||||
action = DualButtonAction(left_text, right_text, left_callback, right_callback, enabled)
|
||||
return ListItem(title="", description=description, action_item=action)
|
||||
|
||||
|
||||
def multiple_button_item(title: str, description: str, buttons: list[str], selected_index: int,
|
||||
def multiple_button_item(title: str | Callable[[], str], description: str | Callable[[], str], buttons: list[str | Callable[[], str]], selected_index: int,
|
||||
button_width: int = BUTTON_WIDTH, callback: Callable = None, icon: str = ""):
|
||||
action = MultipleButtonAction(buttons, button_width, selected_index, callback=callback)
|
||||
return ListItem(title=title, description=description, icon=icon, action_item=action)
|
||||
|
||||
@@ -117,40 +117,42 @@ class AdvancedNetworkSettings(Widget):
|
||||
|
||||
# Tethering
|
||||
self._tethering_action = ToggleAction(initial_state=False)
|
||||
tethering_btn = ListItem(title=tr("Enable Tethering"), action_item=self._tethering_action, callback=self._toggle_tethering)
|
||||
tethering_btn = ListItem(lambda: tr("Enable Tethering"), action_item=self._tethering_action, callback=self._toggle_tethering)
|
||||
|
||||
# Edit tethering password
|
||||
self._tethering_password_action = ButtonAction(text=tr("EDIT"))
|
||||
tethering_password_btn = ListItem(title=tr("Tethering Password"), action_item=self._tethering_password_action, callback=self._edit_tethering_password)
|
||||
self._tethering_password_action = ButtonAction(lambda: tr("EDIT"))
|
||||
tethering_password_btn = ListItem(lambda: tr("Tethering Password"), action_item=self._tethering_password_action, callback=self._edit_tethering_password)
|
||||
|
||||
# Roaming toggle
|
||||
roaming_enabled = self._params.get_bool("GsmRoaming")
|
||||
self._roaming_action = ToggleAction(initial_state=roaming_enabled)
|
||||
self._roaming_btn = ListItem(title=tr("Enable Roaming"), action_item=self._roaming_action, callback=self._toggle_roaming)
|
||||
self._roaming_btn = ListItem(lambda: tr("Enable Roaming"), action_item=self._roaming_action, callback=self._toggle_roaming)
|
||||
|
||||
# Cellular metered toggle
|
||||
cellular_metered = self._params.get_bool("GsmMetered")
|
||||
self._cellular_metered_action = ToggleAction(initial_state=cellular_metered)
|
||||
self._cellular_metered_btn = ListItem(title=tr("Cellular Metered"), description=tr("Prevent large data uploads when on a metered cellular connection"),
|
||||
self._cellular_metered_btn = ListItem(lambda: tr("Cellular Metered"),
|
||||
description=lambda: tr("Prevent large data uploads when on a metered cellular connection"),
|
||||
action_item=self._cellular_metered_action, callback=self._toggle_cellular_metered)
|
||||
|
||||
# APN setting
|
||||
self._apn_btn = button_item(tr("APN Setting"), tr("EDIT"), callback=self._edit_apn)
|
||||
self._apn_btn = button_item(lambda: tr("APN Setting"), lambda: tr("EDIT"), callback=self._edit_apn)
|
||||
|
||||
# Wi-Fi metered toggle
|
||||
self._wifi_metered_action = MultipleButtonAction([tr("default"), tr("metered"), tr("unmetered")], 255, 0, callback=self._toggle_wifi_metered)
|
||||
wifi_metered_btn = ListItem(title=tr("Wi-Fi Network Metered"), description=tr("Prevent large data uploads when on a metered Wi-Fi connection"),
|
||||
self._wifi_metered_action = MultipleButtonAction([lambda: tr("default"), lambda: tr("metered"), lambda: tr("unmetered")], 255, 0,
|
||||
callback=self._toggle_wifi_metered)
|
||||
wifi_metered_btn = ListItem(lambda: tr("Wi-Fi Network Metered"), description=lambda: tr("Prevent large data uploads when on a metered Wi-Fi connection"),
|
||||
action_item=self._wifi_metered_action)
|
||||
|
||||
items: list[Widget] = [
|
||||
tethering_btn,
|
||||
tethering_password_btn,
|
||||
text_item(tr("IP Address"), lambda: self._wifi_manager.ipv4_address),
|
||||
text_item(lambda: tr("IP Address"), lambda: self._wifi_manager.ipv4_address),
|
||||
self._roaming_btn,
|
||||
self._apn_btn,
|
||||
self._cellular_metered_btn,
|
||||
wifi_metered_btn,
|
||||
button_item(tr("Hidden Network"), tr("CONNECT"), callback=self._connect_to_hidden_network),
|
||||
button_item(lambda: tr("Hidden Network"), lambda: tr("CONNECT"), callback=self._connect_to_hidden_network),
|
||||
]
|
||||
|
||||
self._scroller = Scroller(items, line_separator=True, spacing=0)
|
||||
@@ -282,7 +284,6 @@ class WifiManagerUI(Widget):
|
||||
self._networks: list[Network] = []
|
||||
self._networks_buttons: dict[str, Button] = {}
|
||||
self._forget_networks_buttons: dict[str, Button] = {}
|
||||
self._confirm_dialog = ConfirmDialog("", tr("Forget"), tr("Cancel"))
|
||||
|
||||
self._wifi_manager.set_callbacks(need_auth=self._on_need_auth,
|
||||
activated=self._on_activated,
|
||||
@@ -314,9 +315,10 @@ class WifiManagerUI(Widget):
|
||||
self.keyboard.reset(min_text_size=MIN_PASSWORD_LENGTH)
|
||||
gui_app.set_modal_overlay(self.keyboard, lambda result: self._on_password_entered(cast(Network, self._state_network), result))
|
||||
elif self.state == UIState.SHOW_FORGET_CONFIRM and self._state_network:
|
||||
self._confirm_dialog.set_text(tr("Forget Wi-Fi Network \"{}\"?").format(self._state_network.ssid))
|
||||
self._confirm_dialog.reset()
|
||||
gui_app.set_modal_overlay(self._confirm_dialog, callback=lambda result: self.on_forgot_confirm_finished(self._state_network, result))
|
||||
confirm_dialog = ConfirmDialog("", tr("Forget"), tr("Cancel"))
|
||||
confirm_dialog.set_text(tr("Forget Wi-Fi Network \"{}\"?").format(self._state_network.ssid))
|
||||
confirm_dialog.reset()
|
||||
gui_app.set_modal_overlay(confirm_dialog, callback=lambda result: self.on_forgot_confirm_finished(self._state_network, result))
|
||||
else:
|
||||
self._draw_network_list(rect)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user