Build each screen in one language: the screen texts from the translations, European letters in the fonts, and Language & region in the app
The firmware takes its texts from screen_manager/translations/<code>.json at build time (components/smart_display: one table in main.cpp, keys in screen_text_keys.h), and the fonts carry every letter European languages write with. The app keeps Settings -> Language & region (language, clock, numbers) for every screen, writes LANGUAGE into the screens' YAML and sends the clock and the number format in the layout message; the per-screen 24-hour clock goes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
"""The texts a screen shows, in the language it is built for (app 0.2.90).
|
||||
|
||||
Every text lives in screen_manager/translations/<code>.json; its `screen` section is what the firmware draws. A build
|
||||
takes one language: this module turns that section into one C++ table in the build's main.cpp, so a screen carries a
|
||||
single language and looks a text up by its index (screen_text.h). The keys are the same for every language and come
|
||||
from en.json in document order; tools/i18n.py writes them to screen_text_keys.h, and a static_assert in the build
|
||||
checks that header against the JSON it is built from. A key a language lacks takes the English text.
|
||||
|
||||
Plain Python on purpose, without ESPHome: __init__.py imports it for the build, tools/i18n.py and the tests use it too.
|
||||
The ESP Screens app has its own reader (screen_manager/app/i18n.py), as the app's image holds no components folder.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# The repository root: components/smart_display/ sits two folders down, in a checkout and in the clone ESPHome makes
|
||||
# of the repository for an external component (git clone --depth=1 of the whole repository).
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TRANSLATIONS = ROOT / 'screen_manager' / 'translations'
|
||||
LANGUAGE_CODE = re.compile(r'[a-z]{2,3}(-[A-Za-z0-9]{2,8})?')
|
||||
|
||||
# How a language picks the form of a plural text ("1 hour ago | {n} hours ago"): the index of the form for n, as C++.
|
||||
# The editor has the same rules (web/src/i18n.ts); docs/TRANSLATING.md lists which languages use which.
|
||||
PLURAL_RULES = {
|
||||
'one_other': 'return n == 1 ? 0 : 1;',
|
||||
'one_upto_1': 'return (n < 0 ? -n : n) <= 1 ? 0 : 1;',
|
||||
'slavic_pl': 'if (n == 1) return 0; int t = n % 10, h = n % 100; '
|
||||
'return t >= 2 && t <= 4 && (h < 12 || h > 14) ? 1 : 2;',
|
||||
'east_slavic': 'int t = n % 10, h = n % 100; if (t == 1 && h != 11) return 0; '
|
||||
'return t >= 2 && t <= 4 && (h < 12 || h > 14) ? 1 : 2;',
|
||||
'none': 'return 0;',
|
||||
}
|
||||
|
||||
|
||||
def load(code, folder=TRANSLATIONS):
|
||||
"""A language file as a dict; None when there is none."""
|
||||
path = Path(folder) / f'{code}.json'
|
||||
if not LANGUAGE_CODE.fullmatch(code or '') or not path.is_file():
|
||||
return None
|
||||
return json.loads(path.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
def resolve(code, folder=TRANSLATIONS):
|
||||
"""The language a build takes for `code`: the file itself, else its base language (pt-BR -> pt), else English."""
|
||||
for candidate in (code, (code or '').split('-')[0]):
|
||||
if candidate and load(candidate, folder) is not None:
|
||||
return candidate
|
||||
return 'en'
|
||||
|
||||
|
||||
def flatten(node, prefix=''):
|
||||
"""(key, text) pairs in document order; a list's items get .0, .1, ... ."""
|
||||
if isinstance(node, dict):
|
||||
items = node.items()
|
||||
elif isinstance(node, list):
|
||||
items = ((str(index), value) for index, value in enumerate(node))
|
||||
else:
|
||||
return [(prefix, node)]
|
||||
pairs = []
|
||||
for key, value in items:
|
||||
pairs.extend(flatten(value, f'{prefix}.{key}' if prefix else key))
|
||||
return pairs
|
||||
|
||||
|
||||
def screen_pairs(data):
|
||||
"""The `screen` section as (key, text) pairs, the key without the section."""
|
||||
return [(key, value) for key, value in flatten((data or {}).get('screen', {})) if isinstance(value, str)]
|
||||
|
||||
|
||||
def identifier(key):
|
||||
"""The C++ name of a key: settings.dark_mode -> settings_dark_mode."""
|
||||
return re.sub(r'[^A-Za-z0-9]+', '_', key).strip('_')
|
||||
|
||||
|
||||
def keys_hash(keys):
|
||||
"""FNV-1a over the keys, so a build notices a screen_text_keys.h that no longer matches the JSON."""
|
||||
value = 0x811C9DC5
|
||||
for byte in '\n'.join(keys).encode('utf-8'):
|
||||
value = ((value ^ byte) * 0x01000193) & 0xFFFFFFFF
|
||||
return value
|
||||
|
||||
|
||||
def cpp_string(text):
|
||||
"""A C++ string literal of UTF-8 text; every byte outside printable ASCII as a three-digit octal escape."""
|
||||
out = []
|
||||
for byte in text.encode('utf-8'):
|
||||
char = chr(byte)
|
||||
if char in '"\\':
|
||||
out.append('\\' + char)
|
||||
elif 32 <= byte < 127:
|
||||
out.append(char)
|
||||
else:
|
||||
out.append(f'\\{byte:03o}')
|
||||
return '"' + ''.join(out) + '"'
|
||||
|
||||
|
||||
def table(code, folder=TRANSLATIONS):
|
||||
"""(language, keys, texts, plural rule) of a build: every English key, in its language where it has one."""
|
||||
english = load('en', folder)
|
||||
language = resolve(code, folder)
|
||||
own = dict(screen_pairs(load(language, folder))) if language != 'en' else {}
|
||||
keys, texts = [], []
|
||||
for key, text in screen_pairs(english):
|
||||
keys.append(key)
|
||||
translated = own.get(key)
|
||||
texts.append(translated if isinstance(translated, str) and translated.strip() else text)
|
||||
meta = (load(language, folder) or {}).get('_meta', {})
|
||||
rule = meta.get('plural') if meta.get('plural') in PLURAL_RULES else 'one_other'
|
||||
return language, keys, texts, rule
|
||||
|
||||
|
||||
def arrays(keys):
|
||||
"""{list key: (index of its first item, length)} for the lists among the keys (date.months_short.0 ...)."""
|
||||
found = {}
|
||||
for index, key in enumerate(keys):
|
||||
head, _, last = key.rpartition('.')
|
||||
if head and last.isdigit():
|
||||
start, length = found.get(head, (index, 0))
|
||||
found[head] = (start, length + 1)
|
||||
return found
|
||||
|
||||
|
||||
def keys_header(keys):
|
||||
"""screen_text_keys.h: the index of every key, and the hash of them all."""
|
||||
lines = [
|
||||
'// GENERATED by tools/i18n.py from screen_manager/translations/en.json; run `python3 tools/i18n.py header`',
|
||||
'// after changing the keys of the `screen` section. The index of every text a screen shows in its language\'s',
|
||||
'// table (screen_text.h); a list such as the month names is its first index plus a count.',
|
||||
'#pragma once',
|
||||
'#include <cstdint>',
|
||||
'',
|
||||
'namespace screen_text {',
|
||||
'namespace txt {',
|
||||
]
|
||||
lists = arrays(keys)
|
||||
listed = {index for start, length in lists.values() for index in range(start, start + length)}
|
||||
for index, key in enumerate(keys):
|
||||
if index not in listed:
|
||||
lines.append(f'constexpr uint16_t {identifier(key)} = {index};')
|
||||
for key, (start, length) in lists.items():
|
||||
lines.append(f'constexpr uint16_t {identifier(key)} = {start};')
|
||||
lines.append(f'constexpr uint16_t {identifier(key)}_count = {length};')
|
||||
lines += [
|
||||
'} // namespace txt',
|
||||
f'constexpr uint16_t KEY_COUNT = {len(keys)};',
|
||||
f'constexpr uint32_t KEYS_HASH = 0x{keys_hash(keys):08X}u;',
|
||||
'} // namespace screen_text',
|
||||
'',
|
||||
]
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def definitions(code, folder=TRANSLATIONS):
|
||||
"""The C++ a build adds to main.cpp: its language's table and plural rule, and the check against the keys header."""
|
||||
language, keys, texts, rule = table(code, folder)
|
||||
rows = ',\n'.join(f' {cpp_string(text)}' for text in texts)
|
||||
return language, (
|
||||
f'static_assert(screen_text::KEYS_HASH == 0x{keys_hash(keys):08X}u && screen_text::KEY_COUNT == {len(keys)},\n'
|
||||
' "screen_text_keys.h does not match screen_manager/translations/en.json: run tools/i18n.py header");\n'
|
||||
f'const char *const screen_text::TABLE[] = {{\n{rows}\n}};\n'
|
||||
f'const char *const screen_text::LANGUAGE = {cpp_string(language)};\n'
|
||||
f'int screen_text::plural_index(int n) {{ {PLURAL_RULES[rule]} }}\n'
|
||||
)
|
||||
|
||||
|
||||
def english_header(folder=TRANSLATIONS):
|
||||
"""tests/screen_text_en.h: the English table for the C++ tests, which build without ESPHome."""
|
||||
_, text = definitions('en', folder)
|
||||
return ('// GENERATED by tools/i18n.py (`python3 tools/i18n.py header`): the English texts for the C++ tests, which\n'
|
||||
'// build a header without ESPHome and so without the table a screen\'s build writes into main.cpp.\n'
|
||||
'#pragma once\n'
|
||||
'#include "components/smart_display/screen_text.h"\n\n' + text)
|
||||
Reference in New Issue
Block a user