Translate Galaxy and native UI through the shared language setting

This commit is contained in:
firestar5683
2026-10-30 09:43:29 -05:00
parent 5e057b9848
commit 7acb954a6d
42 changed files with 186506 additions and 124 deletions
@@ -6,9 +6,9 @@ The official upstream source fonts are licensed under Noto-OFL.txt.
NotoSansCJKjp-Regular.otf
Source: https://raw.githubusercontent.com/notofonts/noto-cjk/main/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf
Source SHA-256: 68a3fc98800b2a27b371f2fb79991daf3633bd89309d4ffaa6946fd587f375b5
Subset SHA-256: 7d165e774b66b35b3d0b814aacf8739525d04110bc4f7d316d45378a4758684b
Subset SHA-256: 55eecf7ef2e0805e18476213adfc362c5c2a2205f0742e0e96e3ddc6d4665d32
NotoSansCJKkr-Regular.otf
Source: https://raw.githubusercontent.com/notofonts/noto-cjk/main/Sans/OTF/Korean/NotoSansCJKkr-Regular.otf
Source SHA-256: 6bcb2a0703aa137e874fc2dffa85f6c21ba9a67fa329e81b8c801663af7e992a
Subset SHA-256: 6ee92618137820bfb2671c0601f43cb797ee4b440e52497d0b9fb5e1c923c19c
Subset SHA-256: 846e22c2bf1afffc11718ee95e4acc258538cc528b20aab3aa122476e64533b8
+8 -7
View File
@@ -136,12 +136,13 @@ class TermsPage(Widget):
self._on_accept = on_accept
self._on_decline = on_decline
self._title = Label(tr("Welcome to openpilot"), font_size=90, font_weight=FontWeight.BOLD, text_alignment=TextAlignment.LEFT)
self._desc = Label(tr("You must accept the Terms and Conditions to use openpilot. Read the latest terms at https://comma.ai/terms before continuing."),
self._title = Label(lambda: tr("Welcome to openpilot"), font_size=90, font_weight=FontWeight.BOLD, text_alignment=TextAlignment.LEFT)
self._desc = Label(
lambda: tr("You must accept the Terms and Conditions to use openpilot. Read the latest terms at https://comma.ai/terms before continuing."),
font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=TextAlignment.LEFT)
self._decline_btn = Button(tr("Decline"), click_callback=on_decline)
self._accept_btn = Button(tr("Agree"), button_style=ButtonStyle.PRIMARY, click_callback=on_accept)
self._decline_btn = Button(lambda: tr("Decline"), click_callback=on_decline)
self._accept_btn = Button(lambda: tr("Agree"), button_style=ButtonStyle.PRIMARY, click_callback=on_accept)
def _render(self, _):
welcome_x = self._rect.x + 165
@@ -170,10 +171,10 @@ class TermsPage(Widget):
class DeclinePage(Widget):
def __init__(self, back_callback=None):
super().__init__()
self._text = Label(tr("You must accept the Terms and Conditions in order to use openpilot."),
self._text = Label(lambda: tr("You must accept the Terms and Conditions in order to use openpilot."),
font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=TextAlignment.LEFT)
self._back_btn = Button(tr("Back"), click_callback=back_callback)
self._uninstall_btn = Button(tr("Decline, uninstall openpilot"), button_style=ButtonStyle.DANGER,
self._back_btn = Button(lambda: tr("Back"), click_callback=back_callback)
self._uninstall_btn = Button(lambda: tr("Decline, uninstall openpilot"), button_style=ButtonStyle.DANGER,
click_callback=self._on_uninstall_clicked)
def _on_uninstall_clicked(self):
@@ -210,7 +210,7 @@ class MiciOffroadAlerts(Scroller):
self.alert_items: list[AlertItem] = []
# Create empty state label
self._empty_label = UnifiedLabel(tr("no alerts"), 65, FontWeight.DISPLAY, rl.WHITE,
self._empty_label = UnifiedLabel(lambda: tr("no alerts"), 65, FontWeight.DISPLAY, rl.WHITE,
alignment=TextAlignment.CENTER,
alignment_vertical=TextAlignmentVertical.MIDDLE)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -292,6 +292,7 @@ PLURAL_FORMS: dict[str, str] = {
'zh-CHS': 'nplurals=1; plural=0;',
'ko': 'nplurals=1; plural=0;',
'ja': 'nplurals=1; plural=0;',
'vi': 'nplurals=1; plural=0;',
}
@@ -1,14 +1,22 @@
#!/usr/bin/env python3
from itertools import chain
import os
import json
from pathlib import Path
from openpilot.common.basedir import BASEDIR
from openpilot.system.ui.lib.multilang import SYSTEM_UI_DIR, UI_DIR, TRANSLATIONS_DIR, multilang
from openpilot.selfdrive.ui.translations.potools import extract_strings, generate_pot, merge_po, init_po
from openpilot.selfdrive.ui.translations.potools import extract_strings, generate_pot, merge_po, init_po, POEntry
LANGUAGES_FILE = os.path.join(str(TRANSLATIONS_DIR), "languages.json")
POT_FILE = os.path.join(str(TRANSLATIONS_DIR), "app.pot")
def display_catalog_entries():
path = Path(BASEDIR) / "openpilot/starpilot/ui/translation_messages.json"
document = json.loads(path.read_text(encoding="utf-8"))
return [POEntry(msgid=row["source"], source_refs=row["refs"]) for row in document["messages"]]
def update_translations():
files = []
for root, _, filenames in chain(os.walk(SYSTEM_UI_DIR),
@@ -23,7 +31,13 @@ def update_translations():
# Extract translatable strings and generate .pot template
entries = extract_strings(files, BASEDIR)
generate_pot(entries, POT_FILE)
by_source = {entry.msgid: entry for entry in entries}
for entry in display_catalog_entries():
if entry.msgid in by_source:
by_source[entry.msgid].source_refs = sorted(set(by_source[entry.msgid].source_refs + entry.source_refs))
else:
by_source[entry.msgid] = entry
generate_pot(sorted(by_source.values(), key=lambda entry: entry.msgid), POT_FILE)
# Generate/update translation files for each language
for name in multilang.languages.values():
+10 -8
View File
@@ -156,11 +156,13 @@ text and accessibility labels; request values, parameter keys, input values and
technical logs remain unchanged. The native large/compact renderer translates
measurements and drawing together and loads bundled Unicode fonts as needed.
`starpilot/ui/translation_strings.py` marks shared labels for the existing catalog
update tool. The initial shared set covers navigation, common actions, settings
groups, home statistics and several onroad labels in Japanese, Korean, Spanish, French, German and
Vietnamese. The catalogs also retain stock openpilot translations. This is not
complete sentence coverage: long feature explanations, dynamically assembled
messages and unlisted labels remain English. Boot/login screens initially use
English until an authenticated language response is available. User-entered
text, logs, identifiers and brand names are not translation targets.
`starpilot/ui/translation_strings.py` and `translation_messages.json` retain the
shared UI sources for the existing catalog update tool. The six requested
languages each contain 5,465 catalog entries: stock openpilot labels, Galaxy
pages and settings descriptions, native UI labels, formatted status messages
and driving alerts. Formatted translations retain the original values and
identifiers. Unlisted runtime diagnostics and external content retain English
fallback; user names, addresses, media metadata and technical logs are literal.
Boot/login initially uses English until the authenticated language response.
Native text is translated before measurement, wrapping and elision; translated
values carry a marker so they cannot be translated twice.
+1 -1
View File
@@ -7,7 +7,7 @@ from openpilot.starpilot.saved_source import read_saved
from openpilot.system.ui.lib.multilang import TRANSLATIONS_DIR, load_translations
@lru_cache(maxsize=16)
@lru_cache(maxsize=1)
def catalog(code):
if code == "en":
return {}
@@ -49,7 +49,7 @@ class LocalizationSettingsOwner:
name = next((name for name, value in LANGUAGES.items() if value == code), "English")
rows.append(FeatureRow("LanguageSetting", "Language", name, raw, tuple(LANGUAGES),
available=readable and self.authorized() and self.parked(),
reason="Device display language. Untranslated text uses English. Park before changing language.",
reason="Display language for Galaxy and the device. Untranslated text uses English. Park before changing language.",
default_value="English"))
return FeatureSettingsState("localization", "Localization", "Language, units and steering-wheel side for every vehicle.",
tuple(rows), self.parked())
@@ -12,7 +12,7 @@ class Element {
for (const child of children) child.parentElement = this
}
matches(selector) {
return selector.split(",").some(value => value.startsWith("[") ?
return selector.split(",").some(value => value === "select[hidden]" ? this.tag === "select" && this.hasAttribute("hidden") : value === "option:not([value])" ? this.tag === "option" && !this.hasAttribute("value") : value.startsWith("[") ?
value === "[translate=no]" ? this.attrs.translate === "no" : Object.hasOwn(this.attrs, value.slice(1, -1)) : this.tag === value)
}
closest(selector) { return this.matches(selector) || this.parentElement?.closest(selector) }
@@ -42,3 +42,28 @@ assert.equal(label.nodeValue, "Home")
assert.equal(dynamic.nodeValue, "Home")
assert.equal(input.attrs.placeholder, "Search")
assert.equal(root.childNodes[0].attrs.title, "Home")
const dynamicMessages = { "{} drives": "{} trayectos", "Saved {count} items for {name}": "Para {name}: {count} elementos guardados",
"Preparing {} ({}%)": "Preparando {} ({}%)" }
assert.equal(translateText("3 drives", dynamicMessages), "3 trayectos")
assert.equal(translateText("Saved 12 items for Dom", dynamicMessages), "Para Dom: 12 elementos guardados")
assert.equal(translateText("Preparing Alpha (23%)", dynamicMessages), "Preparando Alpha (23%)")
assert.equal(translateText("Some unknown diagnostic", dynamicMessages), "Some unknown diagnostic")
assert.equal(translateText('Expires in 1 day', {'{} day':'{}일','Expires in {} day{}':'{}일{} 후 만료'}), '1일 후 만료')
assert.equal(translateText('1 minute Left', {'{} minute{} Left':'{}분{} 남음'}), '1분 남음')
const implicitText = text("Save"), explicitText = text("Save")
const selectRoot = new Element("select", {}, [new Element("option", {}, [implicitText]),
new Element("option", {value:"saved-id"}, [explicitText])])
localizeDocument(selectRoot, {language:"ja",messages:{Save:"保存"}})
assert.equal(implicitText.nodeValue,"Save","implicit option values must remain unchanged")
assert.equal(explicitText.nodeValue,"保存")
assert.equal(selectRoot.childNodes[1].attrs.value,"saved-id")
assert.equal(translateText("Switch to light mode", {"{} to {}":"{}から{}へ"}), "Switch to light mode")
const hiddenText = text("Save")
const hiddenNative = new Element("select", {hidden:""}, [new Element("option", {value:"saved-id"}, [hiddenText])])
localizeDocument(hiddenNative, {language:"ja",messages:{Save:"保存"}})
assert.equal(hiddenText.nodeValue,"Save","GalaxySelect must retain canonical hidden option labels for sync()")
assert.equal(translateText('Reset Language to default', {Language:'Sprache','Reset {label} to default':'{label} zurücksetzen'}),'Sprache zurücksetzen')
@@ -81,3 +81,24 @@ def test_shared_messages_have_translations(tmp_path, code):
_, entries = parse_po(TRANSLATIONS_DIR.joinpath(f'app_{code}.po'))
translations = {entry.msgid: entry.msgstr for entry in entries}
assert all(translations.get(message) for message in MESSAGES)
@pytest.mark.parametrize('code', ['ja', 'ko', 'es', 'fr', 'de', 'vi'])
def test_all_registered_display_messages_have_catalog_values(code):
import json
from openpilot.common.basedir import BASEDIR
from openpilot.selfdrive.ui.translations.potools import parse_po
from openpilot.system.ui.lib.multilang import TRANSLATIONS_DIR
registry = json.loads((Path(BASEDIR) / 'openpilot/starpilot/ui/translation_messages.json').read_text())
_, entries = parse_po(TRANSLATIONS_DIR.joinpath(f'app_{code}.po'))
messages = {entry.msgid: entry.msgstr for entry in entries}
assert all(messages.get(row['source']) for row in registry['messages'])
assert all(entry.msgstr or entry.msgstr_plural for entry in entries)
def test_display_registry_is_part_of_standard_catalog_update():
import json
from openpilot.common.basedir import BASEDIR
from openpilot.selfdrive.ui.translations.update_translations import display_catalog_entries
registry = json.loads((Path(BASEDIR) / 'openpilot/starpilot/ui/translation_messages.json').read_text())
assert {entry.msgid for entry in display_catalog_entries()} == {row['source'] for row in registry['messages']}
@@ -79,7 +79,7 @@ export const DevicePicker = {
<div class="gx-nav-section__title">Commas</div>
<div v-for="(device, index) in devices" :key="device.slug" class="gx-device-picker__row">
<button type="button" class="gx-nav-item gx-grow" :class="{active:device.slug === activeSlug}" @click="select(device)">
<i class="bi bi-cpu"></i><span class="gx-truncate">{{ device.name || 'Comma ' + (index + 1) }}</span>
<i class="bi bi-cpu"></i><span class="gx-truncate"><span translate="no">{{ device.name || 'Comma ' + (index + 1) }}</span></span>
<small v-if="device.slug === activeSlug">Current</small>
</button>
<button type="button" class="gx-icon-btn" :aria-label="'Rename ' + (device.name || 'comma')" @click="edit(device)"><i class="bi bi-pencil"></i></button>
+6 -5
View File
@@ -1,3 +1,4 @@
import { displayLocale } from "./localization.js"
import { GxState } from "./state.js"
import { GxNotice } from "./notice.js"
import { LocalAccess } from "./local-access.js"
@@ -34,13 +35,13 @@ const driveDate = (start, end = null) => {
if (start === null) return "Date unavailable"
const date = new Date(start * 1000)
if (!Number.isFinite(date.getTime())) return "Date unavailable"
const calendar = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "numeric" })
const clock = (value) => new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(new Date(value * 1000))
const calendar = new Intl.DateTimeFormat(displayLocale(), { month: "short", day: "numeric", year: "numeric" })
const clock = (value) => new Intl.DateTimeFormat(displayLocale(), { hour: "2-digit", minute: "2-digit" }).format(new Date(value * 1000))
const day = calendar.format(date)
const endDay = end !== null ? calendar.format(new Date(end * 1000)) : null
return `${day} · ${clock(start)}${end !== null ? `–${endDay === day ? "" : endDay + " · "}${clock(end)}` : ""}`
}
const dayLabel = (date) => new Intl.DateTimeFormat(undefined, { timeZone: "UTC", weekday: "short" }).format(new Date(`${date}T00:00:00Z`))
const dayLabel = (date) => new Intl.DateTimeFormat(displayLocale(), { timeZone: "UTC", weekday: "short" }).format(new Date(`${date}T00:00:00Z`))
const recordDisplay = (record, isMetric) => record.value === null ? "Unavailable" :
record.unit === "m" ? distance(record.value, isMetric) : record.unit === "s" ? duration(record.value) :
record.unit === "days" || record.unit === "drives" ? counted(record.value, record.unit.slice(0, -1)) :
@@ -101,7 +102,7 @@ export function homeSummary(monitor, model, software) {
memory: measure(live?.memory?.percent, "%"),
storage: live?.storage?.usedGiB != null && live?.storage?.totalGiB != null
? `${measure(live.storage.usedGiB, " GiB", 1)} / ${measure(live.storage.totalGiB, " GiB", 1)}` : "Unavailable",
observed: live ? new Date(live.sampledAt * 1000).toLocaleString() : "Unavailable",
observed: live ? new Date(live.sampledAt * 1000).toLocaleString(displayLocale()) : "Unavailable",
},
model: {
available: !!loaded,
@@ -236,7 +237,7 @@ export const Home = {
<div v-else class="gx-home__model-layout"><div class="gx-home__model-donut" :style="driving.modelStyle"></div>
<div><div v-for="model in driving.models" :key="model.name" class="gx-home__model-row">
<span class="gx-home__swatch" :style="{ background: model.color }"></span>
<div><strong>{{ model.name }}</strong><small>{{ counted(model.drives, "drive") }} · {{ model.distance }}</small></div></div></div></div>
<div><strong><span translate="no">{{ model.name }}</span></strong><small>{{ counted(model.drives, "drive") }} · {{ model.distance }}</small></div></div></div></div>
</section>
</template>
<div class="gx-home__section-title"><span></span>Your device</div>
@@ -1,10 +1,64 @@
import { requestJson } from "./startup.js"
import { ref } from "../vendor/vue/vue.esm-browser.js"
const language = ref(undefined)
const setDisplayLanguage = value => { language.value = value }
export const displayLocale = () => ({"zh-CHS":"zh-Hans","zh-CHT":"zh-Hant"}[language.value] || language.value)
// Translate presentation only: Vue's values, requests and technical identifiers stay in English.
const ATTRIBUTES = ["title", "aria-label", "placeholder", "alt"]
const PROTECTED = "script,style,code,pre,textarea,input,option,[contenteditable],[translate=no]"
const PROTECTED = "script,style,code,pre,textarea,input,select[hidden],option:not([value]),[contenteditable],[translate=no]"
const compiled = new WeakMap()
const escapeRegex = text => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const tokens = text => [...text.matchAll(/\{([^{}]*)\}/g)]
function templates(messages) {
if (compiled.has(messages)) return compiled.get(messages)
const result = []
for (const [source, translated] of Object.entries(messages)) {
const fields = tokens(source), target = tokens(translated)
if (!fields.length || fields.length !== target.length) continue
let offset = 0, pattern = "^"
const keys = fields.map((field, index) => field[1].split(/[!:]/, 1)[0] || String(index))
for (const field of fields) {
pattern += escapeRegex(source.slice(offset, field.index)) + "([^\\n]*?)"
offset = field.index + field[0].length
}
pattern += escapeRegex(source.slice(offset)) + "$"
const literal = source.replace(/\{[^{}]*\}/g, "").toLowerCase()
const stops = new Set(["to", "of", "in", "on", "at", "by", "for", "or", "and", "a", "the"])
const generic = !(literal.match(/[a-z]+/g) || []).some(word => !stops.has(word))
result.push({ generic, specificity: source.length - fields.reduce((sum, field) => sum + field[0].length, 0),
prefix: source.slice(0, fields[0].index), pattern: new RegExp(pattern), keys, translated })
}
result.sort((a, b) => b.specificity - a.specificity || a.keys.length - b.keys.length)
compiled.set(messages, result)
return result
}
export function translateText(source, messages) {
const text = source.trim()
return Object.hasOwn(messages, text) ? source.replace(text, () => messages[text]) : source
let translated = Object.hasOwn(messages, text) ? messages[text] : text
if (translated === text && text.length <= 1024) {
for (const template of templates(messages)) {
if (!text.startsWith(template.prefix)) continue
const matched = template.pattern.exec(text)
if (!matched || template.generic && !matched.slice(1).some(value => /\d/.test(value))) continue
const values = Object.fromEntries(template.keys.map((key, index) => [key, matched[index + 1]]))
if (Object.hasOwn(values, "label") && Object.hasOwn(messages, values.label)) values.label = messages[values.label]
let auto = 0
translated = template.translated.replace(/\{([^{}]*)\}/g, (token, field) => {
const key = field.split(/[!:]/, 1)[0] || String(auto++)
return Object.hasOwn(values, key) ? values[key] : token
})
break
}
}
if (translated === text && /\n|\s+·\s+/.test(text)) {
translated = text.split(/(\n|\s+·\s+)/).map((part, index) => index % 2 === 0 ? translateText(part, messages) : part).join("")
}
return translated !== text ? source.replace(text, () => translated) : source
}
export function localizeDocument(root, { language, messages }, records = new WeakMap()) {
@@ -30,11 +84,12 @@ export function localizeDocument(root, { language, messages }, records = new Wea
}
visit(root)
root.ownerDocument.documentElement.lang = language
setDisplayLanguage(language)
return records
}
export function startLocalization(root = document.body, fetcher = fetch) {
let state = { language: "en", messages: {} }, stopped = false, timer, scheduled = false
let state = { language: "en", messages: {} }, stopped = false, timer, scheduled = false, generation = 0, request
const records = new WeakMap()
const dirty = new Set()
const observer = new MutationObserver(mutations => {
@@ -54,19 +109,25 @@ export function startLocalization(root = document.body, fetcher = fetch) {
observer.observe(root, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ATTRIBUTES })
}
async function refresh() {
clearTimeout(timer)
request?.abort()
const current = ++generation
request = new AbortController()
try {
const response = await fetcher(`./api/localization?language=${encodeURIComponent(state.language)}`, { cache: "no-store" })
if (response.ok) {
const next = await response.json()
if (!stopped && typeof next.language === "string" && next.messages && typeof next.messages === "object") {
state = next
apply()
}
const next = await requestJson(`./api/localization?language=${encodeURIComponent(state.language)}`,
{ fetcher, timeout: 4000, request: { signal: request.signal } })
if (!stopped && current === generation && typeof next.language === "string" && next.messages && typeof next.messages === "object") {
state = next
apply()
}
} catch { /* Keep the last confirmed language during connection loss. */ }
if (!stopped) timer = setTimeout(refresh, 2000)
if (!stopped && current === generation) timer = setTimeout(refresh, 2000)
}
apply()
globalThis.addEventListener?.("galaxy-language-changed", refresh)
refresh()
return () => { stopped = true; clearTimeout(timer); observer.disconnect() }
return () => {
stopped = true; generation++; request?.abort(); clearTimeout(timer); observer.disconnect()
globalThis.removeEventListener?.("galaxy-language-changed", refresh)
}
}
@@ -579,7 +579,7 @@ export const NavigationPage = {
<div v-if="favorites.length" class="gx-navigation__group"><span>Saved</span></div>
<div v-for="place in favorites" :key="'saved-' + place.id" class="gx-navigation__row">
<button type="button" class="gx-navigation__suggestion" :disabled="!controlsAvailable" @click="pick(place)">
<i class="bi" :class="placeIcon(place)" aria-hidden="true"></i><span><strong>{{ placeName(place) }}</strong><small v-if="placeDetail(place)">{{ placeDetail(place) }}</small></span></button>
<i class="bi" :class="placeIcon(place)" aria-hidden="true"></i><span><strong><span :translate="place.label ? undefined : 'no'">{{ placeName(place) }}</span></strong><small v-if="placeDetail(place)"><span :translate="placeDetail(place) ? 'no' : undefined">{{ placeDetail(place) }}</span></small></span></button>
<button type="button" class="gx-navigation__icon" :aria-label="'Remove ' + place.name + ' from saved places'" :disabled="!controlsAvailable" @click="removeFavorite(place)"><i class="bi bi-x-lg" aria-hidden="true"></i></button>
<button type="button" class="gx-navigation__icon" :aria-label="'Save ' + place.name + ' as Home, Work, or Other'" :aria-expanded="favoritePicker === favoriteKey(place)" :disabled="!controlsAvailable || place.temporary" :title="place.temporary ? 'Search for an address to save this place' : undefined" @click="openFavorite(place)"><i class="bi bi-star-fill" aria-hidden="true"></i></button>
<FavoriteChoices v-if="favoritePicker === favoriteKey(place)" :available="controlsAvailable" @choose="saveAs(place, $event)" />
@@ -588,7 +588,7 @@ export const NavigationPage = {
<button type="button" class="gx-navigation__link" :disabled="!controlsAvailable" @click="client.action('clearRecents')">Clear</button></div>
<div v-for="place in recents" :key="'recent-' + place.id" class="gx-navigation__row">
<button type="button" class="gx-navigation__suggestion" :disabled="!controlsAvailable" @click="pick(place)">
<i class="bi bi-clock-history" aria-hidden="true"></i><span><strong>{{ place.name }}</strong><small v-if="place.address">{{ place.address }}</small></span></button>
<i class="bi bi-clock-history" aria-hidden="true"></i><span><strong><span translate="no">{{ place.name }}</span></strong><small v-if="place.address"><span translate="no">{{ place.address }}</span></small></span></button>
<button type="button" class="gx-navigation__icon" :aria-label="'Save ' + place.name" :aria-expanded="favoritePicker === favoriteKey(place)" :disabled="!controlsAvailable || place.temporary" :title="place.temporary ? 'Search for an address to save this place' : undefined" @click="openFavorite(place)"><i class="bi bi-star" aria-hidden="true"></i></button>
<button type="button" class="gx-navigation__icon" :aria-label="'Remove ' + place.name + ' from recent places'" :disabled="!controlsAvailable" @click="client.action('removeRecent',{id:place.id})"><i class="bi bi-x-lg" aria-hidden="true"></i></button>
<FavoriteChoices v-if="favoritePicker === favoriteKey(place)" :available="controlsAvailable" @choose="saveAs(place, $event)" />
@@ -597,13 +597,13 @@ export const NavigationPage = {
<template v-else>
<div v-for="place in localMatches" :key="'local-' + place.id" class="gx-navigation__row">
<button type="button" class="gx-navigation__suggestion" :disabled="!controlsAvailable" @click="pick(place)">
<i class="bi" :class="placeIcon(place)" aria-hidden="true"></i><span><strong>{{ placeName(place) }}</strong><small>{{ placeDetail(place) || (isFavorite(place) ? 'Saved place' : 'Recent') }}</small></span></button>
<i class="bi" :class="placeIcon(place)" aria-hidden="true"></i><span><strong><span :translate="place.label ? undefined : 'no'">{{ placeName(place) }}</span></strong><small><span :translate="placeDetail(place) ? 'no' : undefined">{{ placeDetail(place) || (isFavorite(place) ? 'Saved place' : 'Recent') }}</span></small></span></button>
<button type="button" class="gx-navigation__icon" :aria-label="'Save ' + place.name" :aria-expanded="favoritePicker === favoriteKey(place)" :disabled="!controlsAvailable || place.temporary" :title="place.temporary ? 'Search for an address to save this place' : undefined" @click="openFavorite(place)"><i class="bi" :class="isFavorite(place) ? 'bi-star-fill' : 'bi-star'" aria-hidden="true"></i></button>
<FavoriteChoices v-if="favoritePicker === favoriteKey(place)" :available="controlsAvailable" @choose="saveAs(place, $event)" />
</div>
<div v-for="place in suggestions" :key="place.id" class="gx-navigation__row">
<button type="button" class="gx-navigation__suggestion" :disabled="!controlsAvailable" @click="pick(place)">
<i class="bi bi-geo-alt-fill" aria-hidden="true"></i><span><strong>{{ place.name }}</strong><small v-if="place.description">{{ place.description }}</small></span></button>
<i class="bi bi-geo-alt-fill" aria-hidden="true"></i><span><strong><span translate="no">{{ place.name }}</span></strong><small v-if="place.description">{{ place.description }}</small></span></button>
<button type="button" class="gx-navigation__icon" :aria-label="'Save ' + place.name" :aria-pressed="isFavorite(place)" :aria-expanded="favoritePicker === favoriteKey(place)" :disabled="!controlsAvailable || place.temporary" :title="place.temporary ? 'Search for an address to save this place' : undefined" @click="openFavorite(place)"><i class="bi" :class="isFavorite(place) ? 'bi-star-fill' : 'bi-star'" aria-hidden="true"></i></button>
<FavoriteChoices v-if="favoritePicker === favoriteKey(place)" :available="controlsAvailable" @choose="saveAs(place, $event)" />
</div>
@@ -611,11 +611,11 @@ export const NavigationPage = {
</div>
<div v-if="showShortcuts" class="gx-navigation__shortcuts" aria-label="Saved places">
<button v-for="place in shortcuts" :key="place.id" type="button" class="gx-navigation__chip" :disabled="!controlsAvailable" @click="pick(place)">
<i class="bi" :class="placeIcon(place)" aria-hidden="true"></i>{{ placeName(place) }}</button>
<i class="bi" :class="placeIcon(place)" aria-hidden="true"></i><span :translate="place.label ? undefined : 'no'">{{ placeName(place) }}</span></button>
</div>
<section v-if="data?.destination" class="gx-card gx-navigation__section gx-navigation__destination" :class="{'gx-navigation--stale':stale}">
<div class="gx-navigation__heading">
<div><h3 class="gx-navigation__summary-title">{{ data.destination.name }}</h3><p v-if="data.destination.address" class="gx-note">{{ data.destination.address }}</p></div>
<div><h3 class="gx-navigation__summary-title"><span translate="no">{{ data.destination.name }}</span></h3><p v-if="data.destination.address" class="gx-note"><span translate="no">{{ data.destination.address }}</span></p></div>
<button type="button" class="gx-navigation__icon gx-navigation__save" :class="{'gx-navigation__save--on':destinationSaved}" :aria-pressed="!!destinationSaved"
:aria-label="destinationSaved ? 'Change saved place category' : 'Save this place'" :aria-expanded="favoritePicker === favoriteKey(data.destination)" :disabled="!controlsAvailable || data.destination.temporary" :title="data.destination.temporary ? 'Search for an address to save this place' : undefined" @click="openFavorite(data.destination)">
<i class="bi" :class="destinationSaved ? 'bi-star-fill' : 'bi-star'" aria-hidden="true"></i></button>
@@ -647,7 +647,7 @@ export const NavigationPage = {
<ul v-if="results.length" class="gx-navigation__places" aria-label="Search results">
<li v-for="place in results" :key="place.id" class="gx-navigation__row">
<button type="button" class="gx-navigation__suggestion" :disabled="!controlsAvailable" @click="pick(place)" :aria-label="'Start navigation to ' + place.name">
<i class="bi bi-arrow-up-right" aria-hidden="true"></i><span><strong>{{ place.name }}</strong><small v-if="place.address || place.description">{{ place.address || place.description }}</small><small class="gx-navigation__start">Start navigation</small></span>
<i class="bi bi-arrow-up-right" aria-hidden="true"></i><span><strong><span translate="no">{{ place.name }}</span></strong><small v-if="place.address || place.description"><span translate="no">{{ place.address || place.description }}</span></small><small class="gx-navigation__start">Start navigation</small></span>
</button>
<button type="button" class="gx-navigation__icon" :aria-label="(isFavorite(place) ? 'Saved: ' : 'Save ') + place.name" :aria-pressed="isFavorite(place)" :aria-expanded="favoritePicker === favoriteKey(place)" :disabled="!controlsAvailable || place.temporary" :title="place.temporary ? 'Search for an address to save this place' : undefined" @click="openFavorite(place)"><i class="bi" :class="isFavorite(place) ? 'bi-star-fill' : 'bi-star'" aria-hidden="true"></i></button>
<FavoriteChoices v-if="favoritePicker === favoriteKey(place)" :available="controlsAvailable" @choose="saveAs(place, $event)" />
@@ -104,7 +104,7 @@ export const LocalRecordingsPage = {
<p v-if="!routeCards.length" class="gx-recordings__empty">{{ data.routes.length ? 'No drives match your filters.' : 'No saved drives found on this device.' }}</p>
<article v-for="route in routeCards" :key="route.routeId" class="gx-recordings__row" :class="{'gx-recordings__row--preserved': route.preserved}">
<button class="gx-recordings__drive" :data-route-id="route.routeId" :aria-label="'Open route ' + (route.displayName || route.date.label)" @click="openRoute(route)">
<span class="gx-recordings__name"><span class="gx-row__label">{{ route.displayName !== route.routeId ? route.displayName : route.date.label }}</span><span v-if="route.preserved" class="gx-chip">Preserved</span></span>
<span class="gx-recordings__name"><span class="gx-row__label"><span translate="no">{{ route.displayName !== route.routeId ? route.displayName : route.date.label }}</span></span><span v-if="route.preserved" class="gx-chip">Preserved</span></span>
<span class="gx-row__desc"><template v-if="route.displayName !== route.routeId">{{ route.date.label }} · </template>~{{ formatDuration(route.segmentCount) }}</span>
</button>
<span class="gx-note">{{ route.segmentCount }} {{ route.segmentCount === 1 ? "segment" : "segments" }}</span>
@@ -233,6 +233,7 @@ export class SettingsFeed {
const attempt = this.generation
const result = await operation
if (result?.saved === true) {
if (page === "localization") globalThis.dispatchEvent?.(new Event("galaxy-language-changed"))
for (const feed of activeSettings) {
if (feed !== this && !feed.pending && !feed.saving) feed.load(feed.page, { keepData: true, quiet: true })
}
+6 -6
View File
@@ -478,8 +478,8 @@ export const SpotifyPage = {
<section v-if="setup && setupDone && !phoneSource" class="gx-card gx-starport-step gx-starport-home gx-spotify-now" aria-labelledby="gx-spotify-now-title">
<div class="gx-spotify-now__main">
<div class="gx-spotify-now__text">
<h3 id="gx-spotify-now-title" class="gx-truncate">{{ track?.title || setup.preferences.deviceName }}</h3>
<p v-if="track" class="gx-truncate">{{ track.artists }}<template v-if="track.album"> · {{ track.album }}</template></p>
<h3 id="gx-spotify-now-title" class="gx-truncate"><span translate="no">{{ track?.title || setup.preferences.deviceName }}</span></h3>
<p v-if="track" class="gx-truncate"><span translate="no">{{ track.artists }}</span><template v-if="track.album"> · <span translate="no">{{ track.album }}</span></template></p>
<p role="status" class="gx-note gx-truncate">{{ statusText }}<template v-if="setup.enabled"> · {{ outputName }}</template></p>
</div>
</div>
@@ -497,7 +497,7 @@ export const SpotifyPage = {
<small v-if="setup.enabled && player?.loggedIn && player.volume !== null" class="gx-note">Volume {{ player.volume }}% - change in Spotify app if needed, otherwise use car volume</small>
<div v-if="setup.enabled && player?.loggedIn && !player.active" class="gx-stack">
<button type="button" class="gx-btn gx-btn--tonal" @click="control('activate')">
<i class="bi bi-broadcast" aria-hidden="true"></i> Move Spotify to {{ setup.preferences.deviceName || 'StarPilot' }}</button>
<i class="bi bi-broadcast" aria-hidden="true"></i> Move Spotify to <span translate="no">{{ setup.preferences.deviceName || 'StarPilot' }}</span></button>
<small class="gx-note">Takes over from the phone or speaker Spotify is playing on now. Music plays through {{ outputName }}. You might need to confirm the switch in the Spotify app.</small>
</div>
<GalaxySettingRow :row="switchRow('Spotify', setup.enabled, 'Turn off to stop playing and free up the comma. Your setup stays saved.')"
@@ -584,7 +584,7 @@ export const SpotifyPage = {
<li v-if="!phoneSource" class="gx-card gx-starport-step" :class="{ 'gx-starport-step--done': setupDone && setup.enabled && player?.loggedIn }">
<div class="gx-starport-step__head">
<span class="gx-starport-step__num" aria-hidden="true"><i v-if="setupDone && setup.enabled && player?.loggedIn" class="bi bi-check-lg"></i><template v-else>4</template></span>
<div><h3>Connect Spotify</h3><p>Turn it on, then pick <strong>{{ setup.preferences.deviceName }}</strong> in the Spotify app’s device list.</p></div>
<div><h3>Connect Spotify</h3><p>Turn it on, then pick <strong><span translate="no">{{ setup.preferences.deviceName }}</span></strong> in the Spotify app’s device list.</p></div>
</div>
<div class="gx-starport-step__body">
<GalaxySettingRow :row="switchRow('Spotify', setup.enabled, 'Runs Spotify on your comma. Turn off to stop playing; your setup stays saved.')"
@@ -625,7 +625,7 @@ export const SpotifyPage = {
<li>For cellular or different networks after pairing, turn off <strong>Local device visibility</strong> in Spotify → Settings → Apps and devices. Both devices need internet access.</li>
<li>On iPhone, allow Spotify’s <strong>Local Network</strong> permission in iPhone Settings. If your phone’s hotspot blocks initial pairing, pair once on another shared Wi‑Fi network.</li>
<li>If it still does not appear, turn Spotify off and back on here. This reconnects using your saved account; you do not need to clear the account or cache.</li>
<li>In Spotify, tap the device icon and look for <strong>{{ setup?.preferences.deviceName || 'StarPilot' }}</strong>.</li>
<li>In Spotify, tap the device icon and look for <strong><span translate="no">{{ setup?.preferences.deviceName || 'StarPilot' }}</span></strong>.</li>
</ol>
</details>
<details class="gx-starport-wiki"><summary>Can I use the comma hotspot or wireless Android Auto?</summary>
@@ -709,7 +709,7 @@ export const SpotifyPage = {
</label>
<small v-if="archiveChecking" role="status" class="gx-note">Checking the file…</small>
<div v-if="archiveFile && !archiveError && !archiveChecking" class="gx-chip gx-starport-file-chip">
<i class="bi bi-file-earmark-zip"></i><span>{{ archiveFile.name }}</span><small>{{ Math.ceil(archiveFile.size / 1048576) }} MB</small>
<i class="bi bi-file-earmark-zip"></i><span><span translate="no">{{ archiveFile.name }}</span></span><small>{{ Math.ceil(archiveFile.size / 1048576) }} MB</small>
</div>
<small v-if="archiveError" class="gx-starport-bad" role="alert">{{ archiveError }}</small>
</div>
+45 -15
View File
@@ -1,13 +1,35 @@
"""Source-supplied alerts with native compact lane-guidance rendering."""
from dataclasses import replace
import re
import pyray as rl
from openpilot.system.ui.lib.multilang import tr_display as tr
from openpilot.system.ui.lib.display_translation import DisplayText
from openpilot.common.filter_simple import BounceFilter, FirstOrderFilter
from openpilot.starpilot.ui.onroad_state import AlertSize, OnroadAlert
from openpilot.selfdrive.ui.mici.onroad.alert_renderer import Alert as NativeAlert, AlertRenderer as NativeAlertRenderer
from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole, Profile
def _wrap_display(text, measure, width):
lines, current = [], ""
for word in re.findall(r"\S+\s*", text):
chunks = (word,) if measure(DisplayText(word.rstrip())) <= width else tuple(word)
for chunk in chunks:
candidate = current + chunk
if current and measure(DisplayText(candidate.rstrip())) > width:
lines.append(DisplayText(current.rstrip()))
current = chunk.lstrip()
else:
current = candidate
if current.strip():
lines.append(DisplayText(current.rstrip()))
return lines
class AlertRenderer:
def __init__(self, fonts: BitmapFonts):
self.fonts = fonts
@@ -18,8 +40,15 @@ class AlertRenderer:
self._previous: OnroadAlert | None = None
self.text_only_alert_names: frozenset[str] = frozenset()
def _fit_size(self, text, role, size, width, letter_spacing=0):
while size > 26 and self.fonts.measure(text, role, size, spacing=size * letter_spacing).width > width:
size -= 2
return size
def _center(self, text: str, role: FontRole, size: int, rect: rl.Rectangle, y: float,
color: rl.Color = rl.WHITE, *, letter_spacing: float = 0) -> None:
text = DisplayText(tr(text))
size = self._fit_size(text, role, size, rect.width, letter_spacing)
spacing = size * letter_spacing
lines = text.split('\n')
height = self.fonts.measure(text, role, size, spacing=spacing).height
@@ -32,6 +61,10 @@ class AlertRenderer:
def render(self, rect: rl.Rectangle, alert: OnroadAlert, *, signal_direction: int = 0) -> None:
event_name = alert.alert_type.split('/', 1)[0]
text1, text2 = alert.text1, alert.text2
if event_name == 'personalityChanged' and text1.lower().startswith('driving personality: '):
text1, text2 = text1[len('driving personality: '):], 'Driving Personality'
alert = replace(alert, text1=DisplayText(tr(text1)), text2=DisplayText(tr(text2)))
if self.fonts.profile == Profile.COMPACT:
lane_event = event_name in ('preLaneChangeLeft', 'preLaneChangeRight', 'laneChangeBlocked', 'laneChange',
'steerTempUnavailable', 'steerTempUnavailableSilent')
@@ -78,7 +111,7 @@ class AlertRenderer:
rl.draw_rectangle(int(rect.x), int(rect.y), int(rect.width), solid, color)
rl.draw_rectangle_gradient_v(int(rect.x), int(rect.y + solid), int(rect.width), int(rect.height - solid),
color, rl.Color(color.r, color.g, color.b, 0))
size = 100 if len(alert.text1) > 16 else 110
size = self._fit_size(alert.text1, FontRole.BOLD, 100 if len(alert.text1) > 16 else 110, rect.width - 120, -0.02)
title_h = self.fonts.measure(alert.text1, FontRole.BOLD, size, spacing=size * -0.02).height
self._center(alert.text1, FontRole.BOLD, size, rl.Rectangle(rect.x + 60, rect.y, rect.width - 120, rect.height),
animated_y + (rect.height - title_h) / 2, title_color,
@@ -90,7 +123,7 @@ class AlertRenderer:
alert_height = 271 if alert.size == AlertSize.SMALL else 420
y = rect.y + rect.height - alert_height
color = rl.Color(218, 111, 37, background_alpha) if alert.user_prompt else rl.Color(0, 0, 0, background_alpha)
title_size = 60 if alert.size == AlertSize.SMALL else 88
title_size = self._fit_size(alert.text1, FontRole.BOLD, 60 if alert.size == AlertSize.SMALL else 88, rect.width - 120, -0.02)
title_h = self.fonts.measure(alert.text1, FontRole.BOLD, title_size, spacing=title_size * -0.02).height
plateau_y = round(animated_y + (alert_height - title_h) / 2) - 2
plateau_h = round(title_h) + 4
@@ -127,8 +160,8 @@ class AlertRenderer:
rl.draw_rectangle(int(rect.x), int(rect.y), int(rect.width), solid_height, color)
rl.draw_rectangle_gradient_v(int(rect.x), int(rect.y + solid_height), int(rect.width), int(height - solid_height),
color, rl.Color(color.r, color.g, color.b, 0))
text = alert.text1.lower()
subtitle = alert.text2.lower()
text = tr(alert.text1).lower()
subtitle = tr(alert.text2).lower()
if event_name == 'personalityChanged' and text.startswith('driving personality: '):
text = text.removeprefix('driving personality: ')
subtitle = 'driving personality'
@@ -142,17 +175,14 @@ class AlertRenderer:
if alert.size == AlertSize.FULL:
# Match word wrapping and line spacing to measured glyph height, not nominal font size.
available_width = rect.width - 18
lines: list[str] = []
current = ""
for word in text.split():
candidate = f"{current} {word}" if current else word
if current and self.fonts.measure(candidate, FontRole.DISPLAY, font_size, spacing=spacing).width > available_width:
lines.append(current)
current = word
else:
current = candidate
if current:
lines.append(current)
def wrapped(size):
return _wrap_display(text, lambda value: self.fonts.measure(value, FontRole.DISPLAY, size, spacing=-0.02 * size).width,
available_width)
lines = wrapped(font_size)
while font_size > 26 and len(lines) * self.fonts.measure(text, FontRole.DISPLAY, font_size).height * .86 * .9 > rect.height - 12:
font_size -= 2
lines = wrapped(font_size)
spacing = -0.02 * font_size
y = animated_y - 4
for line in lines:
self.fonts.draw(line, FontRole.DISPLAY, font_size, text_x, y, title_color, spacing=spacing)
+6 -5
View File
@@ -10,7 +10,8 @@ import numpy as np
from PIL import Image
import pyray as rl
from openpilot.system.ui.lib.multilang import TRANSLATIONS_DIR, multilang, tr
from openpilot.system.ui.lib.multilang import TRANSLATIONS_DIR, multilang, tr_display as tr
from openpilot.system.ui.lib.display_translation import native_text
class Profile(StrEnum):
@@ -179,7 +180,7 @@ class BitmapFonts:
return 1., 0.
def measure(self, text: str, role: FontRole, size: float, *, spacing: float = 0, fallback: bool = False) -> TextSize:
text = tr(text) if role != FontRole.BRAND else text
text = native_text(tr(text)) if role != FontRole.BRAND else text
font = self._text_font(text, role, fallback)
spacing = round(spacing, 4)
width_scale, weight = self._optical_style(role, fallback)
@@ -193,7 +194,7 @@ class BitmapFonts:
def vertical_ink(self, text: str, role: FontRole, size: float) -> tuple[float, float]:
"""Visible glyph top/bottom, independent of the font's padded line box."""
text = tr(text) if role != FontRole.BRAND else text
text = native_text(tr(text)) if role != FontRole.BRAND else text
key = (text, role, size)
if key not in self._vertical_ink:
font = self._text_font(text, role)
@@ -208,7 +209,7 @@ class BitmapFonts:
def horizontal_ink(self, text: str, role: FontRole, size: float) -> tuple[float, float]:
"""Visible glyph left/right from the draw x, without the font's side bearings."""
text = tr(text) if role != FontRole.BRAND else text
text = native_text(tr(text)) if role != FontRole.BRAND else text
key = (text, role, size)
if key not in self._horizontal_ink:
font = self._text_font(text, role)
@@ -257,7 +258,7 @@ class BitmapFonts:
def draw(self, text: str, role: FontRole, size: float, x: float, y: float, color: rl.Color = rl.WHITE,
*, spacing: float = 0, fallback: bool = False) -> None:
text = tr(text) if role != FontRole.BRAND else text
text = native_text(tr(text)) if role != FontRole.BRAND else text
font = self._text_font(text, role, fallback)
draw = getattr(rl, "_orig_draw_text_ex", rl.draw_text_ex)
width_scale, weight = self._optical_style(role, fallback)
+1 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.multilang import tr_display as tr
import math
import random
@@ -8,6 +8,7 @@ import pyray as rl
from openpilot.starpilot.ui.spacepilot.state import Flight, Phase
from openpilot.system.ui.lib.application import FontWeight, gui_app
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.multilang import tr_display
INK = rl.Color(8, 13, 30, 255)
WHITE = rl.Color(235, 248, 255, 255)
@@ -39,6 +40,7 @@ class SpaceRenderer:
@staticmethod
def text(text: str, x: float, y: float, size: int, color=WHITE, *, centered: bool = False, bold: bool = False) -> None:
text = tr_display(text)
font = gui_app.font(FontWeight.BOLD if bold else FontWeight.NORMAL)
if centered:
x -= measure_text_cached(font, text, size).x / 2
@@ -2,6 +2,8 @@
from collections.abc import Callable
from openpilot.system.ui.lib.display_translation import DisplayText
import pyray as rl
from openpilot.selfdrive.ui.layouts.settings.initial_cruise_speed import InitialCruiseSpeedControl
@@ -101,7 +103,7 @@ class StarPilotSettingsAdapterLarge(Widget):
actions = [(tr("CONNECT"), not busy and not connected and network.security_type != SecurityType.UNSUPPORTED)]
if saved:
actions.append((tr("Forget"), not busy))
rows.append(FeatureRow(key, normalize_ssid(network.ssid), f"{detail} - {security} - {network.strength}%",
rows.append(FeatureRow(key, DisplayText(normalize_ssid(network.ssid)), f"{detail} - {security} - {network.strength}%",
available=owner._allowed(), actions=tuple(actions)))
if not owner._networks:
rows.append(FeatureRow("", tr("Scanning Wi-Fi networks..."), ""))
@@ -258,3 +258,11 @@ def test_native_guidance_pulse_matches_original_sixty_hz_waveform():
with patch.object(native.time, 'monotonic', return_value=now):
renderer._draw_icons(layout)
assert abs(draw.call_args.args[-1].a - int(min(original.x, 255))) <= 1
def test_translated_cjk_and_long_words_wrap_within_alert_width():
from openpilot.system.ui.lib.display_translation import DisplayText
for text in ['直ちに運転を引き継いでください', 'Please take control immediately']:
lines = onroad_alerts._wrap_display(DisplayText(text), len, 8)
assert all(len(line) <= 8 and isinstance(line, DisplayText) for line in lines)
assert ''.join(lines).replace(' ', '') == text.replace(' ', '')
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -808,6 +808,9 @@ class GuiApplication:
rl.ffi.cast("int *", codepoint_buffer), len(codepoints))
rl.gen_texture_mipmaps(font.texture)
rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_TRILINEAR)
for previous in self._fallback_fonts.values():
rl.unload_font(previous)
self._fallback_fonts.clear()
self._fallback_fonts[language] = font
return self._fallback_fonts[language]
@@ -847,7 +850,6 @@ class GuiApplication:
rl._orig_draw_text_ex = rl.draw_text_ex
def _draw_text_ex_scaled(font, text, position, font_size, spacing, tint):
text = multilang.tr(text)
font = font_fallback(font)
return rl._orig_draw_text_ex(font, text, position, font_size * FONT_SCALE, spacing, tint)
@@ -0,0 +1,82 @@
"""Translate already formatted display text without changing interpolated values."""
import re
from string import Formatter
class DisplayText(str):
"""A translated or literal display value; never translate it a second time."""
def format(self, *args, **kwargs):
return DisplayText(super().format(*args, **kwargs))
def lower(self):
return DisplayText(super().lower())
def upper(self):
return DisplayText(super().upper())
def replace(self, old, new, count=-1):
return DisplayText(super().replace(old, new, count))
def __getitem__(self, key):
return DisplayText(super().__getitem__(key))
def split(self, sep=None, maxsplit=-1):
return [DisplayText(value) for value in super().split(sep, maxsplit)]
def parts(text):
result, auto = [], 0
for literal, field, _, _ in Formatter().parse(text):
key = str(auto) if field == '' else field
result.append((literal, key))
if field == '':
auto += 1
return result
def display_templates(messages):
grouped = {}
for source, translated in sorted(messages.items(), key=lambda item: (-len(re.sub(r"\{[^{}]*\}", "", item[0])), len(re.findall(r"\{[^{}]*\}", item[0])))):
if not translated or '{' not in source:
continue
try:
source_parts, target_parts = parts(source), parts(translated)
except ValueError:
continue
keys = [key for _, key in source_parts if key is not None]
if not keys or sorted(keys) != sorted(key for _, key in target_parts if key is not None):
continue
prefix = source_parts[0][0]
pattern = ''.join(re.escape(literal) + (r'([^\n]*?)' if key is not None else '') for literal, key in source_parts)
words = re.findall(r"[a-z]+", ''.join(literal for literal, _ in source_parts).lower())
generic = not any(word not in {'to', 'of', 'in', 'on', 'at', 'by', 'for', 'or', 'and', 'a', 'the'} for word in words)
grouped.setdefault(prefix[:1], []).append((prefix, re.compile('^' + pattern + '$'), keys, target_parts, generic))
return grouped
def translate_display(text, messages, templates):
if isinstance(text, DisplayText):
return text
if messages.get(text):
return DisplayText(messages[text])
if len(text) <= 1024:
for prefix, pattern, keys, target, generic in (*templates.get(text[:1], ()), *templates.get('', ())):
if text.startswith(prefix) and (matched := pattern.fullmatch(text)):
if generic and not any(re.search(r"\d", value) for value in matched.groups()):
continue
values = dict(zip(keys, matched.groups(), strict=True))
if "label" in values:
values["label"] = messages.get(values["label"], values["label"])
return DisplayText(''.join(literal + (values[key] if key is not None else '') for literal, key in target))
segments = re.split(r'(\n|\s+·\s+)', text)
if len(segments) > 1:
translated = [translate_display(part, messages, templates) if index % 2 == 0 else part for index, part in enumerate(segments)]
if translated != segments:
return DisplayText(''.join(translated))
return text
def native_text(text):
# The web drag-grip icon is absent from the bundled text fonts.
return DisplayText(text.replace("⠿", "::"))
+12 -2
View File
@@ -5,6 +5,7 @@ import re
from typing import TYPE_CHECKING
from openpilot.common.basedir import BASEDIR
from openpilot.common.swaglog import cloudlog
from openpilot.system.ui.lib.display_translation import DisplayText, display_templates, translate_display
if TYPE_CHECKING:
from openpilot.common.params import Params
@@ -159,6 +160,7 @@ class Multilang:
self._translations: dict[str, str] = {}
self._plurals: dict[str, list[str]] = {}
self._plural_selector = PLURAL_SELECTORS.get('en', lambda n: 0)
self._display_templates = {}
self._load_languages()
@property
@@ -179,6 +181,8 @@ class Multilang:
self._translations = {}
self._plurals = {}
self._display_templates = display_templates(self._translations)
def refresh_language(self) -> None:
if self._params is None:
return
@@ -194,14 +198,19 @@ class Multilang:
self.setup()
def tr(self, text: str) -> str:
return self._translations.get(text, text) or text
if isinstance(text, DisplayText):
return text
return DisplayText(self._translations[text]) if self._translations.get(text) else text
def tr_display(self, text: str) -> str:
return translate_display(text, self._translations, self._display_templates)
def trn(self, singular: str, plural: str, n: int) -> str:
if singular in self._plurals:
idx = self._plural_selector(n)
forms = self._plurals[singular]
if idx < len(forms) and forms[idx]:
return forms[idx]
return DisplayText(forms[idx])
return singular if n == 1 else plural
def _load_languages(self):
@@ -219,6 +228,7 @@ multilang = Multilang()
multilang.setup()
tr, trn = multilang.tr, multilang.trn
tr_display = multilang.tr_display
# no-op marker for static strings translated later
-2
View File
@@ -1,6 +1,5 @@
import pyray as rl
from openpilot.system.ui.lib.application import FONT_SCALE, font_fallback
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.lru_cache import LRUCache
_CACHE_MAXSIZE = 1024
@@ -9,7 +8,6 @@ _cache: LRUCache[int, rl.Vector2] = LRUCache(_CACHE_MAXSIZE)
def measure_text_cached(font: rl.Font, text: str, font_size: int, spacing: float = 0) -> rl.Vector2:
"""Caches text measurements to avoid redundant calculations."""
text = tr(text)
font = font_fallback(font)
spacing = round(spacing, 4)
key = hash((font.texture.id, text, font_size, spacing))
+4
View File
@@ -2,6 +2,8 @@ import pyray as rl
from openpilot.system.ui.lib.lru_cache import LRUCache
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.application import font_fallback
from openpilot.system.ui.lib.multilang import tr_display
from openpilot.system.ui.lib.display_translation import DisplayText, native_text
def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int, spacing: float = 0) -> list[str]:
@@ -43,6 +45,7 @@ _cache: LRUCache[int, list[str]] = LRUCache(_CACHE_MAXSIZE)
def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int, spacing: float = 0) -> list[str]:
text = native_text(tr_display(text))
font = font_fallback(font)
spacing = round(spacing, 4)
key = hash((font.texture.id, text, font_size, max_width, spacing))
@@ -106,5 +109,6 @@ def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int, spacing:
# Add all lines from this paragraph
all_lines.extend(lines)
all_lines = [DisplayText(line) for line in all_lines]
_cache[key] = all_lines
return all_lines
@@ -0,0 +1,63 @@
from openpilot.system.ui.lib.display_translation import DisplayText, display_templates, translate_display
def test_translated_text_is_not_translated_twice_after_formatting_or_elision():
messages = {'Network': 'Red', 'Red': 'Rojo'}
result = translate_display('Network', messages, {})
assert isinstance(result, DisplayText) and result == 'Red'
assert translate_display(result, messages, {}) == 'Red'
assert translate_display(result.format(), messages, {}) == 'Red'
assert isinstance(result[:], DisplayText)
assert isinstance(result.lower(), DisplayText)
assert all(isinstance(part, DisplayText) for part in result.split())
def test_formatted_display_templates_preserve_values_and_named_reordering():
messages = {'{} drives': '{} trayectos', 'Saved {count} items for {name}': 'Para {name}: {count} elementos guardados',
'Preparing {} ({}%)': 'Preparando {} ({}%)'}
templates = display_templates(messages)
assert translate_display('3 drives', messages, templates) == '3 trayectos'
assert translate_display('Saved 12 items for Dom', messages, templates) == 'Para Dom: 12 elementos guardados'
assert translate_display('Preparing Alpha (23%)', messages, templates) == 'Preparando Alpha (23%)'
assert translate_display('SpeedLimitController', messages, templates) == 'SpeedLimitController'
assert translate_display('unknown', messages, templates) == 'unknown'
def test_optional_plural_suffix_and_specific_template_take_priority():
messages = {'{} day': '{}일', 'Expires in {} day{}': '{}일{} 후 만료', '{} minute{} Left': '{}분{} 남음',
'Expires in {} day': '{}일 후 만료', 'Expires in {} days': '{}일 후 만료'}
templates = display_templates(messages)
assert translate_display('Expires in 1 day', messages, templates) == '1일 후 만료'
assert translate_display('Expires in 3 days', messages, templates) == '3일 후 만료'
assert translate_display('1 minute Left', messages, templates) == '1분 남음'
def test_common_label_boundary_preserves_translated_or_literal_marker(monkeypatch):
from openpilot.system.ui.widgets.label import UnifiedLabel
from openpilot.system.ui.lib.multilang import multilang
monkeypatch.setattr(multilang, '_translations', {'Default': 'Standard', 'Standard': 'Standardmodus'})
translated = DisplayText('Standard')
label = object.__new__(UnifiedLabel)
label._text = lambda: translated
assert label.text == 'Standard' and isinstance(label.text, DisplayText)
label._text = DisplayText('Default') # A literal SSID named Default must not be localized.
assert label.text == 'Default'
def test_retained_lazy_label_follows_language_changes(monkeypatch):
from openpilot.system.ui.widgets.label import UnifiedLabel
from openpilot.system.ui.lib.multilang import multilang
label = object.__new__(UnifiedLabel)
label._text = lambda: multilang.tr('Advanced')
monkeypatch.setattr(multilang, '_translations', {'Advanced': 'Erweitert'})
assert label.text == 'Erweitert'
monkeypatch.setattr(multilang, '_translations', {'Advanced': '고급'})
assert label.text == '고급'
def test_ui_label_placeholders_localize_but_user_names_stay_literal():
messages = {'Language': 'Sprache', 'Home': 'Startseite', 'Reset {label} to default': '{label} zurücksetzen',
'Saved for {name}': 'Gespeichert für {name}'}
templates = display_templates(messages)
assert translate_display('Reset Language to default', messages, templates) == 'Sprache zurücksetzen'
assert translate_display('Saved for Home', messages, templates) == 'Gespeichert für Home'
+1 -1
View File
@@ -260,7 +260,7 @@ class HtmlModal(Widget):
super().__init__()
self._content = HtmlRenderer(file_path=file_path, text=text)
self._scroll_panel = GuiScrollPanel()
self._ok_button = Button(tr("OK"), click_callback=gui_app.pop_widget, button_style=ButtonStyle.PRIMARY)
self._ok_button = Button(lambda: tr("OK"), click_callback=gui_app.pop_widget, button_style=ButtonStyle.PRIMARY)
def _render(self, rect: rl.Rectangle):
margin = 50
+10 -3
View File
@@ -10,6 +10,8 @@ from openpilot.system.ui.lib.application import (
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.wrap_text import wrap_text
from openpilot.system.ui.lib.multilang import tr_display
from openpilot.system.ui.lib.display_translation import native_text
ICON_PADDING = 15
@@ -37,6 +39,7 @@ def gui_label(
alignment_vertical: int = TextAlignmentVertical.MIDDLE,
elide_right: bool = True
):
text = native_text(tr_display(text))
font = gui_app.font(font_weight)
text_size = measure_text_cached(font, text, font_size)
display_text = text
@@ -118,7 +121,7 @@ class Label(Widget):
self._update_text(self._text)
def _update_text(self, text):
text = _resolve_value(text)
text = native_text(tr_display(_resolve_value(text)))
key = (text, font_fallback(self._font).texture.id, self._rect.width, self._font_size, self._text_padding,
self._elide_right, self._icon.width if self._icon is not None else None)
if key == self._cached_text_key:
@@ -267,6 +270,7 @@ class UnifiedLabel(Widget):
self._cached_wrapped_lines: list[str] = []
self._cached_line_sizes: list[rl.Vector2] = []
self._cached_total_height: float | None = None
self._cached_font_id = None
self._cached_width: int = -1
# If max_width is set, initialize rect size for Scroller support
@@ -282,7 +286,8 @@ class UnifiedLabel(Widget):
@property
def text(self) -> str:
"""Get the current text content."""
return str(_resolve_value(self._text))
value = _resolve_value(self._text)
return native_text(tr_display(value if isinstance(value, str) else str(value)))
@property
def font_size(self) -> int:
@@ -364,14 +369,16 @@ class UnifiedLabel(Widget):
def _update_text_cache(self, available_width: int):
"""Update cached text processing data."""
text = self.text
font_id = font_fallback(self._font).texture.id
# Check if cache is still valid
if (self._cached_text == text and
if (self._cached_text == text and self._cached_font_id == font_id and
self._cached_width == available_width and
self._cached_wrapped_lines):
return
self._cached_text = text
self._cached_font_id = font_id
self._cached_width = available_width
# Determine wrapping width
+5 -3
View File
@@ -1,3 +1,4 @@
from openpilot.system.ui.lib.display_translation import DisplayText
from enum import IntEnum
from functools import partial
from typing import cast
@@ -71,7 +72,7 @@ class NetworkUI(Widget):
self._current_panel: PanelType = PanelType.WIFI
self._wifi_panel = self._child(WifiManagerUI(wifi_manager))
self._advanced_panel = self._child(AdvancedNetworkSettings(wifi_manager))
self._nav_button = self._child(NavButton(tr("Advanced")))
self._nav_button = self._child(NavButton(lambda: tr("Advanced")))
self._nav_button.set_click_callback(self._cycle_panel)
self._action_guard: Callable[[], bool] | None = None
@@ -506,10 +507,11 @@ class WifiManagerUI(Widget):
def _on_network_updated(self, networks: list[Network]):
self._networks = networks
for n in self._networks:
self._networks_buttons[n.ssid] = Button(normalize_ssid(n.ssid), partial(self._networks_buttons_callback, n), font_size=55,
self._networks_buttons[n.ssid] = Button(DisplayText(normalize_ssid(n.ssid)), partial(self._networks_buttons_callback, n), font_size=55,
text_alignment=TextAlignment.LEFT, button_style=ButtonStyle.TRANSPARENT_WHITE_TEXT)
self._networks_buttons[n.ssid].set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid())
self._forget_networks_buttons[n.ssid] = Button(tr("Forget"), partial(self._forget_networks_buttons_callback, n), button_style=ButtonStyle.FORGET_WIFI,
self._forget_networks_buttons[n.ssid] = Button(lambda: tr("Forget"), partial(self._forget_networks_buttons_callback, n),
button_style=ButtonStyle.FORGET_WIFI,
font_size=45)
self._forget_networks_buttons[n.ssid].set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid())