Files
homeassistant_espscreen/components/smart_display/theme.h
T
Max Gramser bc2a754706 Add Dark mode for a screen beside the bed, with every colour in one table (0.2.63)
Dark mode is a new switch under Brightness on the screen's settings page, in
the Screen settings of ESP Screens and as switch.<screen>_dark_mode in Home
Assistant, so an automation can turn it on at bedtime. The screen changes
look at once, open cards and an alert included, and keeps it after a
restart. The design stays the same: a black page, graphite cards with a
hairline, soft white text, Home Assistant's state colours where they tell
something, and pastel card colours as deeper versions of themselves.

Every colour the firmware chooses now lives in components/smart_display/
theme.h: roles with a light and a dark value, the named card colours and
Home Assistant's state colours. The board profiles give widgets colourless
shared styles that theme.h fills, so a change of look refills those styles
and draws again what code painted by state. The light look is unchanged
apart from merges below one panel step. Dark mode costs 440 bytes of static
RAM on the CYD, and the LVGL tree of the host build is 416 bytes smaller; tests
keep colours out of every other file.

Comparing a screen that switched looks with one that booted in it also
found two older bugs, now fixed: a settings switch that was on showed its
knob at the left whenever its page was drawn, and the sun card's sunlit
area turned orange after a palette change. Firmware 0.2.54.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:07:10 +02:00

350 lines
18 KiB
C++

#pragma once
// Every colour the screens choose themselves, in one table (firmware 0.2.54+).
//
// A role is what a colour is for: the page, a card, the text on it, a key under a finger. Each role has its light value,
// the look the screens have always had, and its dark value, for a screen beside a bed: a black page, graphite cards,
// soft white text, and the colours of Home Assistant's states where they matter. Nothing else in the firmware or in the
// board profiles writes a colour of its own; the profiles name a paint (a shared LVGL style filled from this table) and
// the firmware asks for a role. docs/THEME.md is the recipe for a new colour.
//
// Home Assistant's state colours (the amber of a light that is on, the orange of heating) are not roles: they are data
// and pass through state() on their way to the glass. The values below are free of LVGL, so tests/test_theme.cpp checks
// the table and the contrast of both looks on a PC; the LVGL half is behind THEME_TEST.
#include <cstddef>
#include <cstdint>
#include <string>
namespace theme {
enum Role : uint8_t {
// ---- surfaces
PAGE, // the page behind the tiles, every card and the settings
PAGE_SOFT, // the page of the light's colour controls, a shade lighter
PAGE_PRESSED, // the Previous and Next bars under a finger
CARD, // tiles, cards, rows, the alert
CARD_PRESSED, // a settings row under a finger
LINE, // the hairline around a card
RAISED, // a card above everything else: the alert
RAISED_LINE,
KEY, // round keys (back, close), a white key under a finger
KEY_PRESSED, // a round key under a finger
TRACK, // segment tracks and grey keys inside a card, an unavailable tile's circle, empty history
GRID, // the lines across a history graph
TICK, // the small marks under a history graph
BUTTON, // the plain keys of a simple card (select options, media, timer)
SETTING_KEY, // -/+ keys and choice chips on the settings page
SETTING_KEY_PRESSED,
TOGGLE_OFF, // a switch on the settings page while off
SWITCH_OFF, // the switch on a history card while off
PANEL_TOGGLE_OFF, // the toggle on a wide tile while off
PANEL_TOGGLE_OFF_PRESSED,
KNOB, // handles and knobs
SLIDER_KNOB, // the round knob of a card's number or volume slider
SCRIM, // behind an alert
VEIL, // the sheet over a busy tile, the haze over the value card
SPINNER_TRACK, // the ring of the busy spinner
// ---- text
INK, // names, values, words on keys
INK_SOFT, // the alert's second line
MUTED, // secondary words: states, times beside a value
SUBTLE, // quiet words: axis labels, notes, "Target"
SLATE, // the top bar, units, a value on a coloured card, mode key icons
ROW_ICON, // icons at the start of a settings row
CHEVRON, // the arrow at the end of a settings row
OFF, // something that is off: its icon, its circle and the fill of its slider
ON_ACCENT, // words and icons on a blue key and on the alert's key
// ---- keys that stand out
ACCENT, // the screen's own blue: a chosen segment, the main key, a switch that is on
ACCENT_PRESSED,
ACCENT_BRIGHT, // the value card's fill and a chosen climate chip
ACCENT_TINT, // pale blue: a choice under a finger, the value card's track, climate power on
ACCENT_ICON, // the climate power icon while on
ICON_OFF, // the climate power icon while off
BUTTON_DARK, // the alert's key
BUTTON_DARK_PRESSED,
// ---- drawings
ROBOT_BODY, // the vacuum seen from above
ROBOT_RIM,
ROBOT_TOP,
ROBOT_LENS,
BATTERY, // a battery's outline and cap
SUN_PATH, // the arc the sun travels
MOON, // the sun below the horizon
AMBER_TRACK, // the track of a light's brightness slider
HISTORY_OFF, // an off or away stretch on a timeline (the add-on's CFCFCF)
HISTORY_EMPTY, // a stretch without data on a timeline (the add-on's E6E6E6)
// ---- the CYD's touch calibration: dark in both looks, a yellow crosshair
CALIBRATION_PAGE,
CALIBRATION_INK,
CALIBRATION_MARK,
ROLE_COUNT
};
struct Pair { uint32_t light, dark; };
// light: the design as it always was. dark: RGB565-friendly values (a grey whose red, green and blue land on the same
// step of the panel), so dark greys stay neutral on both boards instead of turning green.
inline constexpr Pair ROLES[ROLE_COUNT] = {
/* PAGE */ {0xE7E7E7, 0x000000},
/* PAGE_SOFT */ {0xF8F8F8, 0x101010},
/* PAGE_PRESSED */ {0xE3E3E3, 0x1A1A1A},
/* CARD */ {0xFFFFFF, 0x1A1A1A},
/* CARD_PRESSED */ {0xF1F1F1, 0x2A2A2A},
/* LINE */ {0xDDDDDD, 0x292929},
/* RAISED */ {0xFFFFFF, 0x282828},
/* RAISED_LINE */ {0xDDDDDD, 0x393939},
/* KEY */ {0xEEEEEE, 0x2A2A2A},
/* KEY_PRESSED */ {0xDDDDDD, 0x3A3A3A},
/* TRACK */ {0xF1F1F1, 0x2A2A2A},
/* GRID */ {0xEEEEEE, 0x292929},
/* TICK */ {0xCCCCCC, 0x4A4A4A},
/* BUTTON */ {0xD9E6F0, 0x222B32},
/* SETTING_KEY */ {0xE9EFF4, 0x2A2F34},
/* SETTING_KEY_PRESSED */ {0xC3D5E3, 0x3A4652},
/* TOGGLE_OFF */ {0xC4C4C4, 0x484848},
/* SWITCH_OFF */ {0xB0B0B0, 0x484848},
/* PANEL_TOGGLE_OFF */ {0xD7DADF, 0x393C41},
/* PANEL_TOGGLE_OFF_PRESSED */ {0xC5C9CF, 0x494D52},
/* KNOB */ {0xFFFFFF, 0xE8E8E8},
/* SLIDER_KNOB */ {0x111111, 0xE8E8E8},
/* SCRIM */ {0x101820, 0x000000},
/* VEIL */ {0xFFFFFF, 0x1A1A1A},
/* SPINNER_TRACK */ {0xD9DDE2, 0x393D42},
/* INK */ {0x1B1B1B, 0xDADADA},
/* INK_SOFT */ {0x3A3A3C, 0xBABABA},
/* MUTED */ {0x616161, 0x999999},
/* SUBTLE */ {0x6B6B6B, 0x8A8A8A},
/* SLATE */ {0x46525E, 0x9AA6B2},
/* ROW_ICON */ {0x4A4A4A, 0xB8B8B8},
/* CHEVRON */ {0xB0B0B0, 0x5A5A5A},
/* OFF */ {0x9E9E9E, 0x787878},
/* ON_ACCENT */ {0xFFFFFF, 0xFFFFFF},
/* ACCENT */ {0x009FE3, 0x0A93D2},
/* ACCENT_PRESSED */ {0x0075B0, 0x0A6FA3},
/* ACCENT_BRIGHT */ {0x00A6ED, 0x0A9ADB},
/* ACCENT_TINT */ {0xD5EEFC, 0x12384A},
/* ACCENT_ICON */ {0x0B6E99, 0x6CCBF5},
/* ICON_OFF */ {0x8A8A8A, 0x7A7A7A},
/* BUTTON_DARK */ {0x1B1B1B, 0x484848},
/* BUTTON_DARK_PRESSED */ {0x3A3A3C, 0x585858},
/* ROBOT_BODY */ {0xFFFFFF, 0x383D42},
/* ROBOT_RIM */ {0xCEDDE6, 0x5A6972},
/* ROBOT_TOP */ {0xE3EBEF, 0x4A5358},
/* ROBOT_LENS */ {0xA8BCC8, 0x7E93A1},
/* BATTERY */ {0x7D858D, 0x8C959D},
/* SUN_PATH */ {0xCFD8DC, 0x3E494E},
/* MOON */ {0xB0BEC5, 0x90A4AE},
/* AMBER_TRACK */ {0xFDEFC3, 0x483B16},
/* HISTORY_OFF */ {0xCFCFCF, 0x4A4A4A},
/* HISTORY_EMPTY */ {0xE6E6E6, 0x2A2A2A},
/* CALIBRATION_PAGE */ {0x101820, 0x101820},
/* CALIBRATION_INK */ {0xFFFFFF, 0xFFFFFF},
/* CALIBRATION_MARK */ {0xFFD34D, 0xFFD34D},
};
// The look on screen. The board sets it through set_dark() (below) from the Dark mode setting; the table and the
// helpers only read it.
inline bool dark = false;
inline uint32_t hex(Role role) { return dark ? ROLES[role].dark : ROLES[role].light; }
// ---- mixing, exactly as LVGL 9 mixes two colours (lv_color_mix), so a value here is the value on the glass
inline uint32_t mix(uint32_t first, uint32_t second, uint8_t amount) {
uint32_t out = 0;
for (int shift = 16; shift >= 0; shift -= 8) {
const uint32_t a = first >> shift & 0xFF, b = second >> shift & 0xFF;
out |= (((a * amount + b * (255 - amount)) * 0x8081U) >> 23) << shift;
}
return out;
}
// ---- named card colours: what a tile's background or an alert's colour can be
// The add-on sends a name; a tile keeps the light value as the colour's identity (tile_palette.h), and surface() turns it
// into what this look draws. Light: the pastels chosen for dark text. Dark: the same hue deep enough for light text.
struct Swatch { const char *name; uint32_t light, dark; };
inline constexpr Swatch SWATCHES[] = {
{"red", 0xFADADD, 0x4A2422},
{"orange", 0xFFE1C6, 0x472F17},
{"yellow", 0xFFF0C2, 0x3E3516},
{"green", 0xD9EEDC, 0x243A26},
{"mint", 0xD5F0EA, 0x163631},
{"blue", 0xD9EAFB, 0x1C3448},
{"purple", 0xE9DDF5, 0x342C40},
{"pink", 0xF7DDEC, 0x46202E},
{"gray", 0xE5E7EB, 0x2A2D32},
};
inline constexpr size_t SWATCH_COUNT = sizeof(SWATCHES) / sizeof(SWATCHES[0]);
inline uint32_t swatch(const std::string &name) {
for (const auto &s : SWATCHES) if (name == s.name) return s.light;
return 0;
}
// A card's own colour (a swatch's light value, or 0 for none) as this look draws it.
inline uint32_t surface(uint32_t own) {
if (!own) return hex(CARD);
if (!dark) return own;
for (const auto &s : SWATCHES) if (s.light == own) return s.dark;
return hex(CARD);
}
// The hairline around a card: the card's own colour a little darker in light, a little lighter in dark.
inline uint32_t outline(uint32_t own) {
if (!own) return hex(LINE);
return dark ? mix(surface(own), 0xFFFFFF, 232) : mix(own, 0x000000, 220);
}
// ---- Home Assistant's state colours
// What a state looks like in Home Assistant's frontend (its --state-*-color and --*-color variables), the same in both
// looks: an amber light is amber at night too. They are data, drawn through state(), foreground(), tint() and icon().
namespace ha {
constexpr uint32_t AMBER = 0xFFC107; // a light, switch or binary sensor that is on; lux
constexpr uint32_t ORANGE = 0xFF9800; // the sun above the horizon, a paused timer or robot, drying
constexpr uint32_t DEEP_ORANGE = 0xFF6F22; // temperature, heating
constexpr uint32_t RED = 0xF44336; // a robot in trouble
constexpr uint32_t PURPLE = 0x926BC7; // covers, scenes and scripts, energy
constexpr uint32_t DEEP_PURPLE = 0x6E41AB; // a clear night, the sun below the horizon
constexpr uint32_t INDIGO = 0x3F51B5; // selects
constexpr uint32_t BLUE = 0x2196F3; // cooling, a robot on its way back, the settings card, everything else
constexpr uint32_t LIGHT_BLUE = 0x03A9F4; // media players, the weather
constexpr uint32_t CYAN = 0x00BCD4; // fans
constexpr uint32_t TEAL = 0x009688; // numbers, percentages, a running timer, a robot at work
constexpr uint32_t GREEN = 0x4CAF50; // someone at home, auto mode
constexpr uint32_t GREY = 0x9E9E9E; // anything off or away
constexpr uint32_t SKY = 0x00A6ED; // a robot that is docked or idle
constexpr uint32_t SWITCH_ON = 0xFFB900; // the switch on a history card while on
constexpr uint32_t PURPLE_PRESSED = 0x7552A8; // a chosen cover key under a finger
// The weather card's conditions and the sun card.
constexpr uint32_t SUNNY = 0xFFB300, RAIN = 0x1E88E5, SNOW = 0x4FC3F7, LIGHTNING = 0xFFA000;
constexpr uint32_t PARTLY_CLOUDY = 0x7E9BB5, CLOUDY = 0x78909C, NIGHT_SKY = 0x5C6BC0;
// Warnings and good news drawn on a card: a low battery, the analog clock's second hand; charging.
constexpr uint32_t ALARM = 0xE53935, CHARGING = 0x43A047;
} // namespace ha
// ---- Home Assistant's colours on their way to the glass
// Grey is what Home Assistant draws for something off; the add-on's timelines add a lighter grey for off and one for no
// data. In dark those greys come from the table; every other state colour stays itself.
constexpr uint32_t STATE_OFF = ha::GREY;
inline uint32_t state(uint32_t color) {
if (!dark) return color;
if (color == STATE_OFF) return ROLES[OFF].dark;
if (color == ROLES[HISTORY_OFF].light) return ROLES[HISTORY_OFF].dark;
if (color == ROLES[HISTORY_EMPTY].light) return ROLES[HISTORY_EMPTY].dark;
return color;
}
// Relative lightness 0-255 (Rec. 601 weights): enough to tell a colour that sinks into a dark card.
inline uint32_t lightness(uint32_t color) {
return ((color >> 16 & 0xFF) * 299 + (color >> 8 & 0xFF) * 587 + (color & 0xFF) * 114) / 1000;
}
// A state colour drawn as a glyph, a line or words on the page or a card. Dark lifts the deep ones (indigo, purple)
// until they read on graphite.
inline uint32_t foreground(uint32_t color) {
color = state(color);
if (!dark) return color;
const uint32_t l = lightness(color);
return l >= 110 ? color : mix(color, 0xFFFFFF, static_cast<uint8_t>(255 - (110 - l) * 3 / 2));
}
// The pale circle behind a tile's icon, a halo, a slider's track: the colour at `amount` over white in light, and
// over the card in dark, where the same share would vanish, a little more.
inline uint32_t tint(uint32_t color, uint8_t amount) {
color = state(color);
if (!dark) return mix(color, 0xFFFFFF, amount);
const unsigned stronger = amount + amount / 3 + 8;
return mix(color, hex(CARD), static_cast<uint8_t>(stronger > 255 ? 255 : stronger));
}
// A tile's icon in its state colour: a touch darker in light, so a pale bulb still shows; a touch lighter in dark.
inline uint32_t icon(uint32_t color) {
color = state(color);
return dark ? mix(foreground(color), 0xFFFFFF, 230) : mix(color, 0x333333, 205);
}
// The soft area under a graph line: a fifth of the line's colour in light; less over graphite, where the same share
// turns a large brown or purple block.
inline uint8_t fill_opacity() { return dark ? 38 : 51; }
// Keys drawn inside a card of any colour (a wide tile's direct controls): a shade away from the card, towards the
// middle, so they read on white, on a pastel and on graphite alike.
inline uint32_t key_on(uint32_t card, uint8_t depth) {
return dark ? mix(card, 0xFFFFFF, static_cast<uint8_t>(255 - (255 - depth) * 3 / 2)) : mix(card, 0x000000, depth);
}
} // namespace theme
#ifndef THEME_TEST
#include "lvgl.h"
#include <array>
namespace theme {
inline lv_color_t color(Role role) { return lv_color_hex(hex(role)); }
inline lv_color_t rgb(uint32_t value) { return lv_color_hex(value); }
// ---- paints: shared styles filled from the table
// A board profile gives a widget a paint instead of a colour (`styles: paint_card`). Its style definitions carry no
// colour: the profile's `theme::paints` fills each one with fill(), at boot and after every change of look. The list
// is code, not a table in RAM, and a shared style costs a widget one style reference, about what the local colour it
// replaces cost. The firmware's own long-lived widgets take the same paints through style().
constexpr uint8_t NONE = 0xFF;
enum class Paint : uint8_t {
page, page_soft, page_pressed, card, raised, key, key_pressed, track, ink, ink_soft, muted, subtle, slate,
scrim, veil, accent_tint, accent_bright, button_dark, button_dark_pressed, knob, spinner, calibration, COUNT
};
struct PaintRoles { uint8_t bg, border, text, line, arc; };
inline constexpr PaintRoles PAINTS[static_cast<size_t>(Paint::COUNT)] = {
/* page */ {PAGE, NONE, INK, NONE, NONE},
/* page_soft */ {PAGE_SOFT, NONE, INK, NONE, NONE},
/* page_pressed */ {PAGE_PRESSED, NONE, NONE, NONE, NONE},
/* card */ {CARD, LINE, INK, NONE, NONE},
/* raised */ {RAISED, RAISED_LINE, INK, NONE, NONE},
/* key */ {KEY, NONE, INK, NONE, NONE},
/* key_pressed */ {KEY_PRESSED, NONE, NONE, NONE, NONE},
/* track */ {TRACK, NONE, INK, NONE, NONE},
/* ink */ {NONE, NONE, INK, NONE, NONE},
/* ink_soft */ {NONE, NONE, INK_SOFT, NONE, NONE},
/* muted */ {NONE, NONE, MUTED, NONE, NONE},
/* subtle */ {NONE, NONE, SUBTLE, NONE, NONE},
/* slate */ {NONE, SLATE, SLATE, SLATE, NONE},
/* scrim */ {SCRIM, NONE, NONE, NONE, NONE},
/* veil */ {VEIL, NONE, NONE, NONE, NONE},
/* accent_tint */ {ACCENT_TINT, NONE, NONE, NONE, NONE},
/* accent_bright */ {ACCENT_BRIGHT, NONE, NONE, NONE, NONE},
/* button_dark */ {BUTTON_DARK, NONE, ON_ACCENT, NONE, NONE},
/* button_dark_pressed */ {BUTTON_DARK_PRESSED, NONE, NONE, NONE, NONE},
/* knob */ {KNOB, NONE, NONE, NONE, NONE},
/* spinner */ {NONE, NONE, NONE, NONE, SPINNER_TRACK},
/* calibration */ {CALIBRATION_PAGE, NONE, CALIBRATION_INK, NONE, NONE},
};
// Fill a style with a paint in the current look. Setting a property the style already has allocates nothing.
inline void fill(lv_style_t *style, Paint paint) {
if (!style) return;
const PaintRoles &p = PAINTS[static_cast<size_t>(paint)];
if (p.bg != NONE) lv_style_set_bg_color(style, color(static_cast<Role>(p.bg)));
if (p.border != NONE) lv_style_set_border_color(style, color(static_cast<Role>(p.border)));
if (p.text != NONE) lv_style_set_text_color(style, color(static_cast<Role>(p.text)));
if (p.line != NONE) lv_style_set_line_color(style, color(static_cast<Role>(p.line)));
if (p.arc != NONE) lv_style_set_arc_color(style, color(static_cast<Role>(p.arc)));
}
// The board profile's styles and the paint of each, as fill() calls. Set and run once in on_boot.
inline void (*paints)() = nullptr;
// The firmware's own shared style for a paint, made on first use.
inline std::array<lv_style_t *, static_cast<size_t>(Paint::COUNT)> own{};
inline lv_style_t *style(Paint paint) {
auto *&s = own[static_cast<size_t>(paint)];
if (!s) {
s = new lv_style_t;
lv_style_init(s);
fill(s, paint);
}
return s;
}
// What the board draws again after a change of look: the tiles, an open card, the settings page. Set by the profile.
inline void (*redraw)() = nullptr;
// The one way the look changes. Every paint is filled again and LVGL is told once; what code painted itself follows
// through redraw(). A look the screen already has costs nothing, so the setting may be applied on every change.
inline void set_dark(bool on) {
if (on == dark) return;
dark = on;
if (paints) paints();
for (size_t i = 0; i < own.size(); ++i) if (own[i]) fill(own[i], static_cast<Paint>(i));
// One walk over every widget instead of one per paint.
lv_obj_report_style_change(nullptr);
if (redraw) redraw();
}
} // namespace theme
#endif