From bc2a75470639127f0f2ce1899cf048e25632b606 Mon Sep 17 00:00:00 2001
From: Max Gramser
Date: Thu, 17 Sep 2026 12:07:03 +0200
Subject: [PATCH] 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._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
---
AGENTS.md | 4 +
README.md | 6 +
README_EXTENDED.md | 9 +-
components/smart_display/alert_overlay.h | 2 +-
components/smart_display/climate_card.h | 15 +-
components/smart_display/cyd_calibration.h | 7 +-
components/smart_display/light_controls.h | 49 ++-
components/smart_display/runtime_tiles.h | 361 +++++++++--------
components/smart_display/settings_screen.h | 90 +++--
components/smart_display/theme.h | 349 ++++++++++++++++
components/smart_display/tile_controls.h | 16 +-
components/smart_display/tile_palette.h | 17 +-
docs/EASY_SETUP.md | 1 +
docs/RELEASING.md | 47 +++
docs/SETTINGS.md | 7 +-
docs/THEME.md | 84 ++++
docs/images/guition-dark-controls.png | Bin 0 -> 29514 bytes
docs/images/guition-dark-home.png | Bin 0 -> 36611 bytes
docs/images/guition-dark-page-4.png | Bin 0 -> 42528 bytes
guition-4848s040.yaml | 435 ++++++++------------
home-like-2432s028.yaml | 440 ++++++++-------------
packages/cyd.yaml | 440 ++++++++-------------
packages/guition.yaml | 435 ++++++++------------
screen_manager/CHANGELOG.md | 12 +
screen_manager/app/claude_skill.py | 31 +-
screen_manager/app/core.py | 10 +-
screen_manager/app/server.py | 2 +
screen_manager/app/static/app.js | 1 +
screen_manager/config.yaml | 2 +-
tests/test_alert_overlay.cpp | 7 +-
tests/test_alerts_reference.py | 6 +-
tests/test_claude_skill.py | 15 +-
tests/test_off_look.py | 5 +-
tests/test_screen_owned_settings.py | 31 +-
tests/test_settings_screen.cpp | 23 +-
tests/test_theme.cpp | 118 ++++++
tests/test_theme.py | 162 ++++++++
tests/test_tile_controls.cpp | 1 +
tests/test_tile_palette.cpp | 3 +-
tests/test_tile_palette.py | 5 +-
40 files changed, 1885 insertions(+), 1363 deletions(-)
create mode 100644 components/smart_display/theme.h
create mode 100644 docs/THEME.md
create mode 100644 docs/images/guition-dark-controls.png
create mode 100644 docs/images/guition-dark-home.png
create mode 100644 docs/images/guition-dark-page-4.png
create mode 100644 tests/test_theme.cpp
create mode 100644 tests/test_theme.py
diff --git a/AGENTS.md b/AGENTS.md
index daec794..a056796 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -49,6 +49,10 @@ physically tap; an agent cannot replace that with software coordinates.
- Keep base hardware and UI in `home-like-2432s028.yaml`; personal data belongs
in the gitignored local profiles.
+- Colours live in one table: `components/smart_display/theme.h` (firmware 0.2.54+). Every role has a
+ light and a dark value, board profiles name paints (`styles: paint_card`) instead of writing a colour,
+ and firmware code asks for a role (`theme::color(theme::INK)`). Never write a hex colour in a profile or
+ another header; docs/THEME.md is the recipe, tests/test_theme.py and tests/test_theme.cpp guard it.
- Screen settings live in one table: `components/smart_display/settings_screen.h` draws the
page on the screen, `SETTING_RULES` in the add-on validates the same keys. Firmware 0.2.49+
owns them: every writer (the page, the entities in both profiles) goes through
diff --git a/README.md b/README.md
index 0b28e2a..f6a28ce 100644
--- a/README.md
+++ b/README.md
@@ -51,6 +51,12 @@ no reflash, no YAML to write, no blueprint, MQTT or token.
The same cards on the 2.8-inch CYD, 320 × 240.
+
+
+
+
+
+Dark mode, for a screen beside the bed: the same pages, darker. It is a switch on the screen, in ESP Screens and in Home Assistant, so an automation can turn it on at bedtime.
## Managed from Home Assistant
diff --git a/README_EXTENDED.md b/README_EXTENDED.md
index 2101ee7..35f4fed 100644
--- a/README_EXTENDED.md
+++ b/README_EXTENDED.md
@@ -74,11 +74,12 @@ on the screen itself, and how updates work.
(open/closed), the alarm, who's home, or when something or someone last
changed ("5 min ago", "Yesterday"). See [Top bar](#top-bar).
- **Screen settings:** standby time, normal and dimmed brightness,
- night hours, 24- or 12-hour clock, return to the home page, and optional swiping
- between pages. Change them in ESP Screens, where they apply at once, or on the screen
+ night hours, **Dark mode** (firmware 0.2.54+), 24- or 12-hour clock, return to the home page,
+ and optional swiping between pages. Change them in ESP Screens, where they apply at once, or on the screen
itself. With firmware 0.2.49+ the screen keeps them, and every one of them is also an
entity in Home Assistant, so an automation can switch **Night mode** or **Auto standby**
- (firmware 0.2.41+), for example to keep a screen on while someone is home, and
+ (firmware 0.2.41+), for example to keep a screen on while someone is home, turn **Dark mode**
+ on at bedtime (firmware 0.2.54+), and
wake a screen or put it to sleep with its **Wake** and **Sleep** buttons (firmware 0.2.45+).
See [Wake and sleep](#wake-and-sleep-from-an-automation).
- **Settings on the screen itself** (firmware 0.2.44+): hold the top bar for about a
@@ -313,7 +314,7 @@ Home Assistant, `esphome._open_settings` opens it too (`page` 0 menu, 1
| Group | What is on it |
|---|---|
-| Brightness | Brightness, Auto standby, Standby after, Standby brightness |
+| Brightness | Brightness, Dark mode, Auto standby, Standby after, Standby brightness |
| Night | Night mode, Starts, Ends, Night brightness |
| Screen | 12/24-hour clock, back to page 1 by itself and after how long, also on standby, swiping between pages, rotation (boards that turn) |
| This screen | Name, IP address, firmware version, Home Assistant connected, Restart |
diff --git a/components/smart_display/alert_overlay.h b/components/smart_display/alert_overlay.h
index d9c07ed..09b2639 100644
--- a/components/smart_display/alert_overlay.h
+++ b/components/smart_display/alert_overlay.h
@@ -13,7 +13,7 @@ namespace screen_alert {
constexpr const char *FALLBACK_ICON = "alert-outline";
constexpr const char *FALLBACK_TITLE = "Notification";
constexpr const char *FALLBACK_BUTTON = "OK";
-constexpr uint32_t DEFAULT_CARD_COLOR = 0xFFFFFF;
+constexpr uint32_t DEFAULT_CARD_COLOR = 0; // the normal card: theme::surface(0)
constexpr int MAX_TIMEOUT_SECONDS = 86400;
struct Alert {
diff --git a/components/smart_display/climate_card.h b/components/smart_display/climate_card.h
index 59ff250..392057d 100644
--- a/components/smart_display/climate_card.h
+++ b/components/smart_display/climate_card.h
@@ -7,6 +7,7 @@
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "lvgl.h"
+#include "theme.h"
#include
#include
#include
@@ -47,13 +48,13 @@ inline int text_width(const std::string &text, const lv_font_t *font) {
lv_text_get_size(&size, text.c_str(), font, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE);
return size.x;
}
-inline lv_obj_t *shape(lv_obj_t *parent, int x, int y, int w, int h, uint32_t color, int radius) {
+inline lv_obj_t *shape(lv_obj_t *parent, int x, int y, int w, int h, theme::Role color, int radius) {
auto *o = lv_obj_create(parent);
lv_obj_remove_style_all(o);
lv_obj_set_pos(o, x, y);
lv_obj_set_size(o, w, h);
lv_obj_set_style_radius(o, radius, 0);
- lv_obj_set_style_bg_color(o, lv_color_hex(color), 0);
+ lv_obj_set_style_bg_color(o, theme::color(color), 0);
lv_obj_set_style_bg_opa(o, LV_OPA_COVER, 0);
lv_obj_remove_flag(o, LV_OBJ_FLAG_CLICKABLE);
lv_obj_remove_flag(o, LV_OBJ_FLAG_SCROLLABLE);
@@ -87,9 +88,9 @@ inline void build() {
auto *icon = lv_label_create(card);
lv_label_set_text(icon, row.icon);
lv_obj_set_style_text_font(icon, look.icon_font, 0);
- lv_obj_set_style_text_color(icon, lv_color_hex(0x6B6B6B), 0);
+ lv_obj_set_style_text_color(icon, theme::color(theme::SUBTLE), 0);
lv_obj_set_pos(icon, look.edge, y + (look.row_h - icon_h) / 2);
- shape(card, track_x, y, track_w, look.row_h, 0xF1F1F1, look.row_h / 2);
+ shape(card, track_x, y, track_w, look.row_h, theme::TRACK, look.row_h / 2);
const unsigned n = choices(row.modes);
std::string labels[MAX_CHOICES];
int widths[MAX_CHOICES], words = 0;
@@ -104,16 +105,16 @@ inline void build() {
const int sw = i + 1 == n ? track_x + track_w - inset - sx : (words <= room ? widths[i] + share : room / static_cast(n));
const bool selected = cyd::list_item(row.modes, i) == row.current;
const int sh = look.row_h - 2 * inset;
- auto *segment = shape(card, sx, y + inset, sw, sh, 0x009FE3, sh / 2);
+ auto *segment = shape(card, sx, y + inset, sw, sh, theme::ACCENT, sh / 2);
lv_obj_add_flag(segment, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_bg_opa(segment, selected ? LV_OPA_COVER : LV_OPA_TRANSP, 0);
lv_obj_set_style_bg_opa(segment, LV_OPA_COVER, LV_STATE_PRESSED);
- lv_obj_set_style_bg_color(segment, lv_color_hex(selected ? 0x0075B0 : 0xD5EEFC), LV_STATE_PRESSED);
+ lv_obj_set_style_bg_color(segment, theme::color(selected ? theme::ACCENT_PRESSED : theme::ACCENT_TINT), LV_STATE_PRESSED);
auto *label = lv_label_create(segment);
lv_label_set_text(label, labels[i].c_str());
lv_label_set_long_mode(label, LV_LABEL_LONG_DOT);
lv_obj_set_style_text_font(label, look.font, 0);
- lv_obj_set_style_text_color(label, lv_color_hex(selected ? 0xFFFFFF : 0x1B1B1B), 0);
+ lv_obj_set_style_text_color(label, theme::color(selected ? theme::ON_ACCENT : theme::INK), 0);
lv_obj_set_width(label, std::min(sw - 6, widths[i] + 2));
lv_obj_remove_flag(label, LV_OBJ_FLAG_CLICKABLE);
lv_obj_center(label);
diff --git a/components/smart_display/cyd_calibration.h b/components/smart_display/cyd_calibration.h
index ecd1fcb..ca78082 100644
--- a/components/smart_display/cyd_calibration.h
+++ b/components/smart_display/cyd_calibration.h
@@ -3,6 +3,7 @@
#include "esphome/core/preferences.h"
#include "esphome/core/hal.h"
#include "lvgl.h"
+#include "theme.h"
#include
namespace screen_calibration {
inline bool active = false, pressed = false;
@@ -36,8 +37,8 @@ inline void setup(lv_obj_t *home_screen, const lv_font_t *font, const lv_font_t
for (int i = 0; i < 4; ++i) valid &= stored.bounds[i] == calibration.bounds[i];
for (float coefficient : stored.c) valid &= std::isfinite(coefficient);
screen = lv_obj_create(nullptr);
- lv_obj_set_style_bg_color(screen, lv_color_hex(0x101820), 0);
- lv_obj_set_style_text_color(screen, lv_color_hex(0xFFFFFF), 0);
+ lv_obj_set_style_bg_color(screen, theme::color(theme::CALIBRATION_PAGE), 0);
+ lv_obj_set_style_text_color(screen, theme::color(theme::CALIBRATION_INK), 0);
lv_obj_set_style_text_font(screen, font, 0);
instructions = lv_label_create(screen);
lv_obj_set_style_text_align(instructions, LV_TEXT_ALIGN_CENTER, 0);
@@ -46,7 +47,7 @@ inline void setup(lv_obj_t *home_screen, const lv_font_t *font, const lv_font_t
cross = lv_label_create(screen); lv_label_set_text(cross, "+");
lv_obj_set_size(cross, 24, 24); lv_obj_set_style_text_font(cross, large, 0);
lv_obj_set_style_text_align(cross, LV_TEXT_ALIGN_CENTER, 0);
- lv_obj_set_style_text_color(cross, lv_color_hex(0xFFD34D), 0);
+ lv_obj_set_style_text_color(cross, theme::color(theme::CALIBRATION_MARK), 0);
if (valid) { calibration = stored; if (apply) apply(calibration); }
else begin();
}
diff --git a/components/smart_display/light_controls.h b/components/smart_display/light_controls.h
index 36f01f4..470469a 100644
--- a/components/smart_display/light_controls.h
+++ b/components/smart_display/light_controls.h
@@ -37,12 +37,13 @@ struct State {
#include "esphome/core/log.h"
#include "lvgl.h"
#include "cyd_ui.h"
+#include "theme.h"
#include
namespace light_controls {
inline State states[8];
inline State fallback;
inline State *active = &fallback;
-struct Row { lv_obj_t *box{}, *slider{}, *value{}; bool dirty = false; unsigned index{}; bool off = false; int held = 0; };
+struct Row { lv_obj_t *box{}, *slider{}, *value{}, *icon{}; bool dirty = false; unsigned index{}; bool off = false; int held = 0; };
inline Row rows[3];
inline std::function commits[3];
inline bool ready = false;
@@ -69,10 +70,13 @@ inline void subscribe(const char *const *entities, size_t count) {
(void) count;
#endif
}
+// The picker's own spectrum: the colours a light can take, not a look, so they stay the same in dark.
+constexpr uint32_t WARM = 0xFF9C32, COOL = 0xC6E6FF;
+inline constexpr uint32_t RAINBOW[] = {0xFF0000, 0xFFFF00, 0x00FF00, 0x00FFFF, 0x0000FF, 0xFF00FF, 0xFF0000};
// Temperature knob colour: the same warm-to-cool blend as the track under it.
inline lv_color_t kelvin_color(int value, int low, int high) {
int mix = high > low ? clamp((value - low) * 255 / (high - low), 0, 255) : 128;
- return lv_color_mix(lv_color_hex(0xC6E6FF), lv_color_hex(0xFF9C32), mix);
+ return lv_color_hex(theme::mix(COOL, WARM, static_cast(mix)));
}
inline void preview(Row &row) {
int value = lv_slider_get_value(row.slider);
@@ -88,7 +92,7 @@ inline void preview(Row &row) {
// An off light shows only the track, as in Home Assistant, until the slider moves.
lv_obj_set_style_bg_opa(row.slider, row.off ? LV_OPA_TRANSP : LV_OPA_COVER, LV_PART_INDICATOR);
lv_obj_set_style_bg_opa(row.slider, row.off ? LV_OPA_TRANSP : LV_OPA_COVER, LV_PART_KNOB);
- lv_obj_set_style_bg_color(row.slider, lv_color_hex(row.off ? 0xECECEC : 0xFDEFC3), LV_PART_MAIN);
+ lv_obj_set_style_bg_color(row.slider, theme::color(row.off ? theme::TRACK : theme::AMBER_TRACK), LV_PART_MAIN);
if (row.off) lv_label_set_text(row.value, "Off");
else lv_label_set_text_fmt(row.value, "%d %%", value);
}
@@ -138,6 +142,20 @@ inline lv_obj_t *round_end(lv_obj_t *parent, int x, int y, int size, uint32_t co
lv_obj_set_style_bg_color(end, lv_color_hex(color), 0);
return end;
}
+// The colours of the three cards, for setup() and for a change of look.
+inline void paint(Row &row) {
+ lv_obj_set_style_bg_color(row.box, theme::color(theme::CARD), 0);
+ lv_obj_set_style_border_color(row.box, theme::color(theme::LINE), 0);
+ lv_obj_set_style_text_color(row.box, theme::color(theme::INK), 0);
+ lv_obj_set_style_text_color(row.value, theme::color(theme::MUTED), 0);
+ if (row.icon) lv_obj_set_style_text_color(row.icon, theme::color(theme::MUTED), 0);
+ if (row.index < 2) lv_obj_set_style_border_color(row.slider, theme::color(theme::KNOB), LV_PART_KNOB);
+ else lv_obj_set_style_bg_color(row.slider, theme::color(theme::KNOB), LV_PART_KNOB);
+}
+inline void restyle() {
+ if (!ready) return;
+ for (auto &row : rows) { paint(row); preview(row); }
+}
// Three white cards like the tiles: an icon, the name and the value, then a pill-shaped track.
// Colour and temperature are gradient pills with a round knob in the chosen colour; brightness
// is filled like the tile sliders, with the white handle inside the fill.
@@ -151,22 +169,17 @@ inline void setup(lv_obj_t *parent, const lv_font_t *font, int width, int height
int track_x = inset, track_w = w - 2 * inset, track_y = card_h - inset + (large ? 2 : 3) - track_h, radius = track_h / 2;
const char *names[] = {"Color", "Color temperature", "Brightness"};
const char *icons[] = {"\U000F03D8", "\U000F050F", "\U000F0335"};
- const uint32_t rainbow[] = {0xFF0000, 0xFFFF00, 0x00FF00, 0x00FFFF, 0x0000FF, 0xFF00FF, 0xFF0000};
for (unsigned i = 0; i < 3; ++i) {
auto &row = rows[i]; row.index = i;
row.box = plain(parent, margin, top + i * spacing, w, card_h);
lv_obj_set_style_bg_opa(row.box, LV_OPA_COVER, 0);
- lv_obj_set_style_bg_color(row.box, lv_color_hex(0xFFFFFF), 0);
lv_obj_set_style_radius(row.box, large ? 18 : 10, 0);
lv_obj_set_style_border_width(row.box, 1, 0);
- lv_obj_set_style_border_color(row.box, lv_color_hex(0xDDDDDD), 0);
lv_obj_set_style_text_font(row.box, font, 0);
- lv_obj_set_style_text_color(row.box, lv_color_hex(0x1B1B1B), 0);
int text_x = inset;
if (icon_font) {
- auto *icon = lv_label_create(row.box); lv_label_set_text(icon, icons[i]);
+ auto *icon = row.icon = lv_label_create(row.box); lv_label_set_text(icon, icons[i]);
lv_obj_set_style_text_font(icon, icon_font, 0);
- lv_obj_set_style_text_color(icon, lv_color_hex(0x616161), 0);
int icon_h = lv_font_get_line_height(icon_font), text_h = lv_font_get_line_height(font);
lv_obj_set_pos(icon, inset - (large ? 2 : 1), text_y + (text_h - icon_h) / 2);
text_x += icon_h + (large ? 6 : 4);
@@ -174,23 +187,22 @@ inline void setup(lv_obj_t *parent, const lv_font_t *font, int width, int height
auto *label = lv_label_create(row.box); lv_label_set_text(label, names[i]);
lv_obj_set_pos(label, text_x, text_y);
row.value = lv_label_create(row.box); lv_obj_align(row.value, LV_ALIGN_TOP_RIGHT, -inset, text_y);
- lv_obj_set_style_text_color(row.value, lv_color_hex(0x616161), 0);
row.slider = lv_slider_create(row.box);
lv_obj_remove_style_all(row.slider);
lv_obj_remove_flag(row.slider, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_ext_click_area(row.slider, large ? 12 : 8);
if (i < 2) {
// The knob travels between the centres of the two round ends.
- round_end(row.box, track_x, track_y, track_h, i == 0 ? rainbow[0] : 0xFF9C32);
- round_end(row.box, track_x + track_w - track_h, track_y, track_h, i == 0 ? rainbow[6] : 0xC6E6FF);
+ round_end(row.box, track_x, track_y, track_h, i == 0 ? RAINBOW[0] : WARM);
+ round_end(row.box, track_x + track_w - track_h, track_y, track_h, i == 0 ? RAINBOW[6] : COOL);
unsigned segments = i == 0 ? 6 : 1;
int span = track_w - track_h;
for (unsigned n = 0; n < segments; ++n) {
int start = n * span / segments, end = (n + 1) * span / segments;
auto *stripe = plain(row.box, track_x + radius + start, track_y, end - start, track_h);
lv_obj_set_style_bg_opa(stripe, LV_OPA_COVER, 0);
- lv_obj_set_style_bg_color(stripe, lv_color_hex(i == 0 ? rainbow[n] : 0xFF9C32), 0);
- lv_obj_set_style_bg_grad_color(stripe, lv_color_hex(i == 0 ? rainbow[n + 1] : 0xC6E6FF), 0);
+ lv_obj_set_style_bg_color(stripe, lv_color_hex(i == 0 ? RAINBOW[n] : WARM), 0);
+ lv_obj_set_style_bg_grad_color(stripe, lv_color_hex(i == 0 ? RAINBOW[n + 1] : COOL), 0);
lv_obj_set_style_bg_grad_dir(stripe, LV_GRAD_DIR_HOR, 0);
}
lv_obj_move_foreground(row.slider);
@@ -201,9 +213,9 @@ inline void setup(lv_obj_t *parent, const lv_font_t *font, int width, int height
lv_obj_set_style_radius(row.slider, LV_RADIUS_CIRCLE, LV_PART_KNOB);
lv_obj_set_style_pad_all(row.slider, large ? 4 : 3, LV_PART_KNOB);
lv_obj_set_style_border_width(row.slider, large ? 4 : 3, LV_PART_KNOB);
- lv_obj_set_style_border_color(row.slider, lv_color_hex(0xFFFFFF), LV_PART_KNOB);
lv_obj_set_style_outline_width(row.slider, 1, LV_PART_KNOB);
- lv_obj_set_style_outline_color(row.slider, lv_color_hex(0x000000), LV_PART_KNOB);
+ // A faint dark edge that lifts the white ring off a pale end of the track.
+ lv_obj_set_style_outline_color(row.slider, lv_color_black(), LV_PART_KNOB);
lv_obj_set_style_outline_opa(row.slider, LV_OPA_20, LV_PART_KNOB);
lv_slider_set_range(row.slider, i == 0 ? 0 : 2000, i == 0 ? 360 : 6500);
} else {
@@ -216,12 +228,10 @@ inline void setup(lv_obj_t *parent, const lv_font_t *font, int width, int height
lv_obj_set_style_radius(row.slider, corner, LV_PART_INDICATOR);
lv_obj_set_style_bg_opa(row.slider, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_bg_opa(row.slider, LV_OPA_COVER, LV_PART_INDICATOR);
- lv_obj_set_style_bg_color(row.slider, lv_color_hex(0xFDEFC3), LV_PART_MAIN);
- lv_obj_set_style_bg_color(row.slider, lv_color_hex(0xFFC107), LV_PART_INDICATOR);
+ lv_obj_set_style_bg_color(row.slider, lv_color_hex(theme::ha::AMBER), LV_PART_INDICATOR);
// White handle inside the end of the fill, as on the tiles (runtime_tiles::slider_handle).
int handle = large ? 4 : 3, back = std::max(1, track_h / 8) + handle / 2, half = track_h >> 1;
lv_obj_set_style_bg_opa(row.slider, LV_OPA_COVER, LV_PART_KNOB);
- lv_obj_set_style_bg_color(row.slider, lv_color_hex(0xFFFFFF), LV_PART_KNOB);
lv_obj_set_style_radius(row.slider, 2, LV_PART_KNOB);
lv_obj_set_style_pad_left(row.slider, back + handle / 2 - half, LV_PART_KNOB);
lv_obj_set_style_pad_right(row.slider, handle / 2 - back - (track_h - half), LV_PART_KNOB);
@@ -232,6 +242,7 @@ inline void setup(lv_obj_t *parent, const lv_font_t *font, int width, int height
lv_slider_set_range(row.slider, 1 - below, 100);
}
lv_obj_add_event_cb(row.slider, event, LV_EVENT_ALL, &row);
+ paint(row);
preview(row);
}
}
diff --git a/components/smart_display/runtime_tiles.h b/components/smart_display/runtime_tiles.h
index 9dad9c9..c14658f 100644
--- a/components/smart_display/runtime_tiles.h
+++ b/components/smart_display/runtime_tiles.h
@@ -2,6 +2,7 @@
#include "runtime_model.h"
#include "header_bar.h"
#include "tile_palette.h"
+#include "theme.h"
#include "tile_icon.h"
#include "screen_settings.h"
#include "settings_screen.h"
@@ -32,6 +33,7 @@ using settings_screen::auto_home_seconds;
inline esphome::ESPPreferenceObject rotation_preference;
inline esphome::ESPPreferenceObject swipe_preference;
inline esphome::ESPPreferenceObject home_preference;
+inline esphome::ESPPreferenceObject dark_preference;
inline Model model;
inline std::string inbox;
inline const lv_font_t *watch_font = nullptr;
@@ -114,8 +116,11 @@ inline void load_settings() {
settings_preference = esphome::global_preferences->make_preference(0x53435231);
swipe_preference = esphome::global_preferences->make_preference(0x53575031);
home_preference = esphome::global_preferences->make_preference(0x484F4D31);
+ dark_preference = esphome::global_preferences->make_preference(0x44524B31);
uint32_t swipe_saved=0;
if(swipe_preference.load(&swipe_saved))swipe_pages=swipe_saved==1;
+ uint32_t dark_saved=0;
+ if(dark_preference.load(&dark_saved))settings_screen::dark_mode=dark_saved==1;
HomeTimeout home;
if(home_preference.load(&home) && home.seconds>=30 && home.seconds<=3600){
auto_home=home.enabled?1:0;auto_home_seconds=(int32_t)home.seconds;
@@ -136,6 +141,8 @@ inline void persist_settings() {
swipe_preference.save(&swipe);
HomeTimeout home{(uint32_t) (auto_home ? 1 : 0), (uint32_t) std::clamp(auto_home_seconds, 30, 3600)};
home_preference.save(&home);
+ uint32_t dark = settings_screen::dark_mode ? 1 : 0;
+ dark_preference.save(&dark);
if (rotation_supported) { uint32_t turned = (uint32_t) rotation; rotation_preference.save(&turned); }
}
inline bool parse_settings(JsonObject obj, screen_settings::Settings &s) {
@@ -624,7 +631,7 @@ inline lv_obj_t *detail_badge_status=nullptr;
inline lv_obj_t *detail_switch=nullptr;
inline lv_obj_t *detail_label(lv_obj_t *parent,const std::string &text,int x,int y,int width) {
auto *label=lv_label_create(parent);lv_label_set_text(label,text.c_str());lv_obj_set_pos(label,x,y);lv_obj_set_width(label,width);
- lv_obj_set_style_text_font(label,detail_font,0);lv_obj_set_style_text_color(label,lv_color_hex(0x202020),0);
+ lv_obj_set_style_text_font(label,detail_font,0);lv_obj_set_style_text_color(label,theme::color(theme::INK),0);
lv_label_set_long_mode(label,LV_LABEL_LONG_DOT);lv_obj_set_height(label,lv_font_get_line_height(detail_font));return label;
}
inline std::string detail_state(const Tile &t){
@@ -708,8 +715,8 @@ inline void choose(Tile &t,Choice &row,const std::string &value){
}
inline lv_obj_t *detail_button(const char *text,int x,int y,int width,int height,int command){
auto *button=lv_obj_create(detail_root);lv_obj_remove_style_all(button);lv_obj_set_pos(button,x,y);lv_obj_set_size(button,width,height);
- lv_obj_set_style_bg_color(button,lv_color_hex(command==0?0x009FE3:0xD9E6F0),0);lv_obj_set_style_bg_opa(button,LV_OPA_COVER,0);lv_obj_set_style_radius(button,12,0);lv_obj_add_flag(button,LV_OBJ_FLAG_CLICKABLE);
- auto *label=detail_label(button,text,6,0,width-12);lv_obj_center(label);lv_obj_set_style_text_align(label,LV_TEXT_ALIGN_CENTER,0);if(command==0)lv_obj_set_style_text_color(label,lv_color_hex(0xFFFFFF),0);lv_obj_remove_flag(label,LV_OBJ_FLAG_CLICKABLE);
+ lv_obj_set_style_bg_color(button,theme::color(command==0?theme::ACCENT:theme::BUTTON),0);lv_obj_set_style_bg_opa(button,LV_OPA_COVER,0);lv_obj_set_style_radius(button,12,0);lv_obj_add_flag(button,LV_OBJ_FLAG_CLICKABLE);
+ auto *label=detail_label(button,text,6,0,width-12);lv_obj_center(label);lv_obj_set_style_text_align(label,LV_TEXT_ALIGN_CENTER,0);if(command==0)lv_obj_set_style_text_color(label,theme::color(theme::ON_ACCENT),0);lv_obj_remove_flag(label,LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(button,[](lv_event_t *e){
int cmd=(intptr_t)lv_event_get_user_data(e);if(cmd==-1){hide_detail();return;}
// History ranges (firmware 0.2.51+): redraw after this event, which belongs to a key the redraw deletes.
@@ -737,7 +744,7 @@ inline lv_obj_t *detail_button(const char *text,int x,int y,int width,int height
if(cmd==40)action(t.state=="active"?"timer.pause":"timer.start",t.entity);
if(cmd==41)action("timer.cancel",t.entity);
},LV_EVENT_SHORT_CLICKED,(void*)(intptr_t)command);
- lv_obj_set_style_bg_color(button,lv_color_hex(0x0075B0),LV_STATE_PRESSED);
+ lv_obj_set_style_bg_color(button,theme::color(theme::ACCENT_PRESSED),LV_STATE_PRESSED);
lv_obj_set_style_transform_width(button,-2,LV_STATE_PRESSED);lv_obj_set_style_transform_height(button,-2,LV_STATE_PRESSED);
lv_obj_set_style_opa(button,LV_OPA_50,LV_STATE_DISABLED);
if(command>=0 && detail_action_count<32)detail_actions[detail_action_count++]=button;
@@ -745,19 +752,24 @@ inline lv_obj_t *detail_button(const char *text,int x,int y,int width,int height
}
// ---- Weather card: now, the next hours and the coming days, with rain ----
+// The colour of a condition's icon, as this look draws it on a card.
inline uint32_t weather_accent(const std::string &c) {
- if(c=="sunny")return 0xFFB300;
- if(c=="clear-night")return 0x6E41AB;
- if(c=="rainy"||c=="pouring"||c=="lightning-rainy"||c=="snowy-rainy")return 0x1E88E5;
- if(c=="snowy"||c=="hail")return 0x4FC3F7;
- if(c=="lightning")return 0xFFA000;
- if(c=="partlycloudy")return 0x7E9BB5;
- return 0x78909C;
+ using namespace theme::ha;
+ if(c=="sunny")return theme::foreground(SUNNY);
+ if(c=="clear-night")return theme::foreground(DEEP_PURPLE);
+ if(c=="rainy"||c=="pouring"||c=="lightning-rainy"||c=="snowy-rainy")return theme::foreground(RAIN);
+ if(c=="snowy"||c=="hail")return theme::foreground(SNOW);
+ if(c=="lightning")return theme::foreground(LIGHTNING);
+ if(c=="partlycloudy")return theme::foreground(PARTLY_CLOUDY);
+ return theme::foreground(CLOUDY);
}
inline lv_obj_t *detail_text(lv_obj_t *parent,const std::string &text,int x,int y,int width,const lv_font_t *font,lv_text_align_t align,uint32_t color){
auto *l=detail_label(parent,text,x,y,std::max(1,width));lv_obj_set_style_text_font(l,font,0);lv_obj_set_height(l,lv_font_get_line_height(font));
lv_obj_set_style_text_align(l,align,0);lv_obj_set_style_text_color(l,lv_color_hex(color),0);return l;
}
+inline lv_obj_t *detail_text(lv_obj_t *parent,const std::string &text,int x,int y,int width,const lv_font_t *font,lv_text_align_t align,theme::Role role){
+ return detail_text(parent,text,x,y,width,font,align,theme::hex(role));
+}
// "30%", "30% · 1.7 mm" or "1.7 mm": whatever the provider reports; empty when dry.
inline std::string rain_text(float chance,float mm,bool with_mm){
char b[32];
@@ -772,9 +784,9 @@ inline std::string degrees(float value){ if(!std::isfinite(value))return "--"; c
inline lv_obj_t *detail_card(int x,int y,int w,int h){
auto *card=lv_obj_create(detail_root);lv_obj_remove_style_all(card);lv_obj_remove_flag(card,LV_OBJ_FLAG_SCROLLABLE);lv_obj_remove_flag(card,LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_pos(card,x,y);lv_obj_set_size(card,w,h);
- lv_obj_set_style_bg_color(card,lv_color_hex(0xFFFFFF),0);lv_obj_set_style_bg_opa(card,LV_OPA_COVER,0);
+ lv_obj_set_style_bg_color(card,theme::color(theme::CARD),0);lv_obj_set_style_bg_opa(card,LV_OPA_COVER,0);
lv_obj_set_style_radius(card,lv_obj_get_style_radius(widgets[0].tile,LV_PART_MAIN),0);
- lv_obj_set_style_border_width(card,1,0);lv_obj_set_style_border_color(card,lv_color_hex(0xDDDDDD),0);
+ lv_obj_set_style_border_width(card,1,0);lv_obj_set_style_border_color(card,theme::color(theme::LINE),0);
return card;
}
// Two cards: "now" with the next hours, and the coming days. Bold highs, muted lows,
@@ -787,7 +799,7 @@ inline void render_weather_detail(const Tile &t,bool large,int width,int height,
const lv_font_t *mini=mini_icon_font?mini_icon_font:icon_font;
const lv_font_t *tiny=watch_icon_font?watch_icon_font:mini;
const lv_font_t *small=widgets[0].value?lv_obj_get_style_text_font(widgets[0].value,LV_PART_MAIN):detail_font;
- uint32_t ink=0x202020,muted=0x6B6B6B,rain=0x1E88E5;
+ const uint32_t ink=theme::hex(theme::INK),muted=theme::hex(theme::SUBTLE),rain=theme::foreground(theme::ha::RAIN);
int text_h=lv_font_get_line_height(detail_font),small_h=lv_font_get_line_height(small),mini_h=lv_font_get_line_height(mini),tiny_h=lv_font_get_line_height(tiny);
int icon_h=lv_font_get_line_height(icon_font),big_h=lv_font_get_line_height(big),hero=std::max(icon_h,big_h);
int card_pad=large?14:7,inner=width-2*pad-2*card_pad;
@@ -856,6 +868,9 @@ inline lv_obj_t *detail_shape(lv_obj_t *parent,int x,int y,int w,int h,uint32_t
lv_obj_set_style_radius(o,radius,0);lv_obj_set_style_bg_color(o,lv_color_hex(color),0);lv_obj_set_style_bg_opa(o,LV_OPA_COVER,0);
lv_obj_remove_flag(o,LV_OBJ_FLAG_CLICKABLE);lv_obj_remove_flag(o,LV_OBJ_FLAG_SCROLLABLE);return o;
}
+inline lv_obj_t *detail_shape(lv_obj_t *parent,int x,int y,int w,int h,theme::Role role,int radius){
+ return detail_shape(parent,x,y,w,h,theme::hex(role),radius);
+}
inline int text_width(const std::string &text,const lv_font_t *font){
lv_point_t size;lv_text_get_size(&size,text.c_str(),font,0,0,LV_COORD_MAX,LV_TEXT_FLAG_NONE);return size.x;
}
@@ -863,31 +878,30 @@ inline int text_width(const std::string &text,const lv_font_t *font){
// when paused, red on an error; a docked or idle robot keeps the card's own blue. `tint` is the halo.
struct VacuumLook { uint32_t accent, tint; };
inline VacuumLook vacuum_look(const std::string &state){
- if(state=="cleaning")return {0x009688,0xDCF0EE};
- if(state=="returning")return {0x2196F3,0xE1EFFD};
- if(state=="paused")return {0xFF9800,0xFFF0DA};
- if(state=="error")return {0xF44336,0xFDE4E2};
- return {0x00A6ED,0xEAF5FC};
+ using namespace theme::ha;
+ const uint32_t accent=state=="cleaning"?TEAL:state=="returning"?BLUE:state=="paused"?ORANGE:state=="error"?RED:SKY;
+ // A robot at rest has the quietest halo.
+ return {accent,theme::tint(accent,accent==SKY?22:36)};
}
// A robot seen from above on a halo: a white body with a rim, the laser turret, a light bar in the state colour.
inline void vacuum_robot(lv_obj_t *parent,int x,int y,int size,const VacuumLook &look){
auto *halo=detail_shape(parent,x,y,size,size,look.tint,size/2);
int r=size*72/100,rim=std::max(1,size/44);
- auto *body=detail_shape(halo,(size-r)/2,(size-r)/2,r,r,0xFFFFFF,r/2);
- lv_obj_set_style_border_width(body,rim,0);lv_obj_set_style_border_color(body,lv_color_hex(0xCEDDE6),0);
+ auto *body=detail_shape(halo,(size-r)/2,(size-r)/2,r,r,theme::ROBOT_BODY,r/2);
+ lv_obj_set_style_border_width(body,rim,0);lv_obj_set_style_border_color(body,theme::color(theme::ROBOT_RIM),0);
int c=r-2*rim;auto s=[&](int v){return std::max(1,v*c/100);};
- detail_shape(body,s(32),s(10),s(36),s(36),0xE3EBEF,s(18));
- detail_shape(body,s(41),s(19),s(18),s(18),0xA8BCC8,s(9));
+ detail_shape(body,s(32),s(10),s(36),s(36),theme::ROBOT_TOP,s(18));
+ detail_shape(body,s(41),s(19),s(18),s(18),theme::ROBOT_LENS,s(9));
detail_shape(body,s(31),s(70),s(38),std::max(3,s(7)),look.accent,s(4));
}
// A battery like a phone's: outline, level fill (red below 20 %) and a small cap.
inline void vacuum_battery(lv_obj_t *parent,int x,int y,int w,int h,float level){
int line=std::max(1,h/7),fill_w=w-4*line,fill=std::clamp((int)std::lround(fill_w*level/100.0f),0,fill_w);
- auto *shell=detail_shape(parent,x,y,w,h,0xFFFFFF,std::max(2,h/4));
+ auto *shell=detail_shape(parent,x,y,w,h,theme::CARD,std::max(2,h/4));
lv_obj_set_style_bg_opa(shell,LV_OPA_TRANSP,0);
- lv_obj_set_style_border_width(shell,line,0);lv_obj_set_style_border_color(shell,lv_color_hex(0x7D858D),0);
- if(fill)detail_shape(shell,line,line,fill,h-4*line,level<20?0xE53935:0x46525E,std::max(1,h/9));
- detail_shape(parent,x+w,y+h*3/10,std::max(2,line+1),h-2*(h*3/10),0x7D858D,1);
+ lv_obj_set_style_border_width(shell,line,0);lv_obj_set_style_border_color(shell,theme::color(theme::BATTERY),0);
+ if(fill)detail_shape(shell,line,line,fill,h-4*line,level<20?theme::foreground(theme::ha::ALARM):theme::hex(theme::SLATE),std::max(1,h/9));
+ detail_shape(parent,x+w,y+h*3/10,std::max(2,line+1),h-2*(h*3/10),theme::BATTERY,1);
}
// Battery meter, level and a green bolt while charging, in one row from `x`; returns the row's width.
// Without a parent it only measures.
@@ -900,8 +914,8 @@ inline int vacuum_power(lv_obj_t *parent,const Tile &t,int x,int y,const lv_font
if(!parent)return width;
vacuum_battery(parent,x,y+(h-meter_h)/2,meter_w,meter_h,t.battery);
int px=x+meter_w+3+gap;
- detail_text(parent,percent,px,y,words+2,font,LV_TEXT_ALIGN_LEFT,0x5F6368);
- if(bolt_w)detail_text(parent,"\U000F0241",px+words+gap/2,y+(h-lv_font_get_line_height(bolt_font))/2,bolt_w+2,bolt_font,LV_TEXT_ALIGN_LEFT,0x43A047);
+ detail_text(parent,percent,px,y,words+2,font,LV_TEXT_ALIGN_LEFT,theme::MUTED);
+ if(bolt_w)detail_text(parent,"\U000F0241",px+words+gap/2,y+(h-lv_font_get_line_height(bolt_font))/2,bolt_w+2,bolt_font,LV_TEXT_ALIGN_LEFT,theme::foreground(theme::ha::CHARGING));
return width;
}
// Put a detail_button's words in `font`, sized to the words and centred (or at `x` when given).
@@ -916,12 +930,12 @@ inline lv_obj_t *button_words(lv_obj_t *button,const lv_font_t *font,uint32_t co
inline lv_obj_t *vacuum_command(const char *icon,const char *text,int x,int y,int w,int h,int command,bool primary,
const lv_font_t *font,const lv_font_t *icon_font,int radius){
auto *button=detail_button(text,x,y,w,h,command);
- uint32_t ink=primary?0xFFFFFF:0x1B1B1B;
+ const uint32_t ink=theme::hex(primary?theme::ON_ACCENT:theme::INK);
lv_obj_set_style_radius(button,radius,0);
- lv_obj_set_style_bg_color(button,lv_color_hex(primary?0x009FE3:0xFFFFFF),0);
+ lv_obj_set_style_bg_color(button,theme::color(primary?theme::ACCENT:theme::CARD),0);
if(!primary){
- lv_obj_set_style_border_width(button,1,0);lv_obj_set_style_border_color(button,lv_color_hex(0xDDDDDD),0);
- lv_obj_set_style_bg_color(button,lv_color_hex(0xEEEEEE),LV_STATE_PRESSED);
+ lv_obj_set_style_border_width(button,1,0);lv_obj_set_style_border_color(button,theme::color(theme::LINE),0);
+ lv_obj_set_style_bg_color(button,theme::color(theme::KEY),LV_STATE_PRESSED);
}
int gap=std::max(6,h/7),icon_w=text_width(icon,icon_font),words=text_width(text,font);
int group=icon_w+gap+words,left=std::max(4,(w-group)/2);
@@ -936,7 +950,7 @@ inline lv_obj_t *vacuum_command(const char *icon,const char *text,int x,int y,in
inline void vacuum_segments(const Tile &t,const Choice &row,int first,int x,int y,int w,int h,uint32_t track,bool border,const lv_font_t *font){
int n=std::min((int)row.values.size(),6);if(!n)return;
auto *bar=detail_shape(detail_root,x,y,w,h,track,h/2);
- if(border){lv_obj_set_style_border_width(bar,1,0);lv_obj_set_style_border_color(bar,lv_color_hex(0xDDDDDD),0);}
+ if(border){lv_obj_set_style_border_width(bar,1,0);lv_obj_set_style_border_color(bar,theme::color(theme::LINE),0);}
int inset=std::max(3,h/12),room=w-2*inset,words=0;int widths[6];
for(int i=0;i24)
- detail_text(detail_root," · "+t.extra().room,pad+dot+10+state_w,y,words-state_w,text,LV_TEXT_ALIGN_LEFT,0x5F6368);
+ detail_text(detail_root," · "+t.extra().room,pad+dot+10+state_w,y,words-state_w,text,LV_TEXT_ALIGN_LEFT,theme::MUTED);
if(battery)vacuum_power(detail_root,t,pad+inner-power_w,y,text,false);
y+=line+(gap+4);
}
@@ -1011,20 +1025,20 @@ inline void render_vacuum_detail(Tile &t,bool large,int width,int height,int pad
vacuum_command("\U000F05F8","Dock",pad+start_w+gap,y,inner-start_w-gap,action_h,2,false,text,icons,large?radius:action_h/2);
y+=action_h+gap;
if(!mode && !suction && !water){
- auto *note=detail_text(detail_root,"Automatic suction power",pad,y+gap,inner,text,LV_TEXT_ALIGN_CENTER,0x6B6B6B);(void)note;
+ auto *note=detail_text(detail_root,"Automatic suction power",pad,y+gap,inner,text,LV_TEXT_ALIGN_CENTER,theme::SUBTLE);(void)note;
return;
}
// How it cleans. The Guition groups the rows on one white card; the small screen has no room for a card.
int mode_h=large?48:34,row_h=large?44:32,step=large?10:6,edge=large?14:0,icon_w=large?40:26;
int note_h=lv_font_get_line_height(text);
int block=(mode?mode_h:0)+(suction?(mode?step:0)+row_h:0)+(water?((mode||suction)?step:0)+row_h:0)+(automatic?step+note_h+step:0);
- uint32_t track=large?0xF1F1F1:0xFFFFFF;
+ const uint32_t track=theme::hex(large?theme::TRACK:theme::CARD);
if(large)detail_card(pad,y,inner,block+2*edge);
int x=pad+edge,w=inner-2*edge;
y+=edge;
if(mode){vacuum_segments(t,*mode,50,x,y,w,mode_h,track,!large,text);y+=mode_h+step;}
auto level=[&](const Choice &row,int first,const char *icon){
- detail_text(detail_root,icon,x,y+(row_h-lv_font_get_line_height(icons))/2,icon_w,icons,LV_TEXT_ALIGN_LEFT,0x6B6B6B);
+ detail_text(detail_root,icon,x,y+(row_h-lv_font_get_line_height(icons))/2,icon_w,icons,LV_TEXT_ALIGN_LEFT,theme::SUBTLE);
vacuum_segments(t,row,first,x+icon_w,y,w-icon_w,row_h,track,!large,text);
y+=row_h+step;
};
@@ -1032,7 +1046,7 @@ inline void render_vacuum_detail(Tile &t,bool large,int width,int height,int pad
if(water)level(*water,60,"\U000F058C");
if(automatic){
bool smart=tile_controls::shown_value(t,*mode,now).find("smart")!=std::string::npos;
- detail_text(detail_root,smart?"The robot chooses suction and water":"Suction and water as set per room",x,y,w,text,LV_TEXT_ALIGN_CENTER,0x6B6B6B);
+ detail_text(detail_root,smart?"The robot chooses suction and water":"Suction and water as set per room",x,y,w,text,LV_TEXT_ALIGN_CENTER,theme::SUBTLE);
}
}
// ---- Cover card (firmware 0.2.50+): Home Assistant's cover dialog in the style of the vacuum and climate cards ----
@@ -1040,7 +1054,10 @@ inline void render_vacuum_detail(Tile &t,bool large,int width,int height,int pad
// and the tilt as a handle over slats, each with its value below, like Home Assistant's own sliders. Open, stop
// and close as a row of pill keys under them; the key of the direction the cover moves is filled. A slider
// sends its value when the finger lifts, and shows it while it moves.
-inline constexpr uint32_t COVER_ACCENT = 0x926BC7, COVER_TRACK = 0xEFE8F7, COVER_SLATS = 0xDDD0EF;
+// Home Assistant's purple for covers; the track and the slats are its tints.
+inline constexpr uint32_t COVER_ACCENT = theme::ha::PURPLE;
+inline uint32_t cover_track(){return theme::tint(COVER_ACCENT,37);}
+inline uint32_t cover_slats(){return theme::tint(COVER_ACCENT,80);}
inline lv_obj_t *cover_values[2]{};
inline std::string cover_status_line(const Tile &t){return t.available()?tile_controls::cover_card_status(t):"Unavailable";}
inline void cover_slider_event(lv_event_t *e){
@@ -1066,19 +1083,19 @@ inline void cover_slider_event(lv_event_t *e){
inline lv_obj_t *cover_slider(lv_obj_t *parent,int x,int y,int w,int h,float value,bool tilt){
int radius=std::max(8,w/7),handle_h=std::max(4,w/18),handle_w=tilt?w*3/5:w*2/5,inset=std::max(6,w/9);
if(tilt){
- auto *slats=detail_shape(parent,x,y,w,h,COVER_TRACK,radius);
+ auto *slats=detail_shape(parent,x,y,w,h,cover_track(),radius);
const int count=10,pitch=(h-2*radius/2)/count;
for(int i=0;i &keys,unsigned
const auto &key=keys[i];
auto *button=detail_button("",x+(int)i*(key_w+gap),y,key_w,h,70+key.command);
lv_obj_set_style_radius(button,h/2,0);
- lv_obj_set_style_bg_color(button,lv_color_hex(key.checked?COVER_ACCENT:0xFFFFFF),0);
- lv_obj_set_style_bg_color(button,lv_color_hex(key.checked?0x7552A8:0xEEEEEE),LV_STATE_PRESSED);
- lv_obj_set_style_border_width(button,key.checked?0:1,0);lv_obj_set_style_border_color(button,lv_color_hex(0xDDDDDD),0);
+ lv_obj_set_style_bg_color(button,key.checked?lv_color_hex(COVER_ACCENT):theme::color(theme::CARD),0);
+ lv_obj_set_style_bg_color(button,key.checked?lv_color_hex(theme::ha::PURPLE_PRESSED):theme::color(theme::KEY),LV_STATE_PRESSED);
+ lv_obj_set_style_border_width(button,key.checked?0:1,0);lv_obj_set_style_border_color(button,theme::color(theme::LINE),0);
auto *glyph=lv_obj_get_child(button,0);
lv_obj_set_style_text_font(glyph,icons,0);lv_label_set_text(glyph,key.icon);
- lv_obj_set_style_text_color(glyph,lv_color_hex(key.checked?0xFFFFFF:0x1B1B1B),0);
+ lv_obj_set_style_text_color(glyph,theme::color(key.checked?theme::ON_ACCENT:theme::INK),0);
lv_obj_set_size(glyph,LV_SIZE_CONTENT,LV_SIZE_CONTENT);lv_obj_center(glyph);
if(key.disabled){lv_obj_add_state(button,LV_STATE_DISABLED);if(detail_action_count && detail_actions[detail_action_count-1]==button)--detail_action_count;}
}
@@ -1131,7 +1148,7 @@ inline void cover_battery(const Tile &t,bool large,int width){
int level=(int)std::lround(std::clamp(t.battery,0.0f,100.0f));
int line=lv_font_get_line_height(font),block=meter_h+gap+line,cx=width-bar_x-bar/2,y=bar_y+(bar-block)/2;
vacuum_battery(detail_root,cx-meter_w/2-1,y,meter_w,meter_h,t.battery);
- detail_text(detail_root,std::to_string(level)+"%",cx-bar/2-8,y+meter_h+gap,bar+16,font,LV_TEXT_ALIGN_CENTER,0x5F6368);
+ detail_text(detail_root,std::to_string(level)+"%",cx-bar/2-8,y+meter_h+gap,bar+16,font,LV_TEXT_ALIGN_CENTER,theme::MUTED);
}
inline void render_cover_detail(Tile &t,bool large,int width,int height,int pad){
auto card=tile_controls::cover_card(t);
@@ -1162,8 +1179,8 @@ inline void render_cover_detail(Tile &t,bool large,int width,int height,int pad)
for(int i=0;i=static_cast(h.states.size())?0xF1F1F1:h.states[state].color);
+ dsc.bg_color=lv_color_hex(state<0||state>=static_cast(h.states.size())?theme::hex(theme::TRACK):theme::state(h.states[state].color));
lv_area_t piece{x1,area.y1,std::max(x1,x2),area.y2};lv_draw_rect(layer,&dsc,&piece);
}
}
inline void history_restore(){
auto &c=history_chart;
- if(c.value){label(c.value,c.value_text);lv_obj_set_style_text_color(c.value,lv_color_hex(0x1B1B1B),0);}
+ if(c.value){label(c.value,c.value_text);lv_obj_set_style_text_color(c.value,theme::color(theme::INK),0);}
if(c.first)label(c.first,c.first_text);
if(c.second)label(c.second,c.second_text);
}
@@ -1292,7 +1309,7 @@ inline void history_scrub(lv_event_t *e){
const uint64_t span=h.end-h.start;
const uint32_t begin=h.start+static_cast(span*i/history_view::PARTS),finish=h.start+static_cast(span*(i+1)/history_view::PARTS);
label(c.value,h.has[i]?history_view::number(h.values[i],h.decimals,h.unit):"No data");
- lv_obj_set_style_text_color(c.value,lv_color_hex(h.has[i]?c.accent:0x6B6B6B),0);
+ lv_obj_set_style_text_color(c.value,lv_color_hex(h.has[i]?theme::foreground(c.accent):theme::hex(theme::SUBTLE)),0);
if(c.first)label(c.first,history_clock(begin,week)+" \u2013 "+history_clock(finish,false));
if(c.second)label(c.second,!h.has[i]?"":h.hours==1?"average":"average of "+history_view::duration(finish-begin));
}else{
@@ -1317,7 +1334,7 @@ inline void history_times(int x,int w,int y,const lv_font_t *font,bool large){
const auto &h=history;
const lv_font_t *bold=detail_font?detail_font:font;
const int now_w=text_width("Now",bold),gap=large?10:6;
- detail_text(detail_root,"Now",x+w-now_w,y,now_w+2,bold,LV_TEXT_ALIGN_LEFT,0x1B1B1B);
+ detail_text(detail_root,"Now",x+w-now_w,y,now_w+2,bold,LV_TEXT_ALIGN_LEFT,theme::INK);
int last=x-1000;
for(uint32_t at:h.times){
if(at<=h.start||at>=h.end)continue;
@@ -1326,8 +1343,8 @@ inline void history_times(int x,int w,int y,const lv_font_t *font,bool large){
const int tw=text_width(words,font),cx=x+static_cast(std::lround(float(at-h.start)/float(h.end-h.start)*w));
const int left=std::max(x-(large?8:4),cx-tw/2);
if(left<=last+gap||left+tw>x+w-now_w-gap)continue;
- detail_shape(detail_root,cx,y-(large?7:4),1,large?5:3,0xCCCCCC,0);
- detail_text(detail_root,words,left,y,tw+2,font,LV_TEXT_ALIGN_LEFT,0x6B6B6B);
+ detail_shape(detail_root,cx,y-(large?7:4),1,large?5:3,theme::TICK,0);
+ detail_text(detail_root,words,left,y,tw+2,font,LV_TEXT_ALIGN_LEFT,theme::SUBTLE);
last=left+tw;
}
}
@@ -1346,8 +1363,8 @@ inline void render_history_line(bool large,int card_x,int card_y,int card_w,int
if(c.w<40||c.h<24)return;
for(const auto &tick:h.ticks){
const int ty=static_cast(std::lround(history_y(tick.first)));
- detail_shape(detail_root,c.x,ty,c.w,1,0xEEEEEE,0);
- detail_text(detail_root,tick.second,card_x+edge,ty-label_h/2,label_w+2,small,LV_TEXT_ALIGN_RIGHT,0x6B6B6B);
+ detail_shape(detail_root,c.x,ty,c.w,1,theme::GRID,0);
+ detail_text(detail_root,tick.second,card_x+edge,ty-label_h/2,label_w+2,small,LV_TEXT_ALIGN_RIGHT,theme::SUBTLE);
}
// Through the middle of each part, from the left edge when the range begins with a value, to "Now" at the right
// (the value now); a curve that never overshoots them, so the axis and the highest and lowest moment stay true.
@@ -1388,7 +1405,7 @@ inline void render_history_line(bool large,int card_x,int card_y,int card_w,int
auto marker=[&](float value,float at_x,bool high){
const int mx=c.x+static_cast(std::lround(at_x));
const int my=static_cast(std::lround(history_y(value)));
- auto *o=detail_shape(detail_root,mx-ring/2,my-ring/2,ring,ring,0xFFFFFF,ring/2);
+ auto *o=detail_shape(detail_root,mx-ring/2,my-ring/2,ring,ring,theme::CARD,ring/2);
lv_obj_set_style_border_width(o,large?3:2,0);lv_obj_set_style_border_color(o,lv_color_hex(c.accent),0);
if(!large)return;
const std::string words=history_view::number(value,h.decimals,"");
@@ -1397,14 +1414,14 @@ inline void render_history_line(bool large,int card_x,int card_y,int card_w,int
if(lyc.y+c.h+6)ly=my-ring/2-lh-2;
const int lx=std::max(c.x,std::min(mx-tw/2,c.x+c.w-tw));
- detail_text(detail_root,words,lx,ly,tw,detail_font,LV_TEXT_ALIGN_CENTER,0x1B1B1B);
+ detail_text(detail_root,words,lx,ly,tw,detail_font,LV_TEXT_ALIGN_CENTER,theme::INK);
};
if(std::isfinite(c.high))marker(c.high,high_x,true);
if(std::isfinite(c.low)&&c.low!=c.high)marker(c.low,low_x,false);
if(std::isfinite(current)){
const int size=large?12:8;
auto *o=detail_shape(detail_root,c.x+c.w-1-size/2,static_cast(std::lround(history_y(current)))-size/2,size,size,c.accent,size/2);
- lv_obj_set_style_border_width(o,2,0);lv_obj_set_style_border_color(o,lv_color_hex(0xFFFFFF),0);
+ lv_obj_set_style_border_width(o,2,0);lv_obj_set_style_border_color(o,theme::color(theme::CARD),0);
}
history_times(c.x,c.w,c.y+c.h+(large?8:4),small,large);
history_touch(c.x-(large?14:8),card_y,c.w+(large?28:16),card_h);
@@ -1428,11 +1445,11 @@ inline void render_history_timeline(bool large,int card_x,int card_y,int card_w,
const size_t shown=std::min(h.states.size(),static_cast(rows)*2);
for(size_t i=0;i(i%2)*col_w,ly=top+static_cast(i/2)*row_h;
- detail_shape(detail_root,lx,ly+(label_h-square)/2,square,square,h.states[i].color,large?4:2);
+ detail_shape(detail_root,lx,ly+(label_h-square)/2,square,square,theme::state(h.states[i].color),large?4:2);
const std::string time=history_view::duration(h.states[i].seconds);
const int tw=text_width(time,small)+2,words_x=lx+square+(large?10:5),time_x=lx+col_w-(large?14:8)-tw;
- detail_text(detail_root,h.states[i].label,words_x,ly,std::max(8,time_x-words_x-6),small,LV_TEXT_ALIGN_LEFT,0x1B1B1B);
- detail_text(detail_root,time,time_x,ly,tw,small,LV_TEXT_ALIGN_LEFT,0x6B6B6B);
+ detail_text(detail_root,h.states[i].label,words_x,ly,std::max(8,time_x-words_x-6),small,LV_TEXT_ALIGN_LEFT,theme::INK);
+ detail_text(detail_root,time,time_x,ly,tw,small,LV_TEXT_ALIGN_LEFT,theme::SUBTLE);
}
history_touch(c.x-(large?14:8),card_y,c.w+(large?28:16),c.y+c.h+(large?24:12)-card_y);
}
@@ -1442,19 +1459,19 @@ inline void history_ranges(int x,int y,int w,int h){
static const uint32_t hours[]={1,24,168};
static const char *const words[]={"1 hour","24 hours","1 week"};
const lv_font_t *font=control_font?control_font:detail_font;
- auto *track=detail_shape(detail_root,x,y,w,h,0xFFFFFF,h/2);
- lv_obj_set_style_border_width(track,1,0);lv_obj_set_style_border_color(track,lv_color_hex(0xDDDDDD),0);
+ auto *track=detail_shape(detail_root,x,y,w,h,theme::CARD,h/2);
+ lv_obj_set_style_border_width(track,1,0);lv_obj_set_style_border_color(track,theme::color(theme::LINE),0);
const int inset=std::max(3,h/10),sw=(w-2*inset)/3;
for(int i=0;i<3;++i){
const bool selected=history_hours==hours[i];
const int segment_w=i==2?w-2*inset-2*sw:sw;
auto *segment=detail_button(words[i],x+inset+i*sw,y+inset,segment_w,h-2*inset,160+i);
lv_obj_set_style_radius(segment,(h-2*inset)/2,0);
- lv_obj_set_style_bg_color(segment,lv_color_hex(0x009FE3),0);
+ lv_obj_set_style_bg_color(segment,theme::color(theme::ACCENT),0);
lv_obj_set_style_bg_opa(segment,selected?LV_OPA_COVER:LV_OPA_TRANSP,0);
lv_obj_set_style_bg_opa(segment,LV_OPA_COVER,LV_STATE_PRESSED);
- if(!selected)lv_obj_set_style_bg_color(segment,lv_color_hex(0xD5EEFC),LV_STATE_PRESSED);
- button_words(segment,font,selected?0xFFFFFF:0x1B1B1B,segment_w-6);
+ if(!selected)lv_obj_set_style_bg_color(segment,theme::color(theme::ACCENT_TINT),LV_STATE_PRESSED);
+ button_words(segment,font,theme::hex(selected?theme::ON_ACCENT:theme::INK),segment_w-6);
if(detail_action_count&&detail_actions[detail_action_count-1]==segment)--detail_action_count;
}
}
@@ -1501,9 +1518,9 @@ inline void render_history_detail(const Tile &t,bool large,int width,int height,
const int sw=large?84:52,sh=large?46:28;
detail_switch=lv_switch_create(detail_root);
lv_obj_set_size(detail_switch,sw,sh);lv_obj_set_pos(detail_switch,right-sw,row+(row_h-sh)/2);
- lv_obj_set_style_bg_color(detail_switch,lv_color_hex(0xB0B0B0),LV_PART_MAIN);
- lv_obj_set_style_bg_color(detail_switch,lv_color_hex(0xFFB900),static_cast(LV_PART_INDICATOR)|LV_STATE_CHECKED);
- lv_obj_set_style_bg_color(detail_switch,lv_color_hex(0xFFFFFF),LV_PART_KNOB);
+ lv_obj_set_style_bg_color(detail_switch,theme::color(theme::SWITCH_OFF),LV_PART_MAIN);
+ lv_obj_set_style_bg_color(detail_switch,lv_color_hex(theme::ha::SWITCH_ON),static_cast(LV_PART_INDICATOR)|LV_STATE_CHECKED);
+ lv_obj_set_style_bg_color(detail_switch,theme::color(theme::KNOB),LV_PART_KNOB);
lv_obj_set_style_opa(detail_switch,LV_OPA_50,LV_STATE_DISABLED);
if(t.state=="on")lv_obj_add_state(detail_switch,LV_STATE_CHECKED);
lv_obj_add_event_cb(detail_switch,[](lv_event_t *e){
@@ -1525,16 +1542,16 @@ inline void render_history_detail(const Tile &t,bool large,int width,int height,
if(c.ready&&line){for(int i=0;i=480;int pad=large?20:10, top=large?100:62, gap=large?12:6,bh=large?58:34,cw=(width-pad*2-gap)/2;
// The same top bar as the board's own cards: a round back arrow at the left, the name centred.
int bar=large?60:40,bar_x=large?16:10,bar_y=large?16:8;
- auto *back=detail_button("",bar_x,bar_y,bar,bar,-1);lv_obj_set_style_radius(back,LV_RADIUS_CIRCLE,0);lv_obj_set_style_bg_color(back,lv_color_hex(0xEEEEEE),0);
+ auto *back=detail_button("",bar_x,bar_y,bar,bar,-1);lv_obj_set_style_radius(back,LV_RADIUS_CIRCLE,0);lv_obj_set_style_bg_color(back,theme::color(theme::KEY),0);
auto *arrow=lv_obj_get_child(back,0);if(mini_icon_font)lv_obj_set_style_text_font(arrow,mini_icon_font,0);lv_label_set_text(arrow,"\U000F004D");lv_obj_set_size(arrow,LV_SIZE_CONTENT,LV_SIZE_CONTENT);lv_obj_center(arrow);
const lv_font_t *title_font=watch_font?watch_font:detail_font;
auto *heading=detail_label(detail_root,t.name,bar_x+bar+8,bar_y+(bar-lv_font_get_line_height(title_font))/2,width-2*(bar_x+bar+8));
@@ -1574,7 +1591,7 @@ inline void show_detail(unsigned index){
std::string state=d=="cover"?cover_status_line(t):detail_state(t);
// The vacuum and history cards draw their own state.
const bool with_history=history_card(t);
- if(d!="vacuum"&&!with_history){detail_status=detail_label(detail_root,state+(t.unit.empty()?"":" "+t.unit),pad,large?80:50,width-2*pad);lv_obj_set_style_text_align(detail_status,LV_TEXT_ALIGN_CENTER,0);lv_obj_set_style_text_color(detail_status,lv_color_hex(0x616161),0);}
+ if(d!="vacuum"&&!with_history){detail_status=detail_label(detail_root,state+(t.unit.empty()?"":" "+t.unit),pad,large?80:50,width-2*pad);lv_obj_set_style_text_align(detail_status,LV_TEXT_ALIGN_CENTER,0);lv_obj_set_style_text_color(detail_status,theme::color(theme::MUTED),0);}
if(with_history){
render_history_detail(t,large,width,height,pad);
}else if(d=="vacuum"){
@@ -1590,7 +1607,7 @@ inline void show_detail(unsigned index){
int w=(width-pad*2-2*gap)/3;
detail_button("Previous",pad,top,w,bh,21);detail_button("Play/pause",pad+w+gap,top,w,bh,20);detail_button("Next",pad+2*(w+gap),top,w,bh,22);top+=bh+gap;
detail_label(detail_root,"Volume",pad,top,width-2*pad);
- auto *slider=lv_slider_create(detail_root);lv_obj_set_pos(slider,pad+12,top+(large?52:34));lv_obj_set_size(slider,width-2*pad-24,large?24:16);lv_slider_set_range(slider,0,1000);lv_slider_set_value(slider,slider_value(t),LV_ANIM_OFF);lv_obj_set_style_bg_color(slider,lv_color_hex(0x111111),LV_PART_KNOB);
+ auto *slider=lv_slider_create(detail_root);lv_obj_set_pos(slider,pad+12,top+(large?52:34));lv_obj_set_size(slider,width-2*pad-24,large?24:16);lv_slider_set_range(slider,0,1000);lv_slider_set_value(slider,slider_value(t),LV_ANIM_OFF);lv_obj_set_style_bg_color(slider,theme::color(theme::SLIDER_KNOB),LV_PART_KNOB);
lv_obj_add_event_cb(slider,slider_event,LV_EVENT_ALL,(void*)(uintptr_t)index);
}else if(d=="weather"){
render_weather_detail(t,large,width,height,pad);
@@ -1826,10 +1843,11 @@ inline void bind(size_t index, lv_obj_t *tile, lv_obj_t *title, lv_obj_t *value,
w.slider=lv_slider_create(tile);lv_obj_set_size(w.slider,lv_obj_get_width(tile)-24,lv_obj_get_height(tile)>80?28:10);lv_obj_align(w.slider,LV_ALIGN_BOTTOM_MID,0,0);lv_slider_set_range(w.slider,0,1000);
// A short white bar inside the fill as handle, like the control sliders (invisible before 0.2.20).
int strip=lv_obj_get_height(tile)>80?28:10;
- lv_obj_set_style_bg_color(w.slider,lv_color_hex(0xFFFFFF),LV_PART_KNOB);lv_obj_set_style_bg_opa(w.slider,LV_OPA_COVER,LV_PART_KNOB);
+ lv_obj_add_style(w.slider,theme::style(theme::Paint::knob),LV_PART_KNOB);lv_obj_set_style_bg_opa(w.slider,LV_OPA_COVER,LV_PART_KNOB);
slider_handle(w.slider,lv_obj_get_width(tile)-24,strip);
lv_obj_set_style_border_width(w.slider,0,LV_PART_KNOB);lv_obj_set_style_shadow_width(w.slider,0,LV_PART_KNOB);
- lv_obj_set_style_radius(w.slider,2,LV_PART_KNOB);lv_obj_set_style_bg_color(w.slider,lv_color_hex(0xFCE5B4),LV_PART_MAIN);lv_obj_set_style_bg_color(w.slider,lv_color_hex(0xFFB900),LV_PART_INDICATOR);
+ // Track and fill follow the card's palette (render_slot), which is drawn before the slider shows.
+ lv_obj_set_style_radius(w.slider,2,LV_PART_KNOB);
lv_obj_add_flag(w.slider,LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_flag(w.slider,LV_OBJ_FLAG_GESTURE_BUBBLE);
lv_obj_add_event_cb(w.slider,slider_event,LV_EVENT_ALL,(void*)(uintptr_t)index);
@@ -1851,29 +1869,30 @@ inline void set_line_width(lv_obj_t *obj, int value) { if (lv_obj_get_style_line
// HA's default domain/state palette. Pastel circles also identify inactive domains.
// Custom Lovelace card/theme CSS is not an entity attribute and is not imported.
inline uint32_t domain_accent(const Tile &t) {
+ using namespace theme::ha;
auto d=t.domain();
- if(d=="light" || d=="switch" || d=="input_boolean" || d=="binary_sensor")return 0xFFC107;
+ if(d=="light" || d=="switch" || d=="input_boolean" || d=="binary_sensor")return AMBER;
// A climate card that is off or in an unknown mode keeps HA's orange climate circle.
- if(d=="climate"){uint32_t c=tile_controls::mode_color(t.state);return c==0x9E9E9E?0xFF9800:c;}
- if(d=="vacuum")return t.state=="error"?0xF44336:0x009688;
- if(d=="fan")return 0x00BCD4;
- if(d=="cover")return 0x926BC7;
- if(d=="media_player")return 0x03A9F4;
- if(d=="scene" || d=="script")return 0x926BC7;
- if(d=="select" || d=="input_select")return 0x3F51B5;
- if(d=="number" || d=="input_number")return 0x009688;
- if(d=="weather")return t.state=="sunny"?0xFFC107:t.state=="clear-night"?0x6E41AB:0x03A9F4;
- if(d=="sun")return t.state=="above_horizon"?0xFF9800:0x6E41AB;
- if(d=="timer")return t.state=="active"?0x009688:t.state=="paused"?0xFF9800:0x9E9E9E;
- if(d=="person")return t.state=="home"?0x4CAF50:0x9E9E9E;
- if(d=="screen")return 0x2196F3;
+ if(d=="climate"){uint32_t c=tile_controls::mode_color(t.state);return c==GREY?ORANGE:c;}
+ if(d=="vacuum")return t.state=="error"?RED:TEAL;
+ if(d=="fan")return CYAN;
+ if(d=="cover")return PURPLE;
+ if(d=="media_player")return LIGHT_BLUE;
+ if(d=="scene" || d=="script")return PURPLE;
+ if(d=="select" || d=="input_select")return INDIGO;
+ if(d=="number" || d=="input_number")return TEAL;
+ if(d=="weather")return t.state=="sunny"?AMBER:t.state=="clear-night"?DEEP_PURPLE:LIGHT_BLUE;
+ if(d=="sun")return t.state=="above_horizon"?ORANGE:DEEP_PURPLE;
+ if(d=="timer")return t.state=="active"?TEAL:t.state=="paused"?ORANGE:GREY;
+ if(d=="person")return t.state=="home"?GREEN:GREY;
+ if(d=="screen")return BLUE;
if(d=="sensor"){
- if(t.unit=="lx")return 0xFFC107;
- if(t.unit=="°C" || t.unit=="°F")return 0xFF6F22;
- if(t.unit=="kWh" || t.unit=="Wh")return 0x926BC7;
- if(t.unit=="%")return 0x009688;
+ if(t.unit=="lx")return AMBER;
+ if(t.unit=="°C" || t.unit=="°F")return DEEP_ORANGE;
+ if(t.unit=="kWh" || t.unit=="Wh")return PURPLE;
+ if(t.unit=="%")return TEAL;
}
- return 0x2196F3;
+ return BLUE;
}
// Custom cards draw into a transparent `extra` container; parts are rebuilt only
// when a slot changes mode, so paging keeps RAM use flat on the CYD.
@@ -2072,7 +2091,7 @@ inline void render_graph(Widgets &w,const Tile &t,bool large,int x,int y,int wid
if(n==1){raw[1]=raw[0];raw[1].x=(lv_value_precise_t)(width-1);n=2;}
unsigned count=smooth(raw,n,w.points,POINT_BUFFER,width,height);
part_line(w,0,w.points,count,stroke,x,y);
- w.fill_points=w.points;w.fill_count=count;w.fill_x=x;w.fill_y=y;w.fill_base=height;w.fill_opa=LV_OPA_20;
+ w.fill_points=w.points;w.fill_count=count;w.fill_x=x;w.fill_y=y;w.fill_base=height;w.fill_opa=theme::fill_opacity();
}
// Sun path: horizon, an arc from sunrise to sunset and the sun at the current
// position (or below the horizon at night). Wide cards only.
@@ -2106,14 +2125,14 @@ inline void render_sunpath(Widgets &w,const Tile &t,bool large,int width,int hei
lv_point_precise_t sun={(lv_value_precise_t)(x0+(x1-x0)*fraction),(lv_value_precise_t)(day?horizon-sinf(sa)*amplitude:horizon+sinf(sa)*amplitude)};
travelled[filled+1]=sun;
line[0]={(lv_value_precise_t)0,(lv_value_precise_t)horizon};line[1]={(lv_value_precise_t)(width-1),(lv_value_precise_t)horizon};
- uint32_t path=0xCFD8DC, accent=day?0xFF9800:0x5C6BC0, disc=day?0xFFB300:0xB0BEC5;
+ const uint32_t path=theme::hex(theme::SUN_PATH), accent=day?theme::ha::ORANGE:theme::foreground(theme::ha::NIGHT_SKY), disc=day?theme::ha::SUNNY:theme::hex(theme::MOON);
lv_obj_set_style_line_color(part_line(w,3,line,2,2),lv_color_hex(path),0);
lv_obj_set_style_line_color(part_line(w,4,arc,segments+1,large?2:1),lv_color_hex(path),0);
lv_obj_set_style_line_color(part_line(w,5,travelled,filled+2,large?4:3),lv_color_hex(accent),0);
int size=large?18:10,glow=size+(large?12:6);
auto *halo=part_dot(w,6,int(sun.x)-glow/2,int(sun.y)-glow/2,glow);lv_obj_set_style_bg_color(halo,lv_color_hex(disc),0);lv_obj_set_style_bg_opa(halo,LV_OPA_30,0);
lv_obj_set_style_bg_color(part_dot(w,7,int(sun.x)-size/2,int(sun.y)-size/2,size),lv_color_hex(disc),0);
- if(day){w.fill_points=travelled;w.fill_count=filled+2;w.fill_x=0;w.fill_y=0;w.fill_base=horizon;w.fill_color=lv_color_hex(0xFFB300);w.fill_opa=LV_OPA_20;}
+ if(day){w.fill_points=travelled;w.fill_count=filled+2;w.fill_x=0;w.fill_y=0;w.fill_base=horizon;w.fill_color=lv_color_hex(theme::ha::SUNNY);w.fill_opa=theme::fill_opacity();}
}
// ---- Direct controls on wide cards (Home Assistant entity-row style) ----
@@ -2196,7 +2215,7 @@ inline int layout_panel(Widgets &w,const Tile &t,bool large,int content_w,int co
lv_obj_set_style_pad_all(slider,0,LV_PART_MAIN);
lv_obj_set_style_bg_opa(slider,LV_OPA_COVER,LV_PART_MAIN);lv_obj_set_style_bg_opa(slider,LV_OPA_COVER,LV_PART_INDICATOR);
// The handle is a short white bar inside the fill, like Home Assistant's slider.
- lv_obj_set_style_radius(slider,2,LV_PART_KNOB);lv_obj_set_style_bg_color(slider,lv_color_hex(0xFFFFFF),LV_PART_KNOB);lv_obj_set_style_bg_opa(slider,LV_OPA_COVER,LV_PART_KNOB);
+ lv_obj_set_style_radius(slider,2,LV_PART_KNOB);lv_obj_add_style(slider,theme::style(theme::Paint::knob),LV_PART_KNOB);lv_obj_set_style_bg_opa(slider,LV_OPA_COVER,LV_PART_KNOB);
slider_handle(slider,mode=="volume"?m.slider_w:m.pill_w,h);
lv_obj_set_style_border_width(slider,0,LV_PART_KNOB);lv_obj_set_style_shadow_width(slider,0,LV_PART_KNOB);
lv_obj_set_ext_click_area(slider,m.ext+2);
@@ -2205,7 +2224,7 @@ inline int layout_panel(Widgets &w,const Tile &t,bool large,int content_w,int co
}else if(mode=="toggle"){
panel_key(w,0,w.panel,m,m.toggle_w,m.toggle_h,false);lv_obj_set_style_radius(w.keys[0],LV_RADIUS_CIRCLE,0);
w.knob=panel_obj(w.keys[0],false);lv_obj_set_size(w.knob,m.toggle_h-8,m.toggle_h-8);lv_obj_set_y(w.knob,4);
- lv_obj_set_style_radius(w.knob,LV_RADIUS_CIRCLE,0);lv_obj_set_style_bg_opa(w.knob,LV_OPA_COVER,0);lv_obj_set_style_bg_color(w.knob,lv_color_hex(0xFFFFFF),0);
+ lv_obj_set_style_radius(w.knob,LV_RADIUS_CIRCLE,0);lv_obj_set_style_bg_opa(w.knob,LV_OPA_COVER,0);lv_obj_add_style(w.knob,theme::style(theme::Paint::knob),0);
w.key_commands[0]=tile_controls::TOGGLE;
}else if(mode=="run"){
const char *text=tile_controls::run_label(d);
@@ -2220,7 +2239,7 @@ inline int layout_panel(Widgets &w,const Tile &t,bool large,int content_w,int co
auto set_checked=[&](unsigned n,bool checked){
if(w.key_checked[n]==(int)checked)return;w.key_checked[n]=checked;
if(checked)lv_obj_add_state(w.keys[n],LV_STATE_CHECKED);else lv_obj_remove_state(w.keys[n],LV_STATE_CHECKED);
- if(w.key_icons[n])lv_obj_set_style_text_color(w.key_icons[n],checked?lv_color_hex(0xFFFFFF):w.panel_text,0);
+ if(w.key_icons[n])lv_obj_set_style_text_color(w.key_icons[n],checked?theme::color(theme::ON_ACCENT):w.panel_text,0);
};
auto set_disabled=[&](unsigned n,bool disabled){if(disabled)lv_obj_add_state(w.keys[n],LV_STATE_DISABLED);else lv_obj_remove_state(w.keys[n],LV_STATE_DISABLED);};
if(tile_controls::is_key_row(mode)){
@@ -2230,7 +2249,7 @@ inline int layout_panel(Widgets &w,const Tile &t,bool large,int content_w,int co
lv_obj_remove_flag(w.keys[n],LV_OBJ_FLAG_HIDDEN);lv_obj_set_pos(w.keys[n],n*(m.key_w+m.gap),0);
label(w.key_icons[n],keys[n].icon);w.key_commands[n]=keys[n].command;w.key_args[n]=keys[n].arg;
// The active mode key carries the accent; "off" stays neutral grey.
- if(keys[n].checked && w.key_checked[n]!=1)lv_obj_set_style_bg_color(w.keys[n],keys[n].arg=="off"?lv_color_hex(0x9E9E9E):w.panel_accent,LV_STATE_CHECKED);
+ if(keys[n].checked && w.key_checked[n]!=1)lv_obj_set_style_bg_color(w.keys[n],keys[n].arg=="off"?theme::color(theme::OFF):w.panel_accent,LV_STATE_CHECKED);
set_checked(n,keys[n].checked);set_disabled(n,keys[n].disabled);
}
panel_w=count?count*m.key_w+(count-1)*m.gap:0;
@@ -2277,28 +2296,29 @@ inline int layout_panel(Widgets &w,const Tile &t,bool large,int content_w,int co
// Colours follow the card palette; called with the rest of the palette when it changes.
inline void style_panel(Widgets &w,const Tile &t,lv_color_t accent,lv_color_t text) {
if(!w.panel || w.panel_mode.empty())return;
- lv_color_t card=lv_color_hex(t.background?t.background:0xFFFFFF);
- lv_color_t key_bg=lv_color_mix(card,lv_color_hex(0x000000),236);
- lv_color_t key_pressed=lv_color_mix(card,lv_color_hex(0x000000),212);
+ const uint32_t surface=theme::surface(t.background);
+ lv_color_t card=lv_color_hex(surface);
+ lv_color_t key_bg=lv_color_hex(theme::key_on(surface,236));
+ lv_color_t key_pressed=lv_color_hex(theme::key_on(surface,212));
w.panel_accent=accent;w.panel_text=text;
for(unsigned n=0;n<3;++n){
auto *key=w.keys[n];if(!key)continue;
bool in_pill=w.pill && lv_obj_get_parent(key)==w.pill;
set_color(key,LV_STYLE_BG_COLOR,in_pill?key_pressed:key_bg);
- set_color(key,LV_STYLE_BG_COLOR,in_pill?lv_color_mix(card,lv_color_hex(0x000000),190):key_pressed,LV_STATE_PRESSED);
- set_color(key,LV_STYLE_BG_COLOR,w.key_args[n]=="off" && w.panel_mode=="mode"?lv_color_hex(0x9E9E9E):accent,LV_STATE_CHECKED);
- if(w.key_icons[n])set_color(w.key_icons[n],LV_STYLE_TEXT_COLOR,w.key_checked[n]==1?lv_color_hex(0xFFFFFF):text);
+ set_color(key,LV_STYLE_BG_COLOR,in_pill?lv_color_hex(theme::key_on(surface,190)):key_pressed,LV_STATE_PRESSED);
+ set_color(key,LV_STYLE_BG_COLOR,w.key_args[n]=="off" && w.panel_mode=="mode"?theme::color(theme::OFF):accent,LV_STATE_CHECKED);
+ if(w.key_icons[n])set_color(w.key_icons[n],LV_STYLE_TEXT_COLOR,w.key_checked[n]==1?theme::color(theme::ON_ACCENT):text);
}
if(w.pill){set_color(w.pill,LV_STYLE_BG_COLOR,key_bg);set_color(w.pill_value,LV_STYLE_TEXT_COLOR,text);}
if(w.control_slider){
bool on=fresh() && t.slider_active();
- lv_color_t fill=on?accent:lv_color_hex(0x9E9E9E);
+ lv_color_t fill=on?accent:theme::color(theme::OFF);
// The track is the fill colour at 20 % over the card, as in Home Assistant.
set_color(w.control_slider,LV_STYLE_BG_COLOR,lv_color_mix(fill,card,51),LV_PART_MAIN);
set_color(w.control_slider,LV_STYLE_BG_COLOR,fill,LV_PART_INDICATOR);
slider_bar(w.control_slider,slider_bar_shown(t,on));
}
- if(w.knob){set_color(w.keys[0],LV_STYLE_BG_COLOR,lv_color_hex(0xD7DADF));set_color(w.keys[0],LV_STYLE_BG_COLOR,lv_color_hex(0xC5C9CF),LV_STATE_PRESSED);}
+ if(w.knob){set_color(w.keys[0],LV_STYLE_BG_COLOR,theme::color(theme::PANEL_TOGGLE_OFF));set_color(w.keys[0],LV_STYLE_BG_COLOR,theme::color(theme::PANEL_TOGGLE_OFF_PRESSED),LV_STATE_PRESSED);}
}
inline void control_event(lv_event_t *e) {
unsigned code=(uintptr_t)lv_event_get_user_data(e);unsigned slot=code/16,n=code%16;
@@ -2334,13 +2354,13 @@ inline void set_busy(Widgets &w,bool busy,bool large){
if(!busy){if(w.busy)lv_obj_add_flag(w.busy,LV_OBJ_FLAG_HIDDEN);return;}
if(!w.busy){
w.busy=lv_obj_create(w.tile);lv_obj_remove_style_all(w.busy);lv_obj_remove_flag(w.busy,LV_OBJ_FLAG_SCROLLABLE);lv_obj_add_flag(w.busy,LV_OBJ_FLAG_CLICKABLE);
- lv_obj_set_style_bg_color(w.busy,lv_color_hex(0xFFFFFF),0);lv_obj_set_style_bg_opa(w.busy,LV_OPA_60,0);
+ lv_obj_add_style(w.busy,theme::style(theme::Paint::veil),0);lv_obj_set_style_bg_opa(w.busy,LV_OPA_60,0);
lv_obj_set_style_radius(w.busy,lv_obj_get_style_radius(w.tile,LV_PART_MAIN),0);
#if LV_USE_SPINNER
w.spinner=lv_spinner_create(w.busy);lv_spinner_set_anim_params(w.spinner,900,200);
int size=large?30:20;lv_obj_set_size(w.spinner,size,size);lv_obj_center(w.spinner);lv_obj_remove_flag(w.spinner,LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_arc_width(w.spinner,large?4:3,LV_PART_MAIN);lv_obj_set_style_arc_width(w.spinner,large?4:3,LV_PART_INDICATOR);
- lv_obj_set_style_arc_color(w.spinner,lv_color_hex(0xD9DDE2),LV_PART_MAIN);lv_obj_set_style_arc_color(w.spinner,lv_color_hex(0x1E88E5),LV_PART_INDICATOR);
+ lv_obj_add_style(w.spinner,theme::style(theme::Paint::spinner),LV_PART_MAIN);lv_obj_set_style_arc_color(w.spinner,lv_color_hex(theme::ha::RAIN),LV_PART_INDICATOR);
lv_obj_set_style_bg_opa(w.spinner,LV_OPA_TRANSP,LV_PART_KNOB);lv_obj_set_style_pad_all(w.spinner,0,LV_PART_KNOB);
#endif
}
@@ -2475,41 +2495,44 @@ inline void render_slot(size_t slot) {
w.cached_active = palette_state;w.panel_dirty=false;
uint32_t accent = domain_accent(t);
// Off is grey, as in Home Assistant, so a light or a door sensor shows its state at a glance.
- auto color=lv_color_hex((t.is_switch()||d=="light"||d=="binary_sensor"||d=="person"||d=="timer") && !on ? 0x9E9E9E : accent);
+ uint32_t state_color=(t.is_switch()||d=="light"||d=="binary_sensor"||d=="person"||d=="timer") && !on ? theme::STATE_OFF : accent;
if(d=="light" && on && t.has_hs_color)
- color=lv_color_hsv_to_rgb(t.hue%360,t.saturation,100);
- auto circle_color=available?lv_color_mix(color,lv_color_hex(0xFFFFFF),38):lv_color_hex(0xF0F0F0);
- // Darken the foreground slightly: very pale bulbs still need a visible icon.
- auto icon_color=available?lv_color_mix(color,lv_color_hex(0x333333),205):lv_color_hex(0x9E9E9E);
+ state_color=lv_color_to_u32(lv_color_hsv_to_rgb(t.hue%360,t.saturation,100))&0xFFFFFF;
+ auto color=lv_color_hex(theme::state(state_color));
+ auto circle_color=lv_color_hex(available?theme::tint(state_color,38):theme::hex(theme::TRACK));
+ // Very pale bulbs still need a visible icon: a touch darker on a light card, a touch lighter on a dark one.
+ auto icon_color=lv_color_hex(available?theme::icon(state_color):theme::hex(theme::OFF));
// Off: a grey track without fill or handle, as in Home Assistant.
- auto fill_color=slider_on?color:lv_color_hex(0x9E9E9E);
+ const uint32_t fill=slider_on?state_color:theme::STATE_OFF;
+ auto fill_color=lv_color_hex(theme::state(fill));
set_color(w.slider,LV_STYLE_BG_COLOR,fill_color,LV_PART_INDICATOR);
- set_color(w.slider,LV_STYLE_BG_COLOR,lv_color_mix(fill_color,lv_color_hex(0xFFFFFF),51),LV_PART_MAIN);
+ set_color(w.slider,LV_STYLE_BG_COLOR,lv_color_hex(theme::tint(fill,51)),LV_PART_MAIN);
slider_bar(w.slider,slider_bar_shown(t,slider_on));
- set_color(w.tile,LV_STYLE_BG_COLOR,lv_color_hex(t.background ? t.background : 0xFFFFFF));
+ set_color(w.tile,LV_STYLE_BG_COLOR,lv_color_hex(theme::surface(t.background)));
set_number(w.tile,LV_STYLE_BORDER_WIDTH,1);
// "Background: none" hides only the card; geometry and padding stay identical,
// and the pressed flash still shows because it lives on the PRESSED state.
set_number(w.tile,LV_STYLE_BG_OPA,t.transparent ? LV_OPA_TRANSP : LV_OPA_COVER);
set_number(w.tile,LV_STYLE_BORDER_OPA,t.transparent ? LV_OPA_TRANSP : LV_OPA_COVER);
- set_color(w.tile,LV_STYLE_BORDER_COLOR,t.background ? lv_color_mix(lv_color_hex(t.background),lv_color_hex(0x000000),220) : lv_color_hex(0xDDDDDD));
+ set_color(w.tile,LV_STYLE_BORDER_COLOR,lv_color_hex(theme::outline(t.background)));
set_color(w.circle,LV_STYLE_BG_COLOR,circle_color);
set_color(w.icon,LV_STYLE_TEXT_COLOR,icon_color);
- auto title_color=lv_color_hex(0x1B1B1B);
- auto value_color=lv_color_hex(t.background ? 0x46525E : 0x616161);
- set_color(w.unit,LV_STYLE_TEXT_COLOR,lv_color_hex(0x46525E));
+ auto title_color=theme::color(theme::INK);
+ auto value_color=theme::color(t.background ? theme::SLATE : theme::MUTED);
+ set_color(w.unit,LV_STYLE_TEXT_COLOR,theme::color(theme::SLATE));
set_color(w.title,LV_STYLE_TEXT_COLOR,title_color);
set_color(w.value,LV_STYLE_TEXT_COLOR,value_color);
style_panel(w,t,color,title_color);
// Custom parts follow the card palette: text like the title, lines/dots in the accent.
- // The sun path sets its own colours on every render.
- w.fill_color=color;
+ // The sun path sets its own colours on every render, the sunlit area under its arc too: taking the
+ // accent here made that area orange after a palette change and yellow again after the next minute.
+ if(w.extra_mode!="sunpath")w.fill_color=color;
for(unsigned i=0;i=2 && i%3==2 : w.extra_mode=="sunpath" ? i>=1 : w.extra_mode=="calendar" ? i==15||i==17 : i==16;
if(lv_obj_check_type(p,&lv_label_class))set_color(p,LV_STYLE_TEXT_COLOR,muted?value_color:title_color);
else if(w.extra_mode=="sunpath")continue;
- else if(lv_obj_check_type(p,&lv_line_class))set_color(p,LV_STYLE_LINE_COLOR,w.extra_mode=="graph"?color:i==18?lv_color_hex(0xE53935):i<12?value_color:i==13?icon_color:title_color);
+ else if(lv_obj_check_type(p,&lv_line_class))set_color(p,LV_STYLE_LINE_COLOR,w.extra_mode=="graph"?color:i==18?lv_color_hex(theme::foreground(theme::ha::ALARM)):i<12?value_color:i==13?icon_color:title_color);
else set_color(p,LV_STYLE_BG_COLOR,i==14?icon_color:value_color);
}
lap(swipe_profile::PALETTE);
@@ -2553,13 +2576,14 @@ inline void render(lv_obj_t *room) {
// Drawn in the board's pixels by the editor's rules (app.js barLayout): every value, the time too,
// in one font on the name's baseline; icons and the dial centred on the height of the digits; the
// gaps measured between glyph ink, so every icon sits equally close to its value.
-struct HeaderSlot { lv_obj_t *icon{}, *text{}; uint32_t icon_color = 0; };
+// An icon either shows the slate paint or a colour of its own (the item's state colour); `own` and `icon_color`
+// remember which, so an unchanged icon is not styled again.
+struct HeaderSlot { lv_obj_t *icon{}, *text{}; uint32_t icon_color = 0; bool own = false; };
inline lv_obj_t *header_root = nullptr, *header_ring = nullptr;
inline std::array header_hands{};
inline std::array header_slots{};
inline lv_point_precise_t header_points[4]{};
inline int header_dial_key = -1;
-constexpr uint32_t HEADER_INK = 0x46525E;
inline void set_visible(lv_obj_t *obj, bool visible) {
if (lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN) != visible) return;
if (visible) lv_obj_remove_flag(obj, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN);
@@ -2585,7 +2609,7 @@ inline lv_obj_t *header_part(lv_obj_t *parent) {
auto *part = lv_label_create(parent);
lv_obj_remove_flag(part, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_flag(part, LV_OBJ_FLAG_HIDDEN);
- lv_obj_set_style_text_color(part, lv_color_hex(HEADER_INK), 0);
+ lv_obj_add_style(part, theme::style(theme::Paint::slate), 0);
return part;
}
// `live`: Home Assistant's values may show; without its link or the manager's feed only clocks stay.
@@ -2605,21 +2629,20 @@ inline void draw_header(bool live) {
slot.text = header_part(header_root);
lv_obj_set_style_text_font(slot.icon, header_icon_font, 0);
lv_obj_set_style_text_font(slot.text, header_text_font, 0);
- slot.icon_color = HEADER_INK;
}
header_ring = lv_obj_create(header_root);
lv_obj_remove_style_all(header_ring);
lv_obj_remove_flag(header_ring, LV_OBJ_FLAG_CLICKABLE);
lv_obj_remove_flag(header_ring, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_radius(header_ring, LV_RADIUS_CIRCLE, 0);
- lv_obj_set_style_border_color(header_ring, lv_color_hex(HEADER_INK), 0);
+ lv_obj_add_style(header_ring, theme::style(theme::Paint::slate), 0);
lv_obj_set_style_border_opa(header_ring, LV_OPA_COVER, 0);
lv_obj_add_flag(header_ring, LV_OBJ_FLAG_HIDDEN);
for (auto *&hand : header_hands) {
hand = lv_line_create(header_root);
lv_obj_remove_flag(hand, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_line_rounded(hand, true, 0);
- lv_obj_set_style_line_color(hand, lv_color_hex(HEADER_INK), 0);
+ lv_obj_add_style(hand, theme::style(theme::Paint::slate), 0);
lv_obj_add_flag(hand, LV_OBJ_FLAG_HIDDEN);
}
// A long name ends in dots instead of running under the items.
@@ -2714,8 +2737,14 @@ inline void draw_header(bool live) {
int ink_top = (middle2 - g.box_h) / 2;
lv_obj_set_pos(slot.icon, x - g.ofs_x, ink_top - ((header_icon_font->line_height - header_icon_font->base_line) - g.box_h - g.ofs_y));
const auto &item = bar.items[p.item];
- uint32_t color = item.has_color ? item.color : HEADER_INK;
- if (slot.icon_color != color) { lv_obj_set_style_text_color(slot.icon, lv_color_hex(color), 0); slot.icon_color = color; }
+ // The words, icons and dial of the top bar take the slate paint; an item's own colour sits on top of it.
+ const uint32_t color = item.has_color ? theme::foreground(item.color) : 0;
+ if (slot.own != item.has_color || slot.icon_color != color) {
+ if (item.has_color) lv_obj_set_style_text_color(slot.icon, lv_color_hex(color), 0);
+ else lv_obj_remove_local_style_prop(slot.icon, LV_STYLE_TEXT_COLOR, 0);
+ slot.own = item.has_color;
+ slot.icon_color = color;
+ }
icon_on[k] = true;
x += p.icon_w + (p.text_w ? gaps.icon : 0);
}
@@ -2807,8 +2836,8 @@ inline bool check_tile_geometry() {
}
if(!fits)ESP_LOGE("ui_test","Tile geometry FAIL slot=%u mode=%s wide=%d title_y=%d..%d value_y=%d..%d content_y=%d..%d",(unsigned)w.index,w.extra_mode.c_str(),w.wide,title.y1,title.y2,value.y1,value.y2,content.y1,content.y2);
if(w.index=LV_OPA_MAX)return lv_obj_get_style_bg_color(o,LV_PART_MAIN);
- return lv_color_hex(0xE7E7E7);
+ return theme::color(theme::PAGE);
}
inline void skeleton(Widgets &w) {
for(auto *o:{w.title,w.value,w.circle,w.unit,w.slider,w.extra,w.panel,w.busy})if(o)lv_obj_add_flag(o,LV_OBJ_FLAG_HIDDEN);
@@ -2874,7 +2903,7 @@ inline void skeleton(Widgets &w) {
lv_obj_set_style_bg_opa(w.veil,LV_OPA_COVER,0);
}
const Tile *t=w.indextransparent?page_color(w):lv_color_hex(t && t->background?t->background:0xFFFFFF));
+ set_color(w.veil,LV_STYLE_BG_COLOR,t && t->transparent?page_color(w):lv_color_hex(theme::surface(t?t->background:0)));
lv_obj_set_pos(w.veil,0,0);
lv_obj_set_size(w.veil,content_width(w),content_height(w));
lv_obj_remove_flag(w.veil,LV_OBJ_FLAG_HIDDEN);
@@ -3040,6 +3069,14 @@ inline void tick() {
}
if(redraw && refresh)refresh();
}
+// A change of look (Dark mode). The paints follow by themselves (theme::set_dark); what the tiles, the top bar and an
+// open card painted in code is drawn again here, in the same pass, so no frame shows half of each look.
+inline void restyle() {
+ for (auto &w : widgets) { w.cached_active = -1; w.panel_dirty = true; }
+ for (auto &slot : header_slots) { slot.own = true; slot.icon_color = UINT32_MAX; }
+ if (room_label) { dirty_all = true; render(room_label); }
+ if (detail_root && !lv_obj_has_flag(detail_root, LV_OBJ_FLAG_HIDDEN) && detail_index < model.count) show_detail(detail_index);
+}
inline std::string vacuum_option(unsigned index) {
if (active_index < 0 || static_cast(active_index) >= model.count) return {};
auto &tile = model.tiles[active_index];
diff --git a/components/smart_display/settings_screen.h b/components/smart_display/settings_screen.h
index 3e7af0e..edb2b20 100644
--- a/components/smart_display/settings_screen.h
+++ b/components/smart_display/settings_screen.h
@@ -20,8 +20,9 @@ namespace settings_screen {
// Values that are not part of the persisted `screen_settings::Settings` block, because that format is
// frozen at version 1: each of these keeps its own preference, so a new option never rewrites the
-// old ones. The manager sends them as their own keys in the layout message.
-inline int32_t swipe_pages = 0, rotation = 0, auto_home = 1, auto_home_seconds = 120;
+// old ones. The manager sends the older ones as their own keys in the layout message; dark_mode (firmware
+// 0.2.54+) only exists as the screen's own setting and entity.
+inline int32_t swipe_pages = 0, rotation = 0, auto_home = 1, auto_home_seconds = 120, dark_mode = 0;
inline bool rotation_supported = false;
// ------------------------------------------------------------------ the table
@@ -102,7 +103,7 @@ inline SetResult set(const std::string &key, int32_t value) {
auto &s = screen_settings::current;
const auto before = s;
const int32_t before_swipe = swipe_pages, before_rotation = rotation, before_home = auto_home,
- before_home_seconds = auto_home_seconds;
+ before_home_seconds = auto_home_seconds, before_dark = dark_mode;
int32_t reported = 0;
auto flag = [](int32_t v) -> int32_t { return v ? 1 : 0; };
if (key == "standby_enabled") reported = s.standby_enabled = flag(value);
@@ -122,9 +123,10 @@ inline SetResult set(const std::string &key, int32_t value) {
else if (key == "rotation" && rotation_supported) reported = rotation = std::clamp(value, 0, 270) / 90 * 90;
else if (key == "auto_home") reported = auto_home = flag(value);
else if (key == "auto_home_seconds") reported = auto_home_seconds = std::clamp(value, 30, 3600);
+ else if (key == "dark_mode") reported = dark_mode = flag(value);
else return SetResult::unknown;
if (s == before && swipe_pages == before_swipe && rotation == before_rotation && auto_home == before_home &&
- auto_home_seconds == before_home_seconds)
+ auto_home_seconds == before_home_seconds && dark_mode == before_dark)
return SetResult::same;
changed(key.c_str(), reported);
return SetResult::changed;
@@ -158,6 +160,11 @@ inline bool at_end(const Row &row, int32_t value, int direction) {
if (row.kind == Kind::moment) return false;
return stepped(row, value, direction) == value;
}
+// Where the knob of a w by h switch sits: at the left when off, at the right when on, inset all round.
+inline int knob_x(int w, int h, bool on) {
+ int inset = std::max(2, h / 10), size = h - 2 * inset;
+ return on ? w - inset - size : inset;
+}
inline std::string duration_text(int32_t seconds) {
char buffer[24];
@@ -217,13 +224,15 @@ inline uint8_t fitting_rows(int span, int row_height, int gap, int pager, uint8_
inline constexpr const char *clock_options[] = {"12 hour", "24 hour"};
inline constexpr const char *rotation_options[] = {"0°", "90°", "180°", "270°"};
-// Brightness, and when the screen dims by itself.
+// Brightness, the dark look for a screen beside a bed, and when the screen dims by itself.
// Every reader says `-> int32_t` out loud: on the ESP32 that is `long`, and a lambda that returns a
// plain `int` (a ternary, say) then does not convert to Read at all. The Mac's host build accepts it,
// the board does not.
inline constexpr Row light_rows[] = {
number("Brightness", []() -> int32_t { return screen_settings::current.brightness; },
[](int32_t value) { set("brightness", value); }, 5, 100, 5, "%"),
+ toggle("Dark mode", []() -> int32_t { return dark_mode; },
+ [](int32_t value) { set("dark_mode", value); }),
toggle("Auto standby", []() -> int32_t { return screen_settings::current.standby_enabled; },
[](int32_t value) { set("standby_enabled", value); }),
duration("Standby after", []() -> int32_t { return screen_settings::current.standby_seconds; },
@@ -301,6 +310,7 @@ constexpr uint8_t PAGE_COUNT = (uint8_t) std::size(pages);
#ifndef SETTINGS_SCREEN_TEST
#include "lvgl.h"
+#include "theme.h"
#include
namespace settings_screen {
@@ -358,14 +368,14 @@ inline lv_obj_t *plain(lv_obj_t *parent, int x, int y, int w, int h) {
lv_obj_set_size(obj, w, h);
return obj;
}
-inline lv_obj_t *text(lv_obj_t *parent, const std::string &value, const lv_font_t *font, uint32_t color,
+inline lv_obj_t *text(lv_obj_t *parent, const std::string &value, const lv_font_t *font, theme::Role color,
lv_text_align_t align = LV_TEXT_ALIGN_LEFT) {
auto *label = lv_label_create(parent);
lv_label_set_text(label, value.c_str());
lv_label_set_long_mode(label, LV_LABEL_LONG_DOT);
lv_obj_remove_flag(label, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_text_font(label, font, 0);
- lv_obj_set_style_text_color(label, lv_color_hex(color), 0);
+ lv_obj_set_style_text_color(label, theme::color(color), 0);
lv_obj_set_style_text_align(label, align, 0);
lv_obj_set_height(label, lv_font_get_line_height(font));
return label;
@@ -379,11 +389,11 @@ inline lv_obj_t *key(lv_obj_t *parent, const char *glyph, int x, int y, int w, i
auto *button = plain(parent, x, y, w, h);
lv_obj_add_flag(button, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_bg_opa(button, LV_OPA_COVER, 0);
- lv_obj_set_style_bg_color(button, lv_color_hex(0xE9EFF4), 0);
- lv_obj_set_style_bg_color(button, lv_color_hex(0xC3D5E3), LV_STATE_PRESSED);
+ lv_obj_set_style_bg_color(button, theme::color(theme::SETTING_KEY), 0);
+ lv_obj_set_style_bg_color(button, theme::color(theme::SETTING_KEY_PRESSED), LV_STATE_PRESSED);
lv_obj_set_style_radius(button, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_opa(button, LV_OPA_40, LV_STATE_DISABLED);
- auto *label = text(button, glyph, icon_font ? icon_font : row_font, 0x1B1B1B);
+ auto *label = text(button, glyph, icon_font ? icon_font : row_font, theme::INK);
lv_obj_set_width(label, LV_SIZE_CONTENT);
lv_obj_center(label);
lv_obj_add_event_cb(button, handler, LV_EVENT_SHORT_CLICKED, data);
@@ -395,12 +405,12 @@ inline lv_obj_t *key(lv_obj_t *parent, const char *glyph, int x, int y, int w, i
inline lv_obj_t *pill(lv_obj_t *parent, int x, int y, int w, int h, bool on) {
auto *track = plain(parent, x, y, w, h);
lv_obj_set_style_bg_opa(track, LV_OPA_COVER, 0);
- lv_obj_set_style_bg_color(track, lv_color_hex(on ? 0x009FE3 : 0xC4C4C4), 0);
+ lv_obj_set_style_bg_color(track, theme::color(on ? theme::ACCENT : theme::TOGGLE_OFF), 0);
lv_obj_set_style_radius(track, LV_RADIUS_CIRCLE, 0);
int inset = std::max(2, h / 10), size = h - 2 * inset;
- auto *knob = plain(track, on ? w - inset - size : inset, inset, size, size);
+ auto *knob = plain(track, knob_x(w, h, on), inset, size, size);
lv_obj_set_style_bg_opa(knob, LV_OPA_COVER, 0);
- lv_obj_set_style_bg_color(knob, lv_color_hex(0xFFFFFF), 0);
+ lv_obj_set_style_bg_color(knob, theme::color(theme::KNOB), 0);
lv_obj_set_style_radius(knob, LV_RADIUS_CIRCLE, 0);
return knob;
}
@@ -408,11 +418,12 @@ inline void move_knob(Drawn &d, const Row &row) {
if (!d.knob) return;
auto *track = lv_obj_get_parent(d.knob);
bool on = row.read && row.read();
- int w = lv_obj_get_width(track), size = lv_obj_get_width(d.knob), inset = (lv_obj_get_height(track) - size) / 2;
// Setting a style always repaints, so only a real change sets it; lv_obj_set_x checks by itself.
- const lv_color_t color = lv_color_hex(on ? 0x009FE3 : 0xC4C4C4);
+ const lv_color_t color = theme::color(on ? theme::ACCENT : theme::TOGGLE_OFF);
if (!lv_color_eq(lv_obj_get_style_bg_color(track, LV_PART_MAIN), color)) lv_obj_set_style_bg_color(track, color, 0);
- lv_obj_set_x(d.knob, on ? w - inset - size : inset);
+ // The size pill() gave the track, not its coordinates: draw() ends in refresh() before LVGL has laid the
+ // new page out, when every width still reads 0 and a switch that is on would show its knob at the left.
+ lv_obj_set_x(d.knob, knob_x(lv_obj_get_style_width(track, LV_PART_MAIN), lv_obj_get_style_height(track, LV_PART_MAIN), on));
}
// After any change: every row on this page tells its own value again. Cheap (eight labels at most) and
@@ -540,15 +551,15 @@ inline void draw() {
auto *back = plain(root, m.pad, m.bar_y, m.bar, m.bar);
lv_obj_add_flag(back, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_bg_opa(back, LV_OPA_COVER, 0);
- lv_obj_set_style_bg_color(back, lv_color_hex(0xEEEEEE), 0);
- lv_obj_set_style_bg_color(back, lv_color_hex(0xDADADA), LV_STATE_PRESSED);
+ lv_obj_set_style_bg_color(back, theme::color(theme::KEY), 0);
+ lv_obj_set_style_bg_color(back, theme::color(theme::KEY_PRESSED), LV_STATE_PRESSED);
lv_obj_set_style_radius(back, LV_RADIUS_CIRCLE, 0);
- auto *arrow = text(back, "\U000F004D", icon_font ? icon_font : row_font, 0x1B1B1B);
+ auto *arrow = text(back, "\U000F004D", icon_font ? icon_font : row_font, theme::INK);
lv_obj_set_width(arrow, LV_SIZE_CONTENT);
lv_obj_center(arrow);
lv_obj_add_event_cb(back, back_event, LV_EVENT_SHORT_CLICKED, nullptr);
const lv_font_t *heading = title_font ? title_font : row_font;
- auto *title = text(root, page.title, heading, 0x1B1B1B, LV_TEXT_ALIGN_CENTER);
+ auto *title = text(root, page.title, heading, theme::INK, LV_TEXT_ALIGN_CENTER);
lv_obj_set_width(title, m.width - 2 * (m.pad + m.bar + 8));
lv_obj_set_pos(title, m.pad + m.bar + 8, m.bar_y + (m.bar - lv_font_get_line_height(heading)) / 2);
@@ -571,30 +582,30 @@ inline void draw() {
d.card = plain(root, m.pad, m.rows_y + slot * (m.row_h + m.gap), m.width - 2 * m.pad, m.row_h);
lv_obj_set_style_bg_opa(d.card, LV_OPA_COVER, 0);
- lv_obj_set_style_bg_color(d.card, lv_color_hex(asking ? 0x009FE3 : 0xFFFFFF), 0);
+ lv_obj_set_style_bg_color(d.card, theme::color(asking ? theme::ACCENT : theme::CARD), 0);
lv_obj_set_style_radius(d.card, m.radius, 0);
lv_obj_set_style_border_width(d.card, 1, 0);
- lv_obj_set_style_border_color(d.card, lv_color_hex(asking ? 0x009FE3 : 0xDDDDDD), 0);
+ lv_obj_set_style_border_color(d.card, theme::color(asking ? theme::ACCENT : theme::LINE), 0);
if (tappable) {
lv_obj_add_flag(d.card, LV_OBJ_FLAG_CLICKABLE);
- lv_obj_set_style_bg_color(d.card, lv_color_hex(asking ? 0x0083BC : 0xF1F1F1), LV_STATE_PRESSED);
+ lv_obj_set_style_bg_color(d.card, theme::color(asking ? theme::ACCENT_PRESSED : theme::CARD_PRESSED), LV_STATE_PRESSED);
lv_obj_add_event_cb(d.card, row_event, LV_EVENT_SHORT_CLICKED, (void *) (intptr_t) index);
}
int left = m.inset;
if (row.icon && *row.icon) {
- auto *glyph = text(d.card, row.icon, icon_font ? icon_font : row_font, asking ? 0xFFFFFF : 0x4A4A4A);
+ auto *glyph = text(d.card, row.icon, icon_font ? icon_font : row_font, asking ? theme::ON_ACCENT : theme::ROW_ICON);
int icon_h = lv_font_get_line_height(icon_font ? icon_font : row_font);
lv_obj_set_width(glyph, LV_SIZE_CONTENT);
lv_obj_set_pos(glyph, left, (m.row_h - icon_h) / 2);
left += icon_h + (m.large ? 12 : 8);
}
- d.label = text(d.card, asking ? "Tap again to restart" : row.label, row_font, asking ? 0xFFFFFF : 0x1B1B1B);
+ d.label = text(d.card, asking ? "Tap again to restart" : row.label, row_font, asking ? theme::ON_ACCENT : theme::INK);
lv_obj_set_pos(d.label, left, (m.row_h - label_h) / 2);
int right = m.width - 2 * m.pad - m.inset; // free space from the right edge of the card
if (row.kind == Kind::page) {
- auto *chevron = text(d.card, "\U000F0142", icon_font ? icon_font : row_font, 0xB0B0B0);
+ auto *chevron = text(d.card, "\U000F0142", icon_font ? icon_font : row_font, theme::CHEVRON);
int icon_h = lv_font_get_line_height(icon_font ? icon_font : row_font);
lv_obj_set_width(chevron, LV_SIZE_CONTENT);
lv_obj_set_pos(chevron, right - icon_h, (m.row_h - icon_h) / 2);
@@ -608,7 +619,7 @@ inline void draw() {
(void *) (intptr_t) (index * 2 + 1));
d.minus = key(d.card, "\U000F0374", right - m.pill_w - value_w - m.pill_w, y, m.pill_w, m.pill_h, step_event,
(void *) (intptr_t) (index * 2));
- d.value = text(d.card, value_text(row), row_font, 0x1B1B1B, LV_TEXT_ALIGN_CENTER);
+ d.value = text(d.card, value_text(row), row_font, theme::INK, LV_TEXT_ALIGN_CENTER);
lv_obj_set_width(d.value, value_w);
lv_obj_set_pos(d.value, right - m.pill_w - value_w, (m.row_h - label_h) / 2);
lv_obj_set_width(d.label, right - 2 * m.pill_w - value_w - left - 6);
@@ -616,14 +627,14 @@ inline void draw() {
int value_w = m.large ? 150 : 96, chip_h = m.pill_h;
auto *chip = plain(d.card, right - value_w, (m.row_h - chip_h) / 2, value_w, chip_h);
lv_obj_set_style_bg_opa(chip, LV_OPA_COVER, 0);
- lv_obj_set_style_bg_color(chip, lv_color_hex(0xE9EFF4), 0);
+ lv_obj_set_style_bg_color(chip, theme::color(theme::SETTING_KEY), 0);
lv_obj_set_style_radius(chip, LV_RADIUS_CIRCLE, 0);
- d.value = text(chip, value_text(row), row_font, 0x1B1B1B, LV_TEXT_ALIGN_CENTER);
+ d.value = text(chip, value_text(row), row_font, theme::INK, LV_TEXT_ALIGN_CENTER);
lv_obj_set_width(d.value, value_w - 8);
lv_obj_set_pos(d.value, 4, (chip_h - label_h) / 2);
lv_obj_set_width(d.label, right - value_w - left - 6);
} else if (row.kind == Kind::info) {
- d.value = text(d.card, value_text(row), row_font, 0x616161, LV_TEXT_ALIGN_RIGHT);
+ d.value = text(d.card, value_text(row), row_font, theme::MUTED, LV_TEXT_ALIGN_RIGHT);
int value_w = (m.width - 2 * m.pad) / 2;
lv_obj_set_width(d.value, value_w);
lv_obj_set_pos(d.value, right - value_w, (m.row_h - label_h) / 2);
@@ -647,16 +658,16 @@ inline void draw() {
lv_obj_add_flag(bar, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_bg_opa(bar, LV_OPA_TRANSP, 0);
lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, LV_STATE_PRESSED);
- lv_obj_set_style_bg_color(bar, lv_color_hex(0xDCDCDC), LV_STATE_PRESSED);
+ lv_obj_set_style_bg_color(bar, theme::color(theme::KEY_PRESSED), LV_STATE_PRESSED);
lv_obj_set_style_radius(bar, m.radius, 0);
- auto *label = text(bar, side ? "Next >" : "< Previous", row_font, 0x1B1B1B,
+ auto *label = text(bar, side ? "Next >" : "< Previous", row_font, theme::INK,
side ? LV_TEXT_ALIGN_RIGHT : LV_TEXT_ALIGN_LEFT);
lv_obj_set_width(label, half - 12);
lv_obj_set_pos(label, 6, (m.pager - label_h) / 2);
lv_obj_add_event_cb(bar, pager_event, LV_EVENT_SHORT_CLICKED,
(void *) (intptr_t) (side ? per_page : -per_page));
}
- auto *counter = text(root, std::to_string(page_number) + " / " + std::to_string(page_total), row_font, 0x616161,
+ auto *counter = text(root, std::to_string(page_number) + " / " + std::to_string(page_total), row_font, theme::MUTED,
LV_TEXT_ALIGN_CENTER);
lv_obj_set_width(counter, m.width);
lv_obj_set_pos(counter, 0, y + (m.pager - label_h) / 2);
@@ -680,7 +691,7 @@ inline void open(uint8_t page = 0) {
lv_obj_set_size(root, lv_pct(100), lv_pct(100));
lv_obj_remove_flag(root, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(root, LV_OBJ_FLAG_CLICKABLE); // nothing leaks through to the tiles below
- lv_obj_set_style_bg_color(root, lv_color_hex(0xE7E7E7), 0);
+ lv_obj_set_style_bg_color(root, theme::color(theme::PAGE), 0);
lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0);
}
lv_obj_move_foreground(root);
@@ -693,6 +704,13 @@ inline void close() {
if (root) { lv_obj_delete(root); root = nullptr; }
}
+// A change of look while the page is open (Dark mode is one of its rows): the page is drawn again in place.
+inline void restyle() {
+ if (!root) return;
+ lv_obj_set_style_bg_color(root, theme::color(theme::PAGE), 0);
+ draw();
+}
+
// ---- holding the top bar opens the page ----
// Not a tile, not a button in view: a press of `hold_ms` on the strip with the screen name. A line
// grows along the top edge while you hold, so the gesture explains itself the first time it happens.
@@ -711,6 +729,9 @@ inline void hold_event(lv_event_t *event) {
if (!hold_bar) return;
if (code == LV_EVENT_PRESSED) {
if (visible() || (may_open && !may_open())) return;
+ // The line takes the look's blue when it starts; it is hidden the rest of the time.
+ const lv_color_t blue = theme::color(theme::ACCENT);
+ if (!lv_color_eq(lv_obj_get_style_bg_color(hold_bar, LV_PART_MAIN), blue)) lv_obj_set_style_bg_color(hold_bar, blue, 0);
lv_obj_set_width(hold_bar, 1);
lv_anim_t anim;
lv_anim_init(&anim);
@@ -745,7 +766,6 @@ inline void attach_hold(lv_obj_t *page, int x, int y, int width, int height, lv_
lv_obj_add_event_cb(hold_area, hold_event, LV_EVENT_PRESS_LOST, nullptr);
hold_bar = plain(page, 0, 0, 1, large ? 5 : 3);
lv_obj_set_style_bg_opa(hold_bar, LV_OPA_COVER, 0);
- lv_obj_set_style_bg_color(hold_bar, lv_color_hex(0x009FE3), 0);
lv_obj_add_flag(hold_bar, LV_OBJ_FLAG_HIDDEN);
lv_obj_move_foreground(hold_bar);
if (below && lv_obj_get_parent(below) == page) {
diff --git a/components/smart_display/theme.h b/components/smart_display/theme.h
new file mode 100644
index 0000000..ff49afe
--- /dev/null
+++ b/components/smart_display/theme.h
@@ -0,0 +1,349 @@
+#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
+#include
+#include
+
+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(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(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(255 - (255 - depth) * 3 / 2)) : mix(card, 0x000000, depth);
+}
+
+} // namespace theme
+
+#ifndef THEME_TEST
+#include "lvgl.h"
+#include
+
+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(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(paint)];
+ if (p.bg != NONE) lv_style_set_bg_color(style, color(static_cast(p.bg)));
+ if (p.border != NONE) lv_style_set_border_color(style, color(static_cast(p.border)));
+ if (p.text != NONE) lv_style_set_text_color(style, color(static_cast(p.text)));
+ if (p.line != NONE) lv_style_set_line_color(style, color(static_cast(p.line)));
+ if (p.arc != NONE) lv_style_set_arc_color(style, color(static_cast(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(Paint::COUNT)> own{};
+inline lv_style_t *style(Paint paint) {
+ auto *&s = own[static_cast(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(i));
+ // One walk over every widget instead of one per paint.
+ lv_obj_report_style_change(nullptr);
+ if (redraw) redraw();
+}
+
+} // namespace theme
+#endif
diff --git a/components/smart_display/tile_controls.h b/components/smart_display/tile_controls.h
index dd0d97e..596d4ac 100644
--- a/components/smart_display/tile_controls.h
+++ b/components/smart_display/tile_controls.h
@@ -4,6 +4,7 @@
// a -/+ step lands on the entity's grid, and the status line beside them. The
// LVGL drawing lives in runtime_tiles.h; tests/test_tile_controls.cpp covers this.
#include "runtime_model.h"
+#include "theme.h"
#include
#include
#include
@@ -49,13 +50,14 @@ inline std::string panel_kind(const Tile &t) {
inline bool has_mode(const std::string &hvac_modes, const char *mode) { return hvac_modes.find("\"" + std::string(mode) + "\"") != std::string::npos; }
// Home Assistant's colour for a climate mode (the card and its mode keys use it).
inline uint32_t mode_color(const std::string &mode) {
- if (mode == "heat") return 0xFF6F22;
- if (mode == "cool") return 0x2196F3;
- if (mode == "heat_cool") return 0xFFC107;
- if (mode == "auto") return 0x4CAF50;
- if (mode == "fan_only") return 0x00BCD4;
- if (mode == "dry") return 0xFF9800;
- return 0x9E9E9E;
+ using namespace theme::ha;
+ if (mode == "heat") return DEEP_ORANGE;
+ if (mode == "cool") return BLUE;
+ if (mode == "heat_cool") return AMBER;
+ if (mode == "auto") return GREEN;
+ if (mode == "fan_only") return CYAN;
+ if (mode == "dry") return ORANGE;
+ return GREY;
}
inline const char *mode_icon(const std::string &mode) {
if (mode == "off") return glyph::POWER;
diff --git a/components/smart_display/tile_palette.h b/components/smart_display/tile_palette.h
index 4fc7a87..86ff7d7 100644
--- a/components/smart_display/tile_palette.h
+++ b/components/smart_display/tile_palette.h
@@ -1,21 +1,12 @@
#pragma once
#include
#include
+#include "theme.h"
namespace tile_palette {
-// Match the editor palette; all backgrounds are chosen for dark text.
-inline uint32_t color(const std::string &name) {
- if(name=="red")return 0xFADADD;
- if(name=="orange")return 0xFFE1C6;
- if(name=="yellow")return 0xFFF0C2;
- if(name=="green")return 0xD9EEDC;
- if(name=="mint")return 0xD5F0EA;
- if(name=="blue")return 0xD9EAFB;
- if(name=="purple")return 0xE9DDF5;
- if(name=="pink")return 0xF7DDEC;
- if(name=="gray")return 0xE5E7EB;
- return 0; // Automatic/default, including unknown future palette names.
-}
+// The named card colours ESP Screens offers (theme::SWATCHES). A tile and an alert keep the light value as the colour's
+// identity; theme::surface() draws it in the current look. Unknown future names and "auto" are 0: the normal card.
+inline uint32_t color(const std::string &name) { return theme::swatch(name); }
// "None": no card behind the tile contents. Older firmware treats it as automatic.
inline bool transparent(const std::string &name) { return name=="none"; }
}
diff --git a/docs/EASY_SETUP.md b/docs/EASY_SETUP.md
index 86cf4b0..db97340 100644
--- a/docs/EASY_SETUP.md
+++ b/docs/EASY_SETUP.md
@@ -256,6 +256,7 @@ After this, changes to these settings don't need a new firmware flash.
| Auto standby | On/off | On |
| Standby after | 1–1440 minutes after the last touch | 10 minutes |
| Normal brightness | 5–100% | 100% |
+| Dark mode | On/off: black page, graphite cards, firmware 0.2.54+ | Off |
| Standby brightness | 0–100%, capped at normal brightness | 20% |
| Night mode | On/off; applies during standby | On |
| Night start/end | Hour and minute, can span midnight | 22:00–07:00 |
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
index c32e7aa..8bec60e 100644
--- a/docs/RELEASING.md
+++ b/docs/RELEASING.md
@@ -176,6 +176,53 @@ icons sit off-center in the browser); run `generate_packages.py` afterward.
The `MDI_GLYPH_*` substitutions are retired: `TILEn_ICON` in manual
profiles must come from the set. No changed preferences or keys.
+### Compatibility 0.2.63 / firmware 0.2.54
+
+Firmware (`components/smart_display`, new `theme.h`), both board profiles and the app's settings. The tile protocol
+and the layout message are unchanged; nothing new on the wire.
+
+- **Colours in one table.** `theme.h` holds every colour the firmware chooses: roles with a light and a dark value,
+ the named card colours (`SWATCHES`, moved out of `tile_palette.h`, which now looks them up) and Home Assistant's
+ state colours (`theme::ha`). Headers ask for roles; the profiles give widgets paints (`styles: paint_card`), shared
+ styles without a colour that the profile's `theme::paints` list fills (`theme::fill()`, code in flash rather than a
+ table in RAM). `tests/test_theme.py` fails on a hex colour anywhere else (the light's colour picker and the value
+ card's colour wheel keep their hues) and on a paint that is not defined, filled once and used in every profile and
+ package; `tests/test_theme.cpp` checks the values.
+- **The light look is the old one.** Host renders of every page, card, alert and settings page against firmware
+ 0.2.53 are pixel-identical, apart from merges below one panel step: the text of the simple cards (`#202020` to
+ `#1B1B1B`) and of some secondary words (`#5F6368` to `#616161`), the colour card's page (`#F7F7F7` to `#F8F8F8`,
+ what the eye saw through the old sheet), an unavailable tile's circle and the off brightness track (to `#F1F1F1`),
+ a pressed round key (`#DADADA` and `#D9D9D9` to `#DDDDDD`) and the tints of the vacuum's halo and the cover sliders,
+ now computed from their state colour (within two steps of the old hand-picked values), and the two fixes below.
+- **Two redraws that drew differently**, found by comparing a screen that switched looks with one that booted in it,
+ and both older than 0.2.54. `settings_screen::draw()` ends in `refresh()`, whose `move_knob()` read the new track's
+ coordinates before LVGL had laid the page out (still 0 wide), so every switch that was on showed its knob at the
+ left until the next refresh; the knob is now placed from the size `pill()` gave the track (`knob_x()`), and a
+ change of look, which draws the page again, no longer throws the Dark mode switch's own knob to the left. And
+ `render_slot`'s palette gave the sun path's fill the tile's accent (orange) whenever the palette changed, while
+ `render_sunpath` sets the sun's yellow on every render, so the sunlit area changed colour by itself a minute later;
+ the sun path keeps its own fill now.
+- **Dark mode** is `settings_screen::dark_mode` beside the other settings outside the frozen block, with a uint32
+ preference of its own (`0x44524B31`, "DRK1"); `screen_settings::Settings` stays version 1. It is a row under
+ Brightness (five rows: no pager on either board), the template switch `Dark mode` (config, `restore_mode: DISABLED`)
+ and the app's key `dark_mode` (`SETTING_RULES`, `SETTINGS_BESIDE_BLOCK`, `SETTING_ENTITIES`). A screen that gets its
+ settings with the layout never has it: `settings_view` leaves it out, the layout message never carries it.
+ `DARK_MODE_MIN_FIRMWARE` is 0.2.54.
+- **A change of look** runs through `set()` -> `apply_screen_settings` -> `theme::set_dark()`: the profile's and the
+ firmware's own paints are refilled, LVGL is told once (`lv_obj_report_style_change(nullptr)`), and `theme::redraw`
+ (set in `on_boot`) draws again what code painted by state: the tiles' palettes and the top bar
+ (`runtime_tiles::restyle`), an open card in place, the colour card (`light_controls::restyle`), the settings page,
+ an alert that is up (`alert_card_color`) and the climate card and its mode picker. The same pass, so no frame
+ mixes the looks; at boot `set_dark()` runs before the first frame.
+- **Profiles:** the substitutions `BG_TOP_COLOR`, `BG_BOTTOM_COLOR` and `ACCENT_*` are gone (an override that set them
+ has no effect any more), `style_bg_grad` is `style_page` (one fill, no gradient between two equal stops), the four
+ unused `*_disabled` styles and the colour card's `color_detail_bg` and `color_detail_scrim` are removed, and
+ `open_value_overlay` lost its three colour parameters (the value card's slider takes paints).
+- App 0.2.63 with firmware 0.2.53 or older: no Dark mode row, everything else as before. Firmware 0.2.54 with an
+ older app: Dark mode works on the screen and in Home Assistant; the older editor does not show the row.
+- A layout of a screen that gets its settings with the layout is stored with `dark_mode` among its settings. An app
+ from before 0.2.63 refuses that key: restore the data backup when rolling back, as for every settings key before.
+
### Compatibility 0.2.62 / firmware 0.2.53
Firmware (`components/smart_display`), the icon fonts and the top bar's icon. Storage, tile protocol, preferences and
diff --git a/docs/SETTINGS.md b/docs/SETTINGS.md
index f512161..1127ab9 100644
--- a/docs/SETTINGS.md
+++ b/docs/SETTINGS.md
@@ -16,6 +16,7 @@ changes them with the entity's own action, and leaves them out of the layout mes
| Row on the screen | Entity | Key in ESP Screens |
|---|---|---|
| Brightness | `number._normal_brightness` | `brightness` |
+| Dark mode (0.2.54+) | `switch._dark_mode` | `dark_mode` |
| Auto standby | `switch._auto_standby` | `standby_enabled` |
| Standby after | `number._standby_after` | `standby_seconds` |
| Standby brightness | `number._standby_brightness` | `standby_brightness` |
@@ -29,7 +30,9 @@ changes them with the entity's own action, and leaves them out of the layout mes
| Rotation (Guition) | `select._rotation` | `rotation` |
The first four entities and Auto standby existed before 0.2.49; ESP Screens recognizes a screen that owns
-its settings by one of the others (`OWNED_SETTINGS_MARKERS` in `core.py`).
+its settings by one of the others (`OWNED_SETTINGS_MARKERS` in `core.py`). Dark mode came with firmware 0.2.54:
+a screen without its switch shows no Dark mode row in ESP Screens, and firmware that gets its settings with the
+layout never gets it at all. What Dark mode changes on the glass is in [docs/THEME.md](THEME.md).
**Older firmware: ESP Screens.** The values travel in the layout message: `settings`, the frozen block of
eleven keys, with `swipe_pages`, `auto_home`, `auto_home_seconds` and `rotation` as keys of their own. A
@@ -47,7 +50,7 @@ The page is a menu of groups, each of which opens a page of its own:
| Group | Rows |
|---|---|
-| Brightness | Brightness, Auto standby, Standby after, Standby brightness |
+| Brightness | Brightness, Dark mode, Auto standby, Standby after, Standby brightness |
| Night | Night mode, Starts, Ends, Night brightness |
| Screen | Clock, Back to page 1, After, Also on standby, Swipe between pages, Rotation (boards that turn) |
| This screen | Screen, Address, Firmware, Home Assistant, Restart |
diff --git a/docs/THEME.md b/docs/THEME.md
new file mode 100644
index 0000000..8fb79d3
--- /dev/null
+++ b/docs/THEME.md
@@ -0,0 +1,84 @@
+# Colours and Dark mode
+
+Every colour the screens choose themselves lives in one file, `components/smart_display/theme.h`
+(firmware 0.2.54+). Dark mode is a second value for each of them, not a second design: the
+layout, the fonts and the sizes stay exactly as they are. This page says how the colours reach
+the glass and what a new colour takes.
+
+## The model
+
+| Part | What it is | Where |
+|---|---|---|
+| Role | What a colour is for (`PAGE`, `CARD`, `INK`, `MUTED`, `KEY_PRESSED`, ...), with a light and a dark value | `theme::ROLES`, the table at the top of `theme.h` |
+| Swatch | A named card colour a tile or an alert can pick (`red` ... `gray`), light and dark | `theme::SWATCHES` |
+| State colour | Home Assistant's colour for a state (the amber of a light that is on), the same in both looks | `theme::ha` |
+| Paint | A shared LVGL style filled from roles: `card` is a card's background, hairline and text | `theme::Paint`, `theme::PAINTS` |
+| Look | `theme::dark`, set by `theme::set_dark()` from the Dark mode setting | `settings_screen::dark_mode` |
+
+The light values are the design as it has always been; `tests/test_theme.cpp` pins the main ones.
+The dark values follow a few rules: a black page, graphite cards (`#1A1A1A`) with a hairline,
+soft white text (`#DADADA`) and greys for secondary words, Home Assistant's state colours
+unchanged, their tints a little stronger over graphite, and a grey whose red, green and blue land
+on the same step of the RGB565 panel, so it stays grey on both boards.
+
+## How a colour reaches the glass
+
+- **Board profiles** write no colour. A widget names a paint, `styles: paint_card`, or a style that
+ carries one (`style_page`, `style_tile`, `style_room`, ...). The style definitions have no colour;
+ `on_boot` sets `theme::paints` to the list of `theme::fill(id(style), Paint::...)` calls and runs it. The list is
+ code in flash, not a table in RAM, and `theme::set_dark()` runs it again.
+ Scripts that colour a widget by state ask for a role: `theme::color(selected ? theme::ACCENT_BRIGHT : theme::CARD)`.
+- **Firmware** asks for roles: `theme::color(theme::INK)`, `theme::hex(theme::MUTED)`, the
+ `detail_text(..., theme::SUBTLE)` and `detail_shape(..., theme::TRACK, ...)` overloads. A widget
+ that lives as long as the screen and never changes colour by state takes a paint too:
+ `lv_obj_add_style(obj, theme::style(theme::Paint::knob), LV_PART_KNOB)`.
+- **State colours** pass through a helper on their way to the glass:
+ `state()` (Home Assistant's greys become the dark greys), `foreground()` (a glyph, a line or words:
+ deep colours are lifted in dark), `tint()` (the pale circle, a halo, a slider track), `icon()`
+ (a tile's icon), `surface()` and `outline()` (a card's own colour and hairline), `key_on()` (keys
+ inside a card of any colour) and `fill_opacity()` (the area under a graph).
+
+## A change of look
+
+`settings_screen::set("dark_mode", ...)` stores the setting in its own preference (`DRK1`), runs
+`apply_screen_settings`, and that calls `theme::set_dark()`. A look the screen already has costs
+nothing. A real change runs `theme::paints`, refills the firmware's own paints, tells LVGL once, and calls
+`theme::redraw`, set in `on_boot`, which draws again what code painted by state:
+
+- `runtime_tiles::restyle()`: every tile's palette, the top bar's icons, and an open card, in place;
+- `light_controls::restyle()`, `settings_screen::restyle()`;
+- an alert that is up (`theme::surface(id(alert_card_color))`), and the climate card and its mode
+ picker when they are open.
+
+Everything happens in the same pass, so no frame shows half of each look. At boot `set_dark()`
+runs before the first frame.
+
+A redraw has to give exactly what a first draw gives, whatever came before it. Two things that did
+not were found by comparing a screen that switched with one that booted in that look: a position
+worked out from coordinates LVGL had not laid out yet (they read 0 until its next pass: use the size
+you set), and a palette pass that overwrote a colour the sun path had chosen itself.
+
+## Adding a colour
+
+1. **A role**: one line in `enum Role` and one row in `ROLES`, in the same order (the tests read
+ both). The light value is the design; choose the dark value on a render, not in the table.
+2. **In firmware code**: `theme::color(theme::YOUR_ROLE)`. Never `lv_color_hex(0x...)`.
+3. **In a board profile**: the widget takes a paint. For a new combination add it to `enum class
+ Paint` and `PAINTS`, a colourless `- id: paint_` in the `style_definitions` of both profiles,
+ and `theme::fill(id(paint_), Paint::);` in their `theme::paints` list. Then
+ `python3 tools/generate_packages.py`.
+4. **Something drawn once and kept that changes colour by state**: set it where the state is drawn,
+ and make sure the redraw above reaches that code.
+5. A local colour always wins over a paint: an object that takes a paint must not also get a local
+ colour for the same property and part.
+
+## Checks
+
+- `tests/test_theme.cpp`: the light design, contrast of the text roles on every surface and swatch in
+ both looks, neutral dark greys, and the helpers.
+- `tests/test_theme.py`: no colour outside `theme.h` (the light's colour picker and the value card's
+ colour wheel keep their hues), every paint defined, filled once and used, and the look applied at
+ boot, after a change and in the redraw.
+- Renders of the host build in both looks (see `docs/RELEASING.md`, Compatibility 0.2.63): the light
+ look pixel for pixel against the previous firmware, a screen that boots dark against one that
+ switched, and every card opened in light and switched while open against the same card opened in dark.
diff --git a/docs/images/guition-dark-controls.png b/docs/images/guition-dark-controls.png
new file mode 100644
index 0000000000000000000000000000000000000000..565ac12dd25ff966831e7c4c50bb33a761a0cbbd
GIT binary patch
literal 29514
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