Files
Max Gramser 378d0b3e61 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>
2026-09-19 14:24:19 +02:00

120 lines
6.4 KiB
C++

#include "screen_text_en.h"
// The colour table (firmware 0.2.54): the light look stays the design it was, the dark look reads well beside a bed.
// clang++ -std=c++17 -Wall -Wextra -Werror -I. tests/test_theme.cpp -o /tmp/test_theme && /tmp/test_theme
#define THEME_TEST
#include "components/smart_display/theme.h"
#include <cassert>
#include <cmath>
#include <cstdlib>
using namespace theme;
static double channel(uint32_t value) {
const double c = value / 255.0;
return c <= 0.04045 ? c / 12.92 : std::pow((c + 0.055) / 1.055, 2.4);
}
static double luminance(uint32_t color) {
return 0.2126 * channel(color >> 16 & 0xFF) + 0.7152 * channel(color >> 8 & 0xFF) + 0.0722 * channel(color & 0xFF);
}
// WCAG contrast ratio of two colours.
static double contrast(uint32_t a, uint32_t b) {
double la = luminance(a), lb = luminance(b);
if (la < lb) std::swap(la, lb);
return (la + 0.05) / (lb + 0.05);
}
static int channel_gap(uint32_t a, uint32_t b) {
int gap = 0;
for (int shift = 0; shift <= 16; shift += 8) gap = std::max(gap, std::abs(int(a >> shift & 0xFF) - int(b >> shift & 0xFF)));
return gap;
}
static void look(bool on) { dark = on; }
int main() {
// ---- the light look is the design as it was
assert(ROLES[PAGE].light == 0xE7E7E7 && ROLES[CARD].light == 0xFFFFFF && ROLES[LINE].light == 0xDDDDDD);
assert(ROLES[INK].light == 0x1B1B1B && ROLES[MUTED].light == 0x616161 && ROLES[SLATE].light == 0x46525E);
assert(ROLES[ACCENT].light == 0x009FE3 && ROLES[OFF].light == ha::GREY && ROLES[KNOB].light == 0xFFFFFF);
look(false);
assert(hex(PAGE) == 0xE7E7E7 && hex(INK) == 0x1B1B1B);
// ---- mixing is LVGL's own: the ends are the two colours, the middle rounds down
assert(mix(0x123456, 0xABCDEF, 255) == 0x123456 && mix(0x123456, 0xABCDEF, 0) == 0xABCDEF);
assert(mix(0xFFFFFF, 0x000000, 128) == 0x808080);
// The old tile circle (the state colour at 38 over white) and icon (at 205 over #333333) are exactly what tint() and
// icon() give in the light look.
for (uint32_t accent : {ha::AMBER, ha::TEAL, ha::INDIGO, ha::DEEP_ORANGE, ha::GREY}) {
assert(tint(accent, 38) == mix(accent, 0xFFFFFF, 38));
assert(icon(accent) == mix(accent, 0x333333, 205));
assert(state(accent) == accent && foreground(accent) == accent);
}
// The halos and cover tracks that were written out by hand before are the same colours within two steps.
assert(channel_gap(tint(ha::TEAL, 36), 0xDCF0EE) <= 2 && channel_gap(tint(ha::BLUE, 36), 0xE1EFFD) <= 2);
assert(channel_gap(tint(ha::ORANGE, 36), 0xFFF0DA) <= 2 && channel_gap(tint(ha::RED, 36), 0xFDE4E2) <= 2);
assert(channel_gap(tint(ha::SKY, 22), 0xEAF5FC) <= 2);
assert(channel_gap(tint(ha::PURPLE, 37), 0xEFE8F7) <= 2 && channel_gap(tint(ha::PURPLE, 80), 0xDDD0EF) <= 2);
assert(fill_opacity() == 51);
// ---- named card colours: identity in light, deep in dark, the normal card for none or an unknown colour
assert(SWATCH_COUNT == 9 && swatch("red") == 0xFADADD && swatch("gray") == 0xE5E7EB && swatch("none") == 0 && swatch("") == 0);
assert(surface(0) == 0xFFFFFF && surface(0xFADADD) == 0xFADADD && surface(0x123456) == 0x123456);
assert(outline(0) == 0xDDDDDD && outline(0xFADADD) == mix(0xFADADD, 0x000000, 220));
assert(key_on(0xFFFFFF, 236) == mix(0xFFFFFF, 0x000000, 236));
// ---- the dark look
look(true);
assert(hex(PAGE) == 0x000000);
assert(surface(0) == ROLES[CARD].dark && surface(0x123456) == ROLES[CARD].dark);
for (const auto &s : SWATCHES) {
assert(surface(s.light) == s.dark);
// Light words read on every dark card colour, the slate of a value too.
assert(contrast(ROLES[INK].dark, s.dark) >= 7.0);
assert(contrast(ROLES[SLATE].dark, s.dark) >= 4.5);
assert(contrast(ROLES[INK].light, s.light) >= 7.0);
}
// Text reads in both looks: names and values, secondary and quiet words, the top bar on the page.
for (bool on : {false, true}) {
look(on);
assert(contrast(hex(INK), hex(CARD)) >= 7.0 && contrast(hex(INK), hex(PAGE)) >= 7.0);
assert(contrast(hex(MUTED), hex(CARD)) >= 4.5 && contrast(hex(SUBTLE), hex(CARD)) >= 4.5);
assert(contrast(hex(SLATE), hex(PAGE)) >= 4.5 && contrast(hex(INK_SOFT), hex(RAISED)) >= 4.5);
assert(contrast(hex(INK), hex(RAISED)) >= 7.0 && contrast(hex(INK), hex(TRACK)) >= 7.0);
assert(contrast(hex(ON_ACCENT), hex(BUTTON_DARK)) >= 4.5);
}
// The blue keys keep at least the contrast they always had (white on #009FE3, just under 3:1).
look(false);
const double blue_light = contrast(hex(ON_ACCENT), hex(ACCENT));
look(true);
assert(blue_light > 2.9 && contrast(hex(ON_ACCENT), hex(ACCENT)) >= blue_light);
look(true);
// Surfaces step up from the black page, a raised card and a key under a finger stand out.
assert(lightness(hex(PAGE)) < lightness(hex(CARD)) && lightness(hex(CARD)) < lightness(hex(LINE)));
assert(lightness(hex(CARD)) < lightness(hex(RAISED)) && lightness(hex(KEY)) < lightness(hex(KEY_PRESSED)));
assert(lightness(hex(CARD)) < 40 && lightness(hex(INK)) > 200);
// Dark greys land on the same step of red, green and blue on the panel (RGB565), so they stay grey.
for (int role = 0; role < ROLE_COUNT; ++role) {
const uint32_t c = ROLES[role].dark;
const uint32_t r = c >> 16 & 0xFF, g = c >> 8 & 0xFF, b = c & 0xFF;
if (r == g && g == b && r > 0 && r < 0xFF) assert((r >> 3) * 2 == (g >> 2));
}
// ---- Home Assistant's colours in the dark look
assert(state(ha::GREY) == ROLES[OFF].dark && state(0xCFCFCF) == ROLES[HISTORY_OFF].dark && state(0xE6E6E6) == ROLES[HISTORY_EMPTY].dark);
assert(state(ha::AMBER) == ha::AMBER && state(ha::TEAL) == ha::TEAL);
// Bright state colours stay themselves; the deep ones are lifted until they read on graphite.
assert(foreground(ha::AMBER) == ha::AMBER && foreground(ha::LIGHT_BLUE) == ha::LIGHT_BLUE);
for (uint32_t deep : {ha::INDIGO, ha::DEEP_PURPLE, uint32_t{0}}) {
assert(lightness(foreground(deep)) > lightness(deep));
assert(contrast(foreground(deep), hex(CARD)) >= 3.0);
}
// A circle stays dark and tinted, its icon light; a slider track stays dark.
for (uint32_t accent : {ha::AMBER, ha::TEAL, ha::INDIGO, ha::GREY, ha::DEEP_ORANGE}) {
assert(lightness(tint(accent, 38)) < 110 && lightness(tint(accent, 51)) < 120);
assert(contrast(icon(accent), tint(accent, 38)) >= 3.0);
}
// Keys in a card are lighter than the card, its hairline too.
assert(lightness(key_on(hex(CARD), 236)) > lightness(hex(CARD)));
assert(lightness(outline(0xFADADD)) > lightness(surface(0xFADADD)));
assert(fill_opacity() < 51);
return 0;
}