Weekdays that fit the weather columns, the top bar's own abbreviation, tile texts that fit, and a check that measures them

- The CYD's weather columns hold two letters beside the icon: every language has its own two-letter days (the CLDR's
  short width, Polish nd pn wt); French mar became nar there. check fails on a longer one.
- The top bar takes the language's abbreviation (sam. 19 sept., Sa., 19. Sept.), English keeps Sa 19 Sep.
- The editor's clock and date follow the screens: 7:05 PM in the top bar, 7:05 on the clock card.
- A long weather word ends in an ellipsis before the days.
- Tile lines that were cut on a screen are shorter (Zum Öffnen, Ouvrir, Otwórz, Sin imagen, ...); check measures
  them with the screens' font against the tile width of both boards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-19 17:13:59 +02:00
parent 365ed158b9
commit f02f429188
26 changed files with 145 additions and 81 deletions
+4 -2
View File
@@ -88,11 +88,13 @@ inline std::string ago_text(int64_t then, int64_t now) {
return plural(txt::time_years_ago, n(31536000));
}
// "Mo 14 Sep" in English, "ma 14 sep" in Dutch (screen.date.top_bar); day_of_week 1 is Sunday, as ESPHome counts.
// "Mo 14 Sep" in English, "za 19 sep" in Dutch, "sam. 19 sept." in French (screen.date.top_bar, with the language's
// abbreviation as {weekday} or its two letters as {weekday_min}); day_of_week 1 is Sunday, as ESPHome counts.
inline std::string date_text(int day_of_week, int day_of_month, int month) {
using namespace screen_text;
if (day_of_week < 1 || day_of_week > 7 || month < 1 || month > 12) return "—";
std::string text = fill(txt::date_top_bar, "weekday", tr(txt::date_weekdays_min + day_of_week - 1));
std::string text = fill(txt::date_top_bar, "weekday", tr(txt::date_weekdays_short + day_of_week - 1));
text = fill(text, "weekday_min", tr(txt::date_weekdays_min + day_of_week - 1));
text = fill(text, "day", std::to_string(day_of_month));
return fill(text, "month", tr(txt::date_months_short + month - 1));
}
+3 -1
View File
@@ -2617,7 +2617,9 @@ inline void render_forecast(Widgets &w,const Tile &t,bool large,int width,int he
auto *icon=part_label(w,0,w.icon_font,0,y+(std::max(icon_h,temp_h)-icon_h)/2,icon_h+4,LV_TEXT_ALIGN_LEFT,t.available()?weather_icon(t.state):"\U000F0595");
lv_obj_set_width(icon,lv_font_get_line_height(w.icon_font)+4);
part_label(w,1,temp_font,icon_h+6,y+(std::max(icon_h,temp_h)-temp_h)/2,left-icon_h-6,LV_TEXT_ALIGN_LEFT,std::isfinite(t.current)?b:"");
part_label(w,2,w.value_font,0,y+std::max(icon_h,temp_h)+2,left-4,LV_TEXT_ALIGN_LEFT,weather_text(t.state));
// Home Assistant's word for the weather can be long ("częściowe zachmurzenie"): it ends in an ellipsis before the days.
auto *condition=part_label(w,2,w.value_font,0,y+std::max(icon_h,temp_h)+2,left-4,LV_TEXT_ALIGN_LEFT,weather_text(t.state));
if(lv_label_get_long_mode(condition)!=LV_LABEL_LONG_DOT)lv_label_set_long_mode(condition,LV_LABEL_LONG_DOT);
int column=(width-left)/5;
for(unsigned k=0;k<5;++k){
static const Forecast no_day;int x=left+k*column;bool has=k<t.extra().forecast.size();const auto &f=has?t.extra().forecast[k]:no_day;
+5 -2
View File
@@ -27,7 +27,9 @@ Three parts fill themselves. Don't change them by hand:
[Home Assistant's translations](https://developers.home-assistant.io/docs/translations/); we pick it up from there.
- `screen.date` holds day and month names and the order of a date. `screen.number` says how numbers are written.
`screen.time.am` and `.pm` are the day periods. All of this comes from the Unicode CLDR, the same data every browser
uses, through `tools/i18n.py cldr`. `screen.number.percent` follows Home Assistant's own rule: a space before `%` in
uses, through `tools/i18n.py cldr`. One list is yours: `screen.date.weekdays_min`, the days of the weather card in
two letters (`di`, `lu`, `ma` in French), because the small screen has room for no more. `cldr` starts a new language
with a guess and then leaves the list alone. `screen.number.percent` follows Home Assistant's own rule: a space before `%` in
Czech, German, Finnish, French, Slovak and Swedish, none in the other languages.
- `_meta.clock`, 12 or 24 hours, also comes from the CLDR.
@@ -44,7 +46,8 @@ there is a `sv.json`, but already the Swedish 24-hour clock and `1 234,5`.
- wrong words;
- words that differ from the ones the Home Assistant app uses in your language (the Home Assistant app is the
reference: *Dashboard*, *Automation*, *Entity* are what people know);
- texts that are too long for a small screen.
- texts that are too long for a small screen. `tools/i18n.py check` measures the line under a tile's name with the
screens' own font and says when it doesn't fit; for the rest, compare with the English.
3. Look at it for real, if you can. In ESP Screens open **Settings → Language & region** and choose your language. The
editor itself follows the language of your Home Assistant profile. The screens show the new texts after their
firmware update.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>ESP Screens</title>
<script type="module" crossorigin src="./assets/index-CRR4xeFy.js"></script>
<script type="module" crossorigin src="./assets/index-Cw-mwwtp.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-B7AxjXTR.css">
</head>
<body>
+3 -3
View File
@@ -142,7 +142,7 @@
"hours_minutes": "{h} Std. {m}"
},
"tile": {
"tap_to_open": "Zum Öffnen tippen",
"tap_to_open": "Zum Öffnen",
"page": "Seite {n}",
"refused": "Abgelehnt",
"command_sent": "Befehl gesendet...",
@@ -214,7 +214,7 @@
},
"media": {
"muted": "Stumm",
"not_playing": "Keine Wiedergabe",
"not_playing": "Nichts läuft",
"loading": "Wird geladen"
},
"timer": {
@@ -245,7 +245,7 @@
"loading": "Bild wird geladen",
"live": "Live",
"recording": "Aufnahme",
"tap_to_view": "Zum Ansehen tippen",
"tap_to_view": "Ansehen",
"no_image_yet": "Noch kein Bild"
},
"calibration": {
+1 -1
View File
@@ -86,7 +86,7 @@
"Nov",
"Dec"
],
"top_bar": "{weekday} {day} {month}",
"top_bar": "{weekday_min} {day} {month}",
"full": "{weekday} {day} {month}",
"day_month": "{day} {month}",
"weekday_time": "{weekday} {time}"
+9 -9
View File
@@ -50,13 +50,13 @@
"sáb"
],
"weekdays_min": [
"dom",
"lun",
"mar",
"mié",
"jue",
"vie",
"sáb"
"do",
"lu",
"ma",
"mi",
"ju",
"vi",
"sá"
],
"months": [
"enero",
@@ -214,7 +214,7 @@
},
"media": {
"muted": "Silenciado",
"not_playing": "No se reproduce nada",
"not_playing": "Inactivo",
"loading": "Cargando"
},
"timer": {
@@ -246,7 +246,7 @@
"live": "En directo",
"recording": "Grabando",
"tap_to_view": "Toca para ver",
"no_image_yet": "Aún no hay imagen"
"no_image_yet": "Sin imagen"
},
"calibration": {
"title": "Sigue la calibración",
+9 -9
View File
@@ -50,13 +50,13 @@
"sam."
],
"weekdays_min": [
"dim",
"lun",
"mar",
"mer",
"jeu",
"ven",
"sam"
"di",
"lu",
"ma",
"me",
"je",
"ve",
"sa"
],
"months": [
"janvier",
@@ -142,7 +142,7 @@
"hours_minutes": "{h} h {m}"
},
"tile": {
"tap_to_open": "Touchez pour ouvrir",
"tap_to_open": "Ouvrir",
"page": "Page {n}",
"refused": "Refusé",
"command_sent": "Commande envoyée...",
@@ -246,7 +246,7 @@
"live": "En direct",
"recording": "Enregistrement",
"tap_to_view": "Touchez pour voir",
"no_image_yet": "Pas encore d'image"
"no_image_yet": "Pas d'image"
},
"calibration": {
"title": "Suivez l'étalonnage",
+9 -9
View File
@@ -50,13 +50,13 @@
"sab"
],
"weekdays_min": [
"dom",
"lun",
"mar",
"mer",
"gio",
"ven",
"sab"
"do",
"lu",
"ma",
"me",
"gi",
"ve",
"sa"
],
"months": [
"gennaio",
@@ -214,7 +214,7 @@
},
"media": {
"muted": "Muto",
"not_playing": "Nessuna riproduzione",
"not_playing": "Inattivo",
"loading": "Caricamento"
},
"timer": {
@@ -246,7 +246,7 @@
"live": "Dal vivo",
"recording": "Registrazione",
"tap_to_view": "Tocca per vedere",
"no_image_yet": "Ancora nessuna immagine"
"no_image_yet": "Niente immagine"
},
"calibration": {
"title": "Segui la calibrazione",
+1 -1
View File
@@ -245,7 +245,7 @@
"loading": "Beeld laden",
"live": "Live",
"recording": "Neemt op",
"tap_to_view": "Tik om te bekijken",
"tap_to_view": "Tik om te kijken",
"no_image_yet": "Nog geen beeld"
},
"calibration": {
+7 -7
View File
@@ -50,13 +50,13 @@
"sob."
],
"weekdays_min": [
"nie",
"pon",
"nd",
"pn",
"wt",
"śr",
"czw",
"cz",
"pt",
"sob"
"sb"
],
"months": [
"stycznia",
@@ -142,7 +142,7 @@
"hours_minutes": "{h} godz. {m}"
},
"tile": {
"tap_to_open": "Dotknij, aby otworzyć",
"tap_to_open": "Otwórz",
"page": "Strona {n}",
"refused": "Odrzucono",
"command_sent": "Wysłano polecenie...",
@@ -235,7 +235,7 @@
},
"script": {
"running": "Działa...",
"never_run": "Nigdy nie uruchomiono",
"never_run": "Nie uruchamiano",
"last_time": "Ostatnio {time}",
"yesterday_time": "Wczoraj {time}",
"last_date": "Ostatnio {date}"
@@ -245,7 +245,7 @@
"loading": "Ładowanie obrazu",
"live": "Na żywo",
"recording": "Nagrywanie",
"tap_to_view": "Dotknij, aby zobaczyć",
"tap_to_view": "Zobacz",
"no_image_yet": "Brak obrazu"
},
"calibration": {
+9 -9
View File
@@ -50,13 +50,13 @@
"sábado"
],
"weekdays_min": [
"dom",
"seg",
"ter",
"qua",
"qui",
"sex",
"sáb"
"do",
"sg",
"te",
"qu",
"qi",
"sx",
"sb"
],
"months": [
"janeiro",
@@ -214,7 +214,7 @@
},
"media": {
"muted": "Sem som",
"not_playing": "Nada a reproduzir",
"not_playing": "Inativo",
"loading": "A carregar"
},
"timer": {
@@ -246,7 +246,7 @@
"live": "Em direto",
"recording": "A gravar",
"tap_to_view": "Toque para ver",
"no_image_yet": "Ainda sem imagem"
"no_image_yet": "Sem imagem"
},
"calibration": {
"title": "Siga a calibração",
+1 -1
View File
@@ -69,7 +69,7 @@ const char *const screen_text::TABLE[] = {
"Oct",
"Nov",
"Dec",
"{weekday} {day} {month}",
"{weekday_min} {day} {month}",
"{weekday} {day} {month}",
"{day} {month}",
"{weekday} {time}",
+49 -2
View File
@@ -87,6 +87,9 @@ def check():
problems, notes = [], []
base = dict(gen.flatten({k: v for k, v in english.items() if k != '_meta'}))
drawable = font_letters()
width = text_width()
if width is None:
notes.append('Pillow is missing: tile texts were not measured')
for code, data in langs.items():
meta = data.get('_meta', {})
for field in ('name', 'english', 'script', 'plural', 'checked'):
@@ -105,7 +108,10 @@ def check():
if key not in base or not isinstance(text, str):
continue
source = base[key]
if set(PLACEHOLDER.findall(text)) != set(PLACEHOLDER.findall(source)):
if key in EITHER_PLACEHOLDER:
if not set(PLACEHOLDER.findall(text)) <= EITHER_PLACEHOLDER[key]:
problems.append(f'{code}: {key} has placeholders beyond {sorted(EITHER_PLACEHOLDER[key])}')
elif set(PLACEHOLDER.findall(text)) != set(PLACEHOLDER.findall(source)):
problems.append(f'{code}: {key} has placeholders {sorted(set(PLACEHOLDER.findall(text)))}, '
f'English {sorted(set(PLACEHOLDER.findall(source)))}')
plural = ' | ' in source
@@ -126,6 +132,14 @@ def check():
ours = not key.startswith(('screen.ha.', 'screen.date.', 'screen.number.'))
if ours and code != 'en' and len(text) > max(8, len(source) * 1.6) and not plural:
notes.append(f'{code}: {key} is much longer than the English ({len(text)} vs {len(source)})')
if width and key in TILE_LINES:
for board, (size, room) in TILE_LINES[key].items():
if width(size, text) > room:
problems.append(f'{code}: {key} "{text}" is too wide for a tile on the {board} '
f'({width(size, text):.0f} of {room} px); make it shorter')
# The weather columns of the CYD hold a day of two letters beside its icon.
if key.startswith('screen.date.weekdays_min.') and len(text) > 2:
problems.append(f'{code}: {key} "{text}" is longer than two letters; the CYD\'s weather columns cut it')
for line in notes:
print('note:', line)
for line in problems:
@@ -134,6 +148,34 @@ def check():
return 1 if problems else 0
# One line under a tile's name, and the room it has there: Roboto 400 at the CYD's 11 px in about 88 px, at the
# Guition's 16 px in about 128 px (packages/boards: TILE_W, FONT_SUBLABEL_SIZE). A longer text is cut; `check` fails.
TILE_ROOM = {'CYD': (11, 88), 'Guition': (16, 128)}
# Camera tiles are the Guition's alone.
TILE_LINES = {'screen.tile.tap_to_open': TILE_ROOM, 'screen.script.never_run': TILE_ROOM, 'screen.script.running': TILE_ROOM,
'screen.media.not_playing': TILE_ROOM, 'screen.timer.paused': TILE_ROOM,
'screen.camera.tap_to_view': {'Guition': TILE_ROOM['Guition']},
'screen.camera.no_image_yet': {'Guition': TILE_ROOM['Guition']}}
def text_width():
"""(size, text) -> pixels in the screens' Roboto 400, or None without Pillow (the check then skips widths)."""
try:
from PIL import ImageFont
except ImportError:
return None
fonts = {}
def width(size, text):
if size not in fonts:
fonts[size] = ImageFont.truetype(str(ROOT / 'fonts' / 'Roboto-400.ttf'), size)
return fonts[size].getlength(text)
return width
# Texts whose placeholders a language picks from a set: the top bar's date takes the weekday's abbreviation ({weekday},
# "sam.") or its two letters ({weekday_min}, "Sa" in English).
EITHER_PLACEHOLDER = {'screen.date.top_bar': {'weekday', 'weekday_min', 'day', 'month'}}
# English that stays in the firmware on purpose, with why. Everything else a screen shows comes from the translations,
# and `lint` fails on a new English text in the code, so it never slips back in.
LINT_KEEP = {
@@ -397,7 +439,12 @@ def cldr_dates(write, codes=None):
before = json.dumps(data, ensure_ascii=False)
data.setdefault('_meta', {})['clock'] = cldr['clock']
number = {**cldr['number'], 'percent': ' %' if percent_space(code) else '%'}
merge(data.setdefault('screen', {}), {'number': number, 'time': cldr['time'], 'date': cldr['date']})
date = dict(cldr['date'])
# The two letters of the weather columns are the language's own choice once it has them (the CLDR's short
# width, which Node's Intl doesn't give): only a new language starts from the abbreviation.
if data.get('screen', {}).get('date', {}).get('weekdays_min'):
date.pop('weekdays_min')
merge(data.setdefault('screen', {}), {'number': number, 'time': cldr['time'], 'date': date})
if json.dumps(data, ensure_ascii=False) != before:
print(f'{code}: {"wrote" if write else "would change"} the CLDR texts')
if write:
+1 -1
View File
@@ -9,7 +9,7 @@ import type { HeaderItem } from "../types";
const query = ref("");
const taken = computed(() => new Set(topbarItems().map(itemKey)));
const samples = computed(() => ({
clock: clockText(clock24.value, new Date(state.now)),
clock: clockText(clock24.value, new Date(state.now), screenLanguage.value),
analog: t("editor.topbar.analog_sample"),
date: dateText(new Date(state.now), screenLanguage.value),
} as Record<string, string>));
+1 -1
View File
@@ -51,7 +51,7 @@ const liveNote = computed(() => {
return !view.shown ? t("editor.topbar.live.hidden") : overflow.value.has(props.index) ? t("editor.topbar.live.overflow") : t("editor.topbar.live.looks");
});
const samples = computed(() => ({
clock: clockText(clock24.value, new Date(state.now)),
clock: clockText(clock24.value, new Date(state.now), screenLanguage.value),
analog: t("editor.topbar.analog_sample"),
date: dateText(new Date(state.now), screenLanguage.value),
} as Record<string, string>));
+10 -6
View File
@@ -12,14 +12,18 @@ export const BUILTIN_ICONS: Record<string, string> = { clock: "clock-outline", a
export const itemKey = (item: HeaderItem) => JSON.stringify(item);
export const glyph = (cp: string) => String.fromCodePoint(parseInt(cp, 16));
export function clockText(clock24: boolean, now = new Date()) {
let hours = now.getHours();
// The screen formats with %I:%M when the 24-hour clock is off.
if (!clock24) hours = hours % 12 || 12;
return `${String(hours).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
// The time as the screens write it (screen_text::clock_text): "07:12" on 24 hours; on 12 "7:12 PM" in the top bar, with
// the language's day periods, and "7:12" in the clock card's big digits, which have no letters.
export function clockText(clock24: boolean, now = new Date(), locale?: string) {
const minutes = String(now.getMinutes()).padStart(2, "0");
if (clock24) return `${String(now.getHours()).padStart(2, "0")}:${minutes}`;
const time = `${now.getHours() % 12 || 12}:${minutes}`;
return locale ? `${time} ${t(`screen.time.${now.getHours() < 12 ? "am" : "pm"}`, {}, { locale })}` : time;
}
// "Sa 19 Sep", "za 19 sep", "sam. 19 sept.": the language's pattern with its abbreviation or its two letters.
export const dateText = (now = new Date(), locale = "en") => t("screen.date.top_bar", {
weekday: t(`screen.date.weekdays_min.${now.getDay()}`, {}, { locale }),
weekday: t(`screen.date.weekdays_short.${now.getDay()}`, {}, { locale }),
weekday_min: t(`screen.date.weekdays_min.${now.getDay()}`, {}, { locale }),
day: now.getDate(),
month: t(`screen.date.months_short.${now.getMonth()}`, {}, { locale }),
}, { locale });
+1 -1
View File
@@ -583,7 +583,7 @@ export function topbarLabel(item: HeaderItem) {
// What the item shows right now: { icon, text, color, shown }. Entities wait for the add-on's preview.
export function topbarView(item: HeaderItem): ItemView {
const now = new Date(state.now);
if (item.type === "clock") return { text: clockText(clock24.value, now), shown: true };
if (item.type === "clock") return { text: clockText(clock24.value, now, screenLanguage.value), shown: true };
if (item.type === "date") return { text: dateText(now, screenLanguage.value), shown: true };
if (item.type === "analog") return { analog: true, shown: true };
const p = state.topbarPreviews[itemKey(item)];
+2 -1
View File
@@ -175,7 +175,8 @@ describe("the mockup speaks the screens' language", () => {
ha: { unavailable: "Nicht verfügbar", on: "An", weather: { windy: "Windig" }, binary: { door_on: "Offen" }, button: { run: "Ausführen" } },
tile: { page: "Seite {n}" },
time: { hours_ago: "Vor 1 Stunde | Vor {n} Stunden", just_now: "Gerade eben" },
date: { weekdays_min: ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"], months_short: ["Jan.", "Feb.", "März", "Apr.", "Mai", "Juni", "Juli", "Aug.", "Sept.", "Okt.", "Nov.", "Dez."], top_bar: "{weekday}., {day}. {month}" },
date: { weekdays_min: ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"], weekdays_short: ["So.", "Mo.", "Di.", "Mi.", "Do.", "Fr.", "Sa."],
months_short: ["Jan.", "Feb.", "März", "Apr.", "Mai", "Juni", "Juli", "Aug.", "Sept.", "Okt.", "Nov.", "Dez."], top_bar: "{weekday}, {day}. {month}" },
number: { decimal: ",", group: ".", group_min: "2" },
},
}, meta("one_other"));
+6 -1
View File
@@ -28,8 +28,13 @@ describe("clock and date", () => {
const evening = new Date(2026, 8, 15, 19, 5);
it("formats the clock the way the screen does", () => {
expect(clockText(true, evening)).toBe("19:05");
expect(clockText(false, evening)).toBe("07:05");
// The clock card's big digits: no leading zero, no day period (its font has no letters).
expect(clockText(false, evening)).toBe("7:05");
expect(clockText(false, new Date(2026, 8, 15, 0, 30))).toBe("12:30");
// The top bar: with the language's day period, as screen_text::clock_text writes it.
expect(clockText(false, evening, "en")).toBe("7:05 PM");
expect(clockText(false, new Date(2026, 8, 15, 0, 30), "en")).toBe("12:30 AM");
expect(clockText(true, evening, "en")).toBe("19:05");
});
it("writes the date short", () => {
expect(dateText(evening)).toBe("Tu 15 Sep");