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:
Shane Smiskol
2025-10-22 16:28:28 -07:00
committed by GitHub
parent 856f8d3d47
commit 4ccafff123
29 changed files with 8874 additions and 269 deletions
+11 -1
View File
@@ -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)
+68 -2
View File
@@ -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
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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)
+10 -2
View File
@@ -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))
+21 -17
View File
@@ -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)
+16 -14
View File
@@ -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)