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)); 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) { inline std::string date_text(int day_of_week, int day_of_month, int month) {
using namespace screen_text; using namespace screen_text;
if (day_of_week < 1 || day_of_week > 7 || month < 1 || month > 12) return "—"; 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)); text = fill(text, "day", std::to_string(day_of_month));
return fill(text, "month", tr(txt::date_months_short + month - 1)); 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"); 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); 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,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; int column=(width-left)/5;
for(unsigned k=0;k<5;++k){ 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; 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. [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.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 `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. Czech, German, Finnish, French, Slovak and Swedish, none in the other languages.
- `_meta.clock`, 12 or 24 hours, also comes from the CLDR. - `_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; - wrong words;
- words that differ from the ones the Home Assistant app uses in your language (the Home Assistant app is the - 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); 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 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 editor itself follows the language of your Home Assistant profile. The screens show the new texts after their
firmware update. 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="viewport" content="width=device-width,initial-scale=1" />
<meta name="color-scheme" content="light dark" /> <meta name="color-scheme" content="light dark" />
<title>ESP Screens</title> <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"> <link rel="stylesheet" crossorigin href="./assets/index-B7AxjXTR.css">
</head> </head>
<body> <body>
+3 -3
View File
@@ -142,7 +142,7 @@
"hours_minutes": "{h} Std. {m}" "hours_minutes": "{h} Std. {m}"
}, },
"tile": { "tile": {
"tap_to_open": "Zum Öffnen tippen", "tap_to_open": "Zum Öffnen",
"page": "Seite {n}", "page": "Seite {n}",
"refused": "Abgelehnt", "refused": "Abgelehnt",
"command_sent": "Befehl gesendet...", "command_sent": "Befehl gesendet...",
@@ -214,7 +214,7 @@
}, },
"media": { "media": {
"muted": "Stumm", "muted": "Stumm",
"not_playing": "Keine Wiedergabe", "not_playing": "Nichts läuft",
"loading": "Wird geladen" "loading": "Wird geladen"
}, },
"timer": { "timer": {
@@ -245,7 +245,7 @@
"loading": "Bild wird geladen", "loading": "Bild wird geladen",
"live": "Live", "live": "Live",
"recording": "Aufnahme", "recording": "Aufnahme",
"tap_to_view": "Zum Ansehen tippen", "tap_to_view": "Ansehen",
"no_image_yet": "Noch kein Bild" "no_image_yet": "Noch kein Bild"
}, },
"calibration": { "calibration": {
+1 -1
View File
@@ -86,7 +86,7 @@
"Nov", "Nov",
"Dec" "Dec"
], ],
"top_bar": "{weekday} {day} {month}", "top_bar": "{weekday_min} {day} {month}",
"full": "{weekday} {day} {month}", "full": "{weekday} {day} {month}",
"day_month": "{day} {month}", "day_month": "{day} {month}",
"weekday_time": "{weekday} {time}" "weekday_time": "{weekday} {time}"
+9 -9
View File
@@ -50,13 +50,13 @@
"sáb" "sáb"
], ],
"weekdays_min": [ "weekdays_min": [
"dom", "do",
"lun", "lu",
"mar", "ma",
"mié", "mi",
"jue", "ju",
"vie", "vi",
"sáb" "sá"
], ],
"months": [ "months": [
"enero", "enero",
@@ -214,7 +214,7 @@
}, },
"media": { "media": {
"muted": "Silenciado", "muted": "Silenciado",
"not_playing": "No se reproduce nada", "not_playing": "Inactivo",
"loading": "Cargando" "loading": "Cargando"
}, },
"timer": { "timer": {
@@ -246,7 +246,7 @@
"live": "En directo", "live": "En directo",
"recording": "Grabando", "recording": "Grabando",
"tap_to_view": "Toca para ver", "tap_to_view": "Toca para ver",
"no_image_yet": "Aún no hay imagen" "no_image_yet": "Sin imagen"
}, },
"calibration": { "calibration": {
"title": "Sigue la calibración", "title": "Sigue la calibración",
+9 -9
View File
@@ -50,13 +50,13 @@
"sam." "sam."
], ],
"weekdays_min": [ "weekdays_min": [
"dim", "di",
"lun", "lu",
"mar", "ma",
"mer", "me",
"jeu", "je",
"ven", "ve",
"sam" "sa"
], ],
"months": [ "months": [
"janvier", "janvier",
@@ -142,7 +142,7 @@
"hours_minutes": "{h} h {m}" "hours_minutes": "{h} h {m}"
}, },
"tile": { "tile": {
"tap_to_open": "Touchez pour ouvrir", "tap_to_open": "Ouvrir",
"page": "Page {n}", "page": "Page {n}",
"refused": "Refusé", "refused": "Refusé",
"command_sent": "Commande envoyée...", "command_sent": "Commande envoyée...",
@@ -246,7 +246,7 @@
"live": "En direct", "live": "En direct",
"recording": "Enregistrement", "recording": "Enregistrement",
"tap_to_view": "Touchez pour voir", "tap_to_view": "Touchez pour voir",
"no_image_yet": "Pas encore d'image" "no_image_yet": "Pas d'image"
}, },
"calibration": { "calibration": {
"title": "Suivez l'étalonnage", "title": "Suivez l'étalonnage",
+9 -9
View File
@@ -50,13 +50,13 @@
"sab" "sab"
], ],
"weekdays_min": [ "weekdays_min": [
"dom", "do",
"lun", "lu",
"mar", "ma",
"mer", "me",
"gio", "gi",
"ven", "ve",
"sab" "sa"
], ],
"months": [ "months": [
"gennaio", "gennaio",
@@ -214,7 +214,7 @@
}, },
"media": { "media": {
"muted": "Muto", "muted": "Muto",
"not_playing": "Nessuna riproduzione", "not_playing": "Inattivo",
"loading": "Caricamento" "loading": "Caricamento"
}, },
"timer": { "timer": {
@@ -246,7 +246,7 @@
"live": "Dal vivo", "live": "Dal vivo",
"recording": "Registrazione", "recording": "Registrazione",
"tap_to_view": "Tocca per vedere", "tap_to_view": "Tocca per vedere",
"no_image_yet": "Ancora nessuna immagine" "no_image_yet": "Niente immagine"
}, },
"calibration": { "calibration": {
"title": "Segui la calibrazione", "title": "Segui la calibrazione",
+1 -1
View File
@@ -245,7 +245,7 @@
"loading": "Beeld laden", "loading": "Beeld laden",
"live": "Live", "live": "Live",
"recording": "Neemt op", "recording": "Neemt op",
"tap_to_view": "Tik om te bekijken", "tap_to_view": "Tik om te kijken",
"no_image_yet": "Nog geen beeld" "no_image_yet": "Nog geen beeld"
}, },
"calibration": { "calibration": {
+7 -7
View File
@@ -50,13 +50,13 @@
"sob." "sob."
], ],
"weekdays_min": [ "weekdays_min": [
"nie", "nd",
"pon", "pn",
"wt", "wt",
"śr", "śr",
"czw", "cz",
"pt", "pt",
"sob" "sb"
], ],
"months": [ "months": [
"stycznia", "stycznia",
@@ -142,7 +142,7 @@
"hours_minutes": "{h} godz. {m}" "hours_minutes": "{h} godz. {m}"
}, },
"tile": { "tile": {
"tap_to_open": "Dotknij, aby otworzyć", "tap_to_open": "Otwórz",
"page": "Strona {n}", "page": "Strona {n}",
"refused": "Odrzucono", "refused": "Odrzucono",
"command_sent": "Wysłano polecenie...", "command_sent": "Wysłano polecenie...",
@@ -235,7 +235,7 @@
}, },
"script": { "script": {
"running": "Działa...", "running": "Działa...",
"never_run": "Nigdy nie uruchomiono", "never_run": "Nie uruchamiano",
"last_time": "Ostatnio {time}", "last_time": "Ostatnio {time}",
"yesterday_time": "Wczoraj {time}", "yesterday_time": "Wczoraj {time}",
"last_date": "Ostatnio {date}" "last_date": "Ostatnio {date}"
@@ -245,7 +245,7 @@
"loading": "Ładowanie obrazu", "loading": "Ładowanie obrazu",
"live": "Na żywo", "live": "Na żywo",
"recording": "Nagrywanie", "recording": "Nagrywanie",
"tap_to_view": "Dotknij, aby zobaczyć", "tap_to_view": "Zobacz",
"no_image_yet": "Brak obrazu" "no_image_yet": "Brak obrazu"
}, },
"calibration": { "calibration": {
+9 -9
View File
@@ -50,13 +50,13 @@
"sábado" "sábado"
], ],
"weekdays_min": [ "weekdays_min": [
"dom", "do",
"seg", "sg",
"ter", "te",
"qua", "qu",
"qui", "qi",
"sex", "sx",
"sáb" "sb"
], ],
"months": [ "months": [
"janeiro", "janeiro",
@@ -214,7 +214,7 @@
}, },
"media": { "media": {
"muted": "Sem som", "muted": "Sem som",
"not_playing": "Nada a reproduzir", "not_playing": "Inativo",
"loading": "A carregar" "loading": "A carregar"
}, },
"timer": { "timer": {
@@ -246,7 +246,7 @@
"live": "Em direto", "live": "Em direto",
"recording": "A gravar", "recording": "A gravar",
"tap_to_view": "Toque para ver", "tap_to_view": "Toque para ver",
"no_image_yet": "Ainda sem imagem" "no_image_yet": "Sem imagem"
}, },
"calibration": { "calibration": {
"title": "Siga a calibração", "title": "Siga a calibração",
+1 -1
View File
@@ -69,7 +69,7 @@ const char *const screen_text::TABLE[] = {
"Oct", "Oct",
"Nov", "Nov",
"Dec", "Dec",
"{weekday} {day} {month}", "{weekday_min} {day} {month}",
"{weekday} {day} {month}", "{weekday} {day} {month}",
"{day} {month}", "{day} {month}",
"{weekday} {time}", "{weekday} {time}",
+49 -2
View File
@@ -87,6 +87,9 @@ def check():
problems, notes = [], [] problems, notes = [], []
base = dict(gen.flatten({k: v for k, v in english.items() if k != '_meta'})) base = dict(gen.flatten({k: v for k, v in english.items() if k != '_meta'}))
drawable = font_letters() 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(): for code, data in langs.items():
meta = data.get('_meta', {}) meta = data.get('_meta', {})
for field in ('name', 'english', 'script', 'plural', 'checked'): for field in ('name', 'english', 'script', 'plural', 'checked'):
@@ -105,7 +108,10 @@ def check():
if key not in base or not isinstance(text, str): if key not in base or not isinstance(text, str):
continue continue
source = base[key] 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)))}, ' problems.append(f'{code}: {key} has placeholders {sorted(set(PLACEHOLDER.findall(text)))}, '
f'English {sorted(set(PLACEHOLDER.findall(source)))}') f'English {sorted(set(PLACEHOLDER.findall(source)))}')
plural = ' | ' in source plural = ' | ' in source
@@ -126,6 +132,14 @@ def check():
ours = not key.startswith(('screen.ha.', 'screen.date.', 'screen.number.')) 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: 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)})') 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: for line in notes:
print('note:', line) print('note:', line)
for line in problems: for line in problems:
@@ -134,6 +148,34 @@ def check():
return 1 if problems else 0 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, # 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. # and `lint` fails on a new English text in the code, so it never slips back in.
LINT_KEEP = { LINT_KEEP = {
@@ -397,7 +439,12 @@ def cldr_dates(write, codes=None):
before = json.dumps(data, ensure_ascii=False) before = json.dumps(data, ensure_ascii=False)
data.setdefault('_meta', {})['clock'] = cldr['clock'] data.setdefault('_meta', {})['clock'] = cldr['clock']
number = {**cldr['number'], 'percent': ' %' if percent_space(code) else '%'} 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: if json.dumps(data, ensure_ascii=False) != before:
print(f'{code}: {"wrote" if write else "would change"} the CLDR texts') print(f'{code}: {"wrote" if write else "would change"} the CLDR texts')
if write: if write:
+1 -1
View File
@@ -9,7 +9,7 @@ import type { HeaderItem } from "../types";
const query = ref(""); const query = ref("");
const taken = computed(() => new Set(topbarItems().map(itemKey))); const taken = computed(() => new Set(topbarItems().map(itemKey)));
const samples = computed(() => ({ 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"), analog: t("editor.topbar.analog_sample"),
date: dateText(new Date(state.now), screenLanguage.value), date: dateText(new Date(state.now), screenLanguage.value),
} as Record<string, string>)); } 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"); 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(() => ({ 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"), analog: t("editor.topbar.analog_sample"),
date: dateText(new Date(state.now), screenLanguage.value), date: dateText(new Date(state.now), screenLanguage.value),
} as Record<string, string>)); } 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 itemKey = (item: HeaderItem) => JSON.stringify(item);
export const glyph = (cp: string) => String.fromCodePoint(parseInt(cp, 16)); export const glyph = (cp: string) => String.fromCodePoint(parseInt(cp, 16));
export function clockText(clock24: boolean, now = new Date()) { // 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
let hours = now.getHours(); // the language's day periods, and "7:12" in the clock card's big digits, which have no letters.
// The screen formats with %I:%M when the 24-hour clock is off. export function clockText(clock24: boolean, now = new Date(), locale?: string) {
if (!clock24) hours = hours % 12 || 12; const minutes = String(now.getMinutes()).padStart(2, "0");
return `${String(hours).padStart(2, "0")}:${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", { 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(), day: now.getDate(),
month: t(`screen.date.months_short.${now.getMonth()}`, {}, { locale }), month: t(`screen.date.months_short.${now.getMonth()}`, {}, { locale }),
}, { 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. // What the item shows right now: { icon, text, color, shown }. Entities wait for the add-on's preview.
export function topbarView(item: HeaderItem): ItemView { export function topbarView(item: HeaderItem): ItemView {
const now = new Date(state.now); 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 === "date") return { text: dateText(now, screenLanguage.value), shown: true };
if (item.type === "analog") return { analog: true, shown: true }; if (item.type === "analog") return { analog: true, shown: true };
const p = state.topbarPreviews[itemKey(item)]; 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" } }, ha: { unavailable: "Nicht verfügbar", on: "An", weather: { windy: "Windig" }, binary: { door_on: "Offen" }, button: { run: "Ausführen" } },
tile: { page: "Seite {n}" }, tile: { page: "Seite {n}" },
time: { hours_ago: "Vor 1 Stunde | Vor {n} Stunden", just_now: "Gerade eben" }, 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" }, number: { decimal: ",", group: ".", group_min: "2" },
}, },
}, meta("one_other")); }, meta("one_other"));
+6 -1
View File
@@ -28,8 +28,13 @@ describe("clock and date", () => {
const evening = new Date(2026, 8, 15, 19, 5); const evening = new Date(2026, 8, 15, 19, 5);
it("formats the clock the way the screen does", () => { it("formats the clock the way the screen does", () => {
expect(clockText(true, evening)).toBe("19:05"); 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"); 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", () => { it("writes the date short", () => {
expect(dateText(evening)).toBe("Tu 15 Sep"); expect(dateText(evening)).toBe("Tu 15 Sep");