mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-04 19:43:49 +08:00
Translate Galaxy and native UI through the shared language setting
This commit is contained in:
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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():
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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("⠿", "::"))
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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'
|
||||
@@ -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,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
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user