raylib: unifont for CJK languages (#36430)

* add rest of langs

* unifont

* all langs are supported

* add japanese translations

* fix strip!

* add language name chars

* use unifont in lang selection

* add korean

* test all langs

* doesn't work

* unifont font fallback for multilang

* add ar translations

* fix labels not updating until scrolling

* t chinese

* more chn

* we already default

* wrap

* update

* fix thai

* fix missing chinese langs and all are supported!

* clean up

* update

* ??? mypy r u ok ???

* fix default option font weight
This commit is contained in:
Shane Smiskol
2025-10-22 19:18:54 -07:00
committed by GitHub
parent b14270bd71
commit a0d48b6c63
23 changed files with 7700 additions and 373 deletions
+15 -4
View File
@@ -51,6 +51,14 @@ class FontWeight(StrEnum):
BOLD = "Inter-Bold.ttf"
EXTRA_BOLD = "Inter-ExtraBold.ttf"
BLACK = "Inter-Black.ttf"
UNIFONT = "unifont.otf"
def font_fallback(font: rl.Font) -> rl.Font:
"""Fall back to unifont for languages that require it."""
if multilang.requires_unifont():
return gui_app.font(FontWeight.UNIFONT)
return font
@dataclass
@@ -335,7 +343,7 @@ class GuiApplication:
except KeyboardInterrupt:
pass
def font(self, font_weight: FontWeight = FontWeight.NORMAL):
def font(self, font_weight: FontWeight = FontWeight.NORMAL) -> rl.Font:
return self._fonts[font_weight]
@property
@@ -356,14 +364,16 @@ class GuiApplication:
all_chars |= set("–‑✓×°§•")
# Load only the characters used in translations
for language in multilang.codes:
for language, code in multilang.languages.items():
all_chars |= set(language)
try:
with open(os.path.join(TRANSLATIONS_DIR, f"app_{language}.po")) as f:
with open(os.path.join(TRANSLATIONS_DIR, f"app_{code}.po")) as f:
all_chars |= set(f.read())
except FileNotFoundError:
cloudlog.warning(f"Translation file for language '{language}' not found when loading fonts.")
cloudlog.warning(f"Translation file for language '{code}' not found when loading fonts.")
all_chars = "".join(all_chars)
cloudlog.debug(f"Loading fonts with {len(all_chars)} glyphs.")
codepoint_count = rl.ffi.new("int *", 1)
codepoints = rl.load_codepoints(all_chars, codepoint_count)
@@ -390,6 +400,7 @@ class GuiApplication:
rl._orig_draw_text_ex = rl.draw_text_ex
def _draw_text_ex_scaled(font, text, position, font_size, spacing, tint):
font = font_fallback(font)
return rl._orig_draw_text_ex(font, text, position, font_size * FONT_SCALE, spacing, tint)
rl.draw_text_ex = _draw_text_ex_scaled
+24 -18
View File
@@ -3,25 +3,27 @@ import json
import gettext
from openpilot.common.params import Params
from openpilot.common.basedir import BASEDIR
from openpilot.common.swaglog import cloudlog
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")
SUPPORTED_LANGUAGES = [
"en",
"de",
"fr",
"pt-BR",
"es",
"tr",
UNIFONT_LANGUAGES = [
"ar",
"th",
"zh-CHT",
"zh-CHS",
"ko",
"ja",
]
class Multilang:
def __init__(self):
self._params = Params()
self._language: str = "en"
self.languages = {}
self.codes = {}
self._translation: gettext.NullTranslations | gettext.GNUTranslations = gettext.NullTranslations()
@@ -29,28 +31,28 @@ class Multilang:
@property
def language(self) -> str:
lang = str(self._params.get("LanguageSetting")).strip("main_")
if lang not in SUPPORTED_LANGUAGES:
lang = "en"
return lang
return self._language
def requires_unifont(self) -> bool:
"""Certain languages require unifont to render their glyphs."""
return self._language in UNIFONT_LANGUAGES
def setup(self):
language = self.language
try:
with open(os.path.join(TRANSLATIONS_DIR, f'app_{language}.mo'), 'rb') as fh:
with open(os.path.join(TRANSLATIONS_DIR, f'app_{self._language}.mo'), 'rb') as fh:
translation = gettext.GNUTranslations(fh)
translation.install()
self._translation = translation
print(f"Loaded translations for language: {language}")
cloudlog.warning(f"Loaded translations for language: {self._language}")
except FileNotFoundError:
print(f"No translation file found for language: {language}, using default.")
cloudlog.error(f"No translation file found for language: {self._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._language = language_code
self.setup()
def tr(self, text: str) -> str:
@@ -61,8 +63,12 @@ class Multilang:
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}
self.languages = json.load(f)
self.codes = {v: k for k, v in self.languages.items()}
lang = str(self._params.get("LanguageSetting")).removeprefix("main_")
if lang in self.codes:
self._language = lang
multilang = Multilang()
+2 -1
View File
@@ -1,5 +1,5 @@
import pyray as rl
from openpilot.system.ui.lib.application import FONT_SCALE
from openpilot.system.ui.lib.application import FONT_SCALE, font_fallback
from openpilot.system.ui.lib.emoji import find_emoji
_cache: dict[int, rl.Vector2] = {}
@@ -7,6 +7,7 @@ _cache: dict[int, rl.Vector2] = {}
def measure_text_cached(font: rl.Font, text: str, font_size: int, spacing: int = 0) -> rl.Vector2:
"""Caches text measurements to avoid redundant calculations."""
font = font_fallback(font)
key = hash((font.texture.id, text, font_size, spacing))
if key in _cache:
return _cache[key]
+2
View File
@@ -1,5 +1,6 @@
import pyray as rl
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.application import font_fallback
def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int) -> list[str]:
@@ -40,6 +41,7 @@ _cache: dict[int, list[str]] = {}
def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int) -> list[str]:
font = font_fallback(font)
key = hash((font.texture.id, text, font_size, max_width))
if key in _cache:
return _cache[key]
+4 -3
View File
@@ -134,9 +134,6 @@ class Label(Widget):
self._font_size = size
self._update_text(self._text)
def _update_layout_rects(self):
self._update_text(self._text)
def _update_text(self, text):
self._emojis = []
self._text_size = []
@@ -170,6 +167,10 @@ class Label(Widget):
self._text_size.append(measure_text_cached(self._font, t, self._font_size))
def _render(self, _):
# Text can be a callable
# TODO: cache until text changed
self._update_text(self._text)
text_size = self._text_size[0] if self._text_size else rl.Vector2(0.0, 0.0)
if self._text_alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE:
text_pos = rl.Vector2(self._rect.x, (self._rect.y + (self._rect.height - text_size.y) // 2))
+2 -1
View File
@@ -17,7 +17,7 @@ LIST_ITEM_SPACING = 25
class MultiOptionDialog(Widget):
def __init__(self, title, options, current=""):
def __init__(self, title, options, current="", option_font_weight=FontWeight.MEDIUM):
super().__init__()
self.title = title
self.options = options
@@ -27,6 +27,7 @@ class MultiOptionDialog(Widget):
# Create scroller with option buttons
self.option_buttons = [Button(option, click_callback=lambda opt=option: self._on_option_clicked(opt),
font_weight=option_font_weight,
text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, button_style=ButtonStyle.NORMAL,
text_padding=50, elide_right=True) for option in options]
self.scroller = Scroller(self.option_buttons, spacing=LIST_ITEM_SPACING)