Add Dark mode for a screen beside the bed, with every colour in one table (0.2.63)
Dark mode is a new switch under Brightness on the screen's settings page, in the Screen settings of ESP Screens and as switch.<screen>_dark_mode in Home Assistant, so an automation can turn it on at bedtime. The screen changes look at once, open cards and an alert included, and keeps it after a restart. The design stays the same: a black page, graphite cards with a hairline, soft white text, Home Assistant's state colours where they tell something, and pastel card colours as deeper versions of themselves. Every colour the firmware chooses now lives in components/smart_display/ theme.h: roles with a light and a dark value, the named card colours and Home Assistant's state colours. The board profiles give widgets colourless shared styles that theme.h fills, so a change of look refills those styles and draws again what code painted by state. The light look is unchanged apart from merges below one panel step. Dark mode costs 440 bytes of static RAM on the CYD, and the LVGL tree of the host build is 416 bytes smaller; tests keep colours out of every other file. Comparing a screen that switched looks with one that booted in it also found two older bugs, now fixed: a settings switch that was on showed its knob at the left whenever its page was drawn, and the sun card's sunlit area turned orange after a palette change. Firmware 0.2.54. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -51,6 +51,12 @@ no reflash, no YAML to write, no blueprint, MQTT or token.
|
||||
<img src="docs/images/cyd-history.png" width="32%" alt="The history card on the CYD: power over 24 hours with its highest and lowest moment, an axis in watts and clock times">
|
||||
</p>
|
||||
<p align="center"><sub>The same cards on the 2.8-inch CYD, 320 × 240.</sub></p>
|
||||
<p align="center">
|
||||
<img src="docs/images/guition-dark-home.png" width="32%" alt="Dark mode on the Guition: the same home page with a black page, graphite cards and soft white text, the clock, the temperature graph, the weather, a lamp and presence">
|
||||
<img src="docs/images/guition-dark-controls.png" width="32%" alt="Dark mode with direct control: the heating setpoint, the dimmer's slider and the Sonos volume keep their colours">
|
||||
<img src="docs/images/guition-dark-page-4.png" width="32%" alt="Dark mode with pastel backgrounds: the coffee machine and the good-night script keep their colour, deeper">
|
||||
</p>
|
||||
<p align="center"><sub>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.</sub></p>
|
||||
|
||||
## Managed from Home Assistant
|
||||
|
||||
|
||||
+5
-4
@@ -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.<screen>_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 |
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "lvgl.h"
|
||||
#include "theme.h"
|
||||
#include <algorithm>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
@@ -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<int>(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);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "esphome/core/preferences.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "lvgl.h"
|
||||
#include "theme.h"
|
||||
#include <functional>
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -37,12 +37,13 @@ struct State {
|
||||
#include "esphome/core/log.h"
|
||||
#include "lvgl.h"
|
||||
#include "cyd_ui.h"
|
||||
#include "theme.h"
|
||||
#include <functional>
|
||||
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<void(int)> 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<uint8_t>(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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<screen_settings::Settings>(0x53435231);
|
||||
swipe_preference = esphome::global_preferences->make_preference<uint32_t>(0x53575031);
|
||||
home_preference = esphome::global_preferences->make_preference<HomeTimeout>(0x484F4D31);
|
||||
dark_preference = esphome::global_preferences->make_preference<uint32_t>(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<int32_t>(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>((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;i<n;++i){widths[i]=text_width(row.labels[i],font);words+=widths[i];}
|
||||
int share=(room-words)/n;
|
||||
@@ -947,11 +961,11 @@ inline void vacuum_segments(const Tile &t,const Choice &row,int first,int x,int
|
||||
bool selected=row.values[i]==chosen;
|
||||
auto *segment=detail_button(row.labels[i].c_str(),sx,y+inset,sw,h-2*inset,first+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,sw-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),sw-6);
|
||||
sx+=sw;
|
||||
}
|
||||
}
|
||||
@@ -981,16 +995,16 @@ inline void render_vacuum_detail(Tile &t,bool large,int width,int height,int pad
|
||||
int line=lv_font_get_line_height(big),text_h=lv_font_get_line_height(text),space=large?6:3;
|
||||
bool room=on_the_way && !t.extra().room.empty();
|
||||
int block=line+(room?space+text_h:0)+(battery?space+text_h:0),ty=(hero_h-block)/2;
|
||||
detail_badge_status=detail_text(hero,state,text_x,ty,text_w,big,LV_TEXT_ALIGN_LEFT,0x1B1B1B);
|
||||
detail_badge_status=detail_text(hero,state,text_x,ty,text_w,big,LV_TEXT_ALIGN_LEFT,theme::INK);
|
||||
ty+=line;
|
||||
if(room){ty+=space;detail_text(hero,t.extra().room,text_x,ty,text_w,text,LV_TEXT_ALIGN_LEFT,0x5F6368);ty+=text_h;}
|
||||
if(room){ty+=space;detail_text(hero,t.extra().room,text_x,ty,text_w,text,LV_TEXT_ALIGN_LEFT,theme::MUTED);ty+=text_h;}
|
||||
if(battery){ty+=space;vacuum_power(hero,t,text_x,ty,text,large);}
|
||||
if(locate){
|
||||
auto *find=detail_button("",pad+inner-edge-key-6,y+(hero_h-key)/2,key,key,3);
|
||||
lv_obj_set_style_radius(find,LV_RADIUS_CIRCLE,0);lv_obj_set_style_bg_color(find,lv_color_hex(0xF1F1F1),0);
|
||||
lv_obj_set_style_bg_color(find,lv_color_hex(0xDDDDDD),LV_STATE_PRESSED);
|
||||
lv_obj_set_style_radius(find,LV_RADIUS_CIRCLE,0);lv_obj_set_style_bg_color(find,theme::color(theme::TRACK),0);
|
||||
lv_obj_set_style_bg_color(find,theme::color(theme::KEY_PRESSED),LV_STATE_PRESSED);
|
||||
auto *glyph=lv_obj_get_child(find,0);lv_obj_set_style_text_font(glyph,icons,0);lv_label_set_text(glyph,"\U000F034E");
|
||||
lv_obj_set_style_text_color(glyph,lv_color_hex(0x46525E),0);lv_obj_set_size(glyph,LV_SIZE_CONTENT,LV_SIZE_CONTENT);lv_obj_center(glyph);
|
||||
lv_obj_set_style_text_color(glyph,theme::color(theme::SLATE),0);lv_obj_set_size(glyph,LV_SIZE_CONTENT,LV_SIZE_CONTENT);lv_obj_center(glyph);
|
||||
}
|
||||
y+=hero_h+gap;
|
||||
}else{
|
||||
@@ -998,9 +1012,9 @@ inline void render_vacuum_detail(Tile &t,bool large,int width,int height,int pad
|
||||
int line=lv_font_get_line_height(text),dot=8,power_w=battery?vacuum_power(nullptr,t,0,0,text,false):0;
|
||||
detail_shape(detail_root,pad+2,y+(line-dot)/2,dot,dot,look.accent,dot/2);
|
||||
int words=inner-dot-10-power_w-12,state_w=std::min(words,text_width(state,text)+2);
|
||||
detail_badge_status=detail_text(detail_root,state,pad+dot+10,y,state_w,text,LV_TEXT_ALIGN_LEFT,0x1B1B1B);
|
||||
detail_badge_status=detail_text(detail_root,state,pad+dot+10,y,state_w,text,LV_TEXT_ALIGN_LEFT,theme::INK);
|
||||
if(on_the_way && !t.extra().room.empty() && words-state_w>24)
|
||||
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<count;++i){
|
||||
int thick=std::max(2,pitch*(20+60*i/(count-1))/100);
|
||||
detail_shape(slats,inset,radius/2+i*pitch+(pitch-thick)/2,w-2*inset,thick,COVER_SLATS,thick/2);
|
||||
detail_shape(slats,inset,radius/2+i*pitch+(pitch-thick)/2,w-2*inset,thick,cover_slats(),thick/2);
|
||||
}
|
||||
}
|
||||
auto *slider=lv_slider_create(parent);lv_obj_remove_style_all(slider);
|
||||
lv_obj_set_pos(slider,x,y);lv_obj_set_size(slider,w,h);lv_slider_set_orientation(slider,LV_SLIDER_ORIENTATION_VERTICAL);
|
||||
lv_obj_set_style_radius(slider,radius,LV_PART_MAIN);lv_obj_set_style_radius(slider,radius,LV_PART_INDICATOR);
|
||||
lv_obj_set_style_bg_color(slider,lv_color_hex(COVER_TRACK),LV_PART_MAIN);lv_obj_set_style_bg_opa(slider,tilt?LV_OPA_TRANSP:LV_OPA_COVER,LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_color(slider,lv_color_hex(cover_track()),LV_PART_MAIN);lv_obj_set_style_bg_opa(slider,tilt?LV_OPA_TRANSP:LV_OPA_COVER,LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_color(slider,lv_color_hex(COVER_ACCENT),LV_PART_INDICATOR);lv_obj_set_style_bg_opa(slider,tilt?LV_OPA_TRANSP:LV_OPA_COVER,LV_PART_INDICATOR);
|
||||
lv_obj_set_style_bg_color(slider,lv_color_hex(tilt?COVER_ACCENT:0xFFFFFF),LV_PART_KNOB);lv_obj_set_style_bg_opa(slider,LV_OPA_COVER,LV_PART_KNOB);
|
||||
lv_obj_set_style_bg_color(slider,tilt?lv_color_hex(COVER_ACCENT):theme::color(theme::KNOB),LV_PART_KNOB);lv_obj_set_style_bg_opa(slider,LV_OPA_COVER,LV_PART_KNOB);
|
||||
lv_obj_set_style_radius(slider,LV_RADIUS_CIRCLE,LV_PART_KNOB);
|
||||
lv_obj_set_style_opa(slider,LV_OPA_50,LV_STATE_DISABLED);
|
||||
// LVGL centres the knob on the end of the fill in a square as wide as the slider; the pads shrink it to a
|
||||
@@ -1112,12 +1129,12 @@ inline void cover_key_row(const std::array<tile_controls::Key,3> &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<n;++i,x+=slider_w+column_gap){
|
||||
cover_slider(detail_root,x,top+edge,slider_w,slider_h,parts[i].value,parts[i].tilt);
|
||||
int ty=top+edge+slider_h+2;
|
||||
cover_values[parts[i].tilt?1:0]=detail_text(detail_root,percent(parts[i].value),x-30,ty,slider_w+60,big,LV_TEXT_ALIGN_CENTER,0x1B1B1B);
|
||||
detail_text(detail_root,parts[i].caption,x-30,ty+value_h,slider_w+60,text,LV_TEXT_ALIGN_CENTER,0x6B6B6B);
|
||||
cover_values[parts[i].tilt?1:0]=detail_text(detail_root,percent(parts[i].value),x-30,ty,slider_w+60,big,LV_TEXT_ALIGN_CENTER,theme::INK);
|
||||
detail_text(detail_root,parts[i].caption,x-30,ty+value_h,slider_w+60,text,LV_TEXT_ALIGN_CENTER,theme::SUBTLE);
|
||||
}
|
||||
}else{
|
||||
// The small screen puts the value and name beside each slider.
|
||||
@@ -1172,8 +1189,8 @@ inline void render_cover_detail(Tile &t,bool large,int width,int height,int pad)
|
||||
for(int i=0;i<n;++i,x+=column+column_gap){
|
||||
cover_slider(detail_root,x,top+edge,slider_w,slider_h,parts[i].value,parts[i].tilt);
|
||||
int ty=top+(box_h-value_h-caption_h)/2;
|
||||
cover_values[parts[i].tilt?1:0]=detail_text(detail_root,percent(parts[i].value),x+slider_w+8,ty,label_w,big,LV_TEXT_ALIGN_LEFT,0x1B1B1B);
|
||||
detail_text(detail_root,parts[i].caption,x+slider_w+8,ty+value_h,label_w,text,LV_TEXT_ALIGN_LEFT,0x6B6B6B);
|
||||
cover_values[parts[i].tilt?1:0]=detail_text(detail_root,percent(parts[i].value),x+slider_w+8,ty,label_w,big,LV_TEXT_ALIGN_LEFT,theme::INK);
|
||||
detail_text(detail_root,parts[i].caption,x+slider_w+8,ty+value_h,label_w,text,LV_TEXT_ALIGN_LEFT,theme::SUBTLE);
|
||||
}
|
||||
}
|
||||
}else{
|
||||
@@ -1181,8 +1198,8 @@ inline void render_cover_detail(Tile &t,bool large,int width,int height,int pad)
|
||||
detail_card(pad,top,inner,box_h);
|
||||
const lv_font_t *icon_font=widgets[0].icon_font?widgets[0].icon_font:mini_icon_font;
|
||||
int halo=std::min(box_h-24,large?120:72);
|
||||
auto *ring=detail_shape(detail_root,pad+(inner-halo)/2,top+(box_h-halo)/2,halo,halo,COVER_TRACK,halo/2);
|
||||
if(icon_font){auto *icon=detail_text(ring,icon_for(t),0,(halo-lv_font_get_line_height(icon_font))/2,halo,icon_font,LV_TEXT_ALIGN_CENTER,COVER_ACCENT);(void)icon;}
|
||||
auto *ring=detail_shape(detail_root,pad+(inner-halo)/2,top+(box_h-halo)/2,halo,halo,cover_track(),halo/2);
|
||||
if(icon_font){auto *icon=detail_text(ring,icon_for(t),0,(halo-lv_font_get_line_height(icon_font))/2,halo,icon_font,LV_TEXT_ALIGN_CENTER,theme::foreground(COVER_ACCENT));(void)icon;}
|
||||
}
|
||||
int y=keys_y;
|
||||
if(key_count){cover_key_row(keys,key_count,pad,y,inner,key_h,gap,key_icons);y+=key_h+gap;}
|
||||
@@ -1213,7 +1230,7 @@ struct HistoryChart {
|
||||
float high=NAN,low=NAN;
|
||||
uint32_t high_at=0,low_at=0;
|
||||
bool line=true,ready=false;
|
||||
uint32_t accent=0x2196F3;
|
||||
uint32_t accent=theme::ha::BLUE;
|
||||
std::string value_text,first_text,second_text;
|
||||
lv_point_precise_t *points=nullptr;unsigned count=0;
|
||||
};
|
||||
@@ -1250,7 +1267,7 @@ inline std::string history_clock(uint32_t epoch,bool weekday){
|
||||
inline void history_fill(lv_event_t *e){
|
||||
const auto &c=history_chart;if(!c.line||c.count<2||!c.area)return;
|
||||
auto *layer=lv_event_get_layer(e);lv_area_t area;lv_obj_get_coords(c.area,&area);
|
||||
lv_draw_triangle_dsc_t dsc;lv_draw_triangle_dsc_init(&dsc);dsc.color=lv_color_hex(c.accent);dsc.opa=LV_OPA_20;
|
||||
lv_draw_triangle_dsc_t dsc;lv_draw_triangle_dsc_init(&dsc);dsc.color=lv_color_hex(c.accent);dsc.opa=theme::fill_opacity();
|
||||
const lv_value_precise_t ox=area.x1,oy=area.y1,base=area.y2+1;
|
||||
for(unsigned i=0;i+1<c.count;++i){
|
||||
const auto &a=c.points[i],&b=c.points[i+1];
|
||||
@@ -1267,13 +1284,13 @@ inline void history_bar(lv_event_t *e){
|
||||
for(size_t i=0;i<h.runs.size();++i){
|
||||
const int state=h.runs[i].state;
|
||||
const int x1=area.x1+w*h.runs[i].slot/h.slots,x2=area.x1+w*history_view::run_end(h.runs,h.slots,i)/h.slots-1;
|
||||
dsc.bg_color=lv_color_hex(state<0||state>=static_cast<int>(h.states.size())?0xF1F1F1:h.states[state].color);
|
||||
dsc.bg_color=lv_color_hex(state<0||state>=static_cast<int>(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<uint32_t>(span*i/history_view::PARTS),finish=h.start+static_cast<uint32_t>(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<int>(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<int>(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<int>(std::lround(at_x));
|
||||
const int my=static_cast<int>(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(ly<card_y+4)ly=my+ring/2+2;
|
||||
if(ly+lh>c.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<int>(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<size_t>(h.states.size(),static_cast<size_t>(rows)*2);
|
||||
for(size_t i=0;i<shown;++i){
|
||||
const int lx=c.x+static_cast<int>(i%2)*col_w,ly=top+static_cast<int>(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_style_selector_t>(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_style_selector_t>(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<history_view::PARTS;++i)if(h.has[i])widest=std::max(widest,text_width(history_view::number(h.values[i],h.decimals,h.unit),big));}
|
||||
else if(c.ready){for(const auto &s:h.states)widest=std::max(widest,text_width(s.label,big));}
|
||||
const int value_x=pad+(large?8:4),value_w=std::min(widest+6,(width-2*pad)*11/20);
|
||||
c.value=detail_text(detail_root,c.value_text,value_x,row,value_w,big,LV_TEXT_ALIGN_LEFT,0x1B1B1B);
|
||||
c.value=detail_text(detail_root,c.value_text,value_x,row,value_w,big,LV_TEXT_ALIGN_LEFT,theme::INK);
|
||||
const int words_x=value_x+value_w+(large?12:6),words_w=std::max(20,right-words_x),words_y=row+(row_h-2*text_h)/2;
|
||||
c.first=detail_text(detail_root,c.first_text,words_x,words_y,words_w,text,LV_TEXT_ALIGN_RIGHT,0x5F6368);
|
||||
c.second=detail_text(detail_root,c.second_text,words_x,words_y+text_h,words_w,text,LV_TEXT_ALIGN_RIGHT,0x5F6368);
|
||||
c.first=detail_text(detail_root,c.first_text,words_x,words_y,words_w,text,LV_TEXT_ALIGN_RIGHT,theme::MUTED);
|
||||
c.second=detail_text(detail_root,c.second_text,words_x,words_y+text_h,words_w,text,LV_TEXT_ALIGN_RIGHT,theme::MUTED);
|
||||
int top=row+std::max(row_h,2*text_h)+(large?10:4);
|
||||
if(d=="number"||d=="input_number"){
|
||||
const int slider_h=large?24:14;
|
||||
auto *slider=lv_slider_create(detail_root);lv_obj_set_pos(slider,pad+(large?14:10),top+(large?10:5));
|
||||
lv_obj_set_size(slider,width-2*pad-(large?28:20),slider_h);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);
|
||||
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)detail_index);
|
||||
top+=slider_h+(large?22:12);
|
||||
}
|
||||
@@ -1542,7 +1559,7 @@ inline void render_history_detail(const Tile &t,bool large,int width,int height,
|
||||
detail_card(pad,top,width-2*pad,card_h);
|
||||
const bool empty=c.ready&&(line?std::none_of(h.has,h.has+history_view::PARTS,[](bool v){return v;}):h.states.empty());
|
||||
if(!c.ready||empty){
|
||||
c.status=detail_text(detail_root,!c.ready?"Loading history...":"No history in this period",pad,top+(card_h-text_h)/2,width-2*pad,text,LV_TEXT_ALIGN_CENTER,0x6B6B6B);
|
||||
c.status=detail_text(detail_root,!c.ready?"Loading history...":"No history in this period",pad,top+(card_h-text_h)/2,width-2*pad,text,LV_TEXT_ALIGN_CENTER,theme::SUBTLE);
|
||||
}else if(line){
|
||||
render_history_line(large,pad,top,width-2*pad,card_h,small,current);
|
||||
}else{
|
||||
@@ -1560,12 +1577,12 @@ inline void show_detail(unsigned index){
|
||||
if(!detail_font)detail_font=lv_obj_get_style_text_font(widgets[0].title,LV_PART_MAIN);
|
||||
if(!detail_root){detail_root=lv_obj_create(lv_screen_active());lv_obj_remove_style_all(detail_root);lv_obj_set_size(detail_root,lv_pct(100),lv_pct(100));lv_obj_remove_flag(detail_root,LV_OBJ_FLAG_SCROLLABLE);}
|
||||
detail_action_count=0;detail_status=nullptr;detail_badge_status=nullptr;detail_switch=nullptr;history_forget();lv_obj_clean(detail_root);lv_obj_remove_flag(detail_root,LV_OBJ_FLAG_HIDDEN);lv_obj_move_foreground(detail_root);
|
||||
lv_obj_set_style_bg_color(detail_root,lv_color_hex(0xE7E7E7),0);lv_obj_set_style_bg_opa(detail_root,LV_OPA_COVER,0);
|
||||
lv_obj_set_style_bg_color(detail_root,theme::color(theme::PAGE),0);lv_obj_set_style_bg_opa(detail_root,LV_OPA_COVER,0);
|
||||
int width=lv_display_get_horizontal_resolution(lv_display_get_default()), height=lv_display_get_vertical_resolution(lv_display_get_default());
|
||||
bool large=width>=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<w.parts.size();++i){
|
||||
auto *p=w.parts[i];if(!p)continue;
|
||||
bool muted=w.extra_mode=="forecast" ? 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<lv_obj_t *, 2> header_hands{};
|
||||
inline std::array<HeaderSlot, header_bar::MAX_ITEMS> 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<model.count && model.tiles[w.index].background){
|
||||
bool palette_ok=lv_color_eq(lv_obj_get_style_bg_color(w.tile,LV_PART_MAIN),lv_color_hex(model.tiles[w.index].background)) &&
|
||||
lv_color_eq(lv_obj_get_style_text_color(w.title,LV_PART_MAIN),lv_color_hex(0x1B1B1B));
|
||||
bool palette_ok=lv_color_eq(lv_obj_get_style_bg_color(w.tile,LV_PART_MAIN),lv_color_hex(theme::surface(model.tiles[w.index].background))) &&
|
||||
lv_color_eq(lv_obj_get_style_text_color(w.title,LV_PART_MAIN),theme::color(theme::INK));
|
||||
if(!palette_ok)ESP_LOGE("ui_test","Tile palette FAIL slot=%u",(unsigned)w.index);
|
||||
fits=fits && palette_ok;
|
||||
}
|
||||
@@ -2864,7 +2893,7 @@ inline int place_page(int page) {
|
||||
inline lv_color_t page_color(const Widgets &w) {
|
||||
for(auto *o=lv_obj_get_parent(w.tile);o;o=lv_obj_get_parent(o))
|
||||
if(lv_obj_get_style_bg_opa(o,LV_PART_MAIN)>=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.index<model.count?&model.tiles[w.index]:nullptr;
|
||||
set_color(w.veil,LV_STYLE_BG_COLOR,t && t->transparent?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<size_t>(active_index) >= model.count) return {};
|
||||
auto &tile = model.tiles[active_index];
|
||||
|
||||
@@ -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<int32_t>(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<int32_t>(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 <functional>
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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 <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace theme {
|
||||
|
||||
enum Role : uint8_t {
|
||||
// ---- surfaces
|
||||
PAGE, // the page behind the tiles, every card and the settings
|
||||
PAGE_SOFT, // the page of the light's colour controls, a shade lighter
|
||||
PAGE_PRESSED, // the Previous and Next bars under a finger
|
||||
CARD, // tiles, cards, rows, the alert
|
||||
CARD_PRESSED, // a settings row under a finger
|
||||
LINE, // the hairline around a card
|
||||
RAISED, // a card above everything else: the alert
|
||||
RAISED_LINE,
|
||||
KEY, // round keys (back, close), a white key under a finger
|
||||
KEY_PRESSED, // a round key under a finger
|
||||
TRACK, // segment tracks and grey keys inside a card, an unavailable tile's circle, empty history
|
||||
GRID, // the lines across a history graph
|
||||
TICK, // the small marks under a history graph
|
||||
BUTTON, // the plain keys of a simple card (select options, media, timer)
|
||||
SETTING_KEY, // -/+ keys and choice chips on the settings page
|
||||
SETTING_KEY_PRESSED,
|
||||
TOGGLE_OFF, // a switch on the settings page while off
|
||||
SWITCH_OFF, // the switch on a history card while off
|
||||
PANEL_TOGGLE_OFF, // the toggle on a wide tile while off
|
||||
PANEL_TOGGLE_OFF_PRESSED,
|
||||
KNOB, // handles and knobs
|
||||
SLIDER_KNOB, // the round knob of a card's number or volume slider
|
||||
SCRIM, // behind an alert
|
||||
VEIL, // the sheet over a busy tile, the haze over the value card
|
||||
SPINNER_TRACK, // the ring of the busy spinner
|
||||
// ---- text
|
||||
INK, // names, values, words on keys
|
||||
INK_SOFT, // the alert's second line
|
||||
MUTED, // secondary words: states, times beside a value
|
||||
SUBTLE, // quiet words: axis labels, notes, "Target"
|
||||
SLATE, // the top bar, units, a value on a coloured card, mode key icons
|
||||
ROW_ICON, // icons at the start of a settings row
|
||||
CHEVRON, // the arrow at the end of a settings row
|
||||
OFF, // something that is off: its icon, its circle and the fill of its slider
|
||||
ON_ACCENT, // words and icons on a blue key and on the alert's key
|
||||
// ---- keys that stand out
|
||||
ACCENT, // the screen's own blue: a chosen segment, the main key, a switch that is on
|
||||
ACCENT_PRESSED,
|
||||
ACCENT_BRIGHT, // the value card's fill and a chosen climate chip
|
||||
ACCENT_TINT, // pale blue: a choice under a finger, the value card's track, climate power on
|
||||
ACCENT_ICON, // the climate power icon while on
|
||||
ICON_OFF, // the climate power icon while off
|
||||
BUTTON_DARK, // the alert's key
|
||||
BUTTON_DARK_PRESSED,
|
||||
// ---- drawings
|
||||
ROBOT_BODY, // the vacuum seen from above
|
||||
ROBOT_RIM,
|
||||
ROBOT_TOP,
|
||||
ROBOT_LENS,
|
||||
BATTERY, // a battery's outline and cap
|
||||
SUN_PATH, // the arc the sun travels
|
||||
MOON, // the sun below the horizon
|
||||
AMBER_TRACK, // the track of a light's brightness slider
|
||||
HISTORY_OFF, // an off or away stretch on a timeline (the add-on's CFCFCF)
|
||||
HISTORY_EMPTY, // a stretch without data on a timeline (the add-on's E6E6E6)
|
||||
// ---- the CYD's touch calibration: dark in both looks, a yellow crosshair
|
||||
CALIBRATION_PAGE,
|
||||
CALIBRATION_INK,
|
||||
CALIBRATION_MARK,
|
||||
ROLE_COUNT
|
||||
};
|
||||
|
||||
struct Pair { uint32_t light, dark; };
|
||||
|
||||
// light: the design as it always was. dark: RGB565-friendly values (a grey whose red, green and blue land on the same
|
||||
// step of the panel), so dark greys stay neutral on both boards instead of turning green.
|
||||
inline constexpr Pair ROLES[ROLE_COUNT] = {
|
||||
/* PAGE */ {0xE7E7E7, 0x000000},
|
||||
/* PAGE_SOFT */ {0xF8F8F8, 0x101010},
|
||||
/* PAGE_PRESSED */ {0xE3E3E3, 0x1A1A1A},
|
||||
/* CARD */ {0xFFFFFF, 0x1A1A1A},
|
||||
/* CARD_PRESSED */ {0xF1F1F1, 0x2A2A2A},
|
||||
/* LINE */ {0xDDDDDD, 0x292929},
|
||||
/* RAISED */ {0xFFFFFF, 0x282828},
|
||||
/* RAISED_LINE */ {0xDDDDDD, 0x393939},
|
||||
/* KEY */ {0xEEEEEE, 0x2A2A2A},
|
||||
/* KEY_PRESSED */ {0xDDDDDD, 0x3A3A3A},
|
||||
/* TRACK */ {0xF1F1F1, 0x2A2A2A},
|
||||
/* GRID */ {0xEEEEEE, 0x292929},
|
||||
/* TICK */ {0xCCCCCC, 0x4A4A4A},
|
||||
/* BUTTON */ {0xD9E6F0, 0x222B32},
|
||||
/* SETTING_KEY */ {0xE9EFF4, 0x2A2F34},
|
||||
/* SETTING_KEY_PRESSED */ {0xC3D5E3, 0x3A4652},
|
||||
/* TOGGLE_OFF */ {0xC4C4C4, 0x484848},
|
||||
/* SWITCH_OFF */ {0xB0B0B0, 0x484848},
|
||||
/* PANEL_TOGGLE_OFF */ {0xD7DADF, 0x393C41},
|
||||
/* PANEL_TOGGLE_OFF_PRESSED */ {0xC5C9CF, 0x494D52},
|
||||
/* KNOB */ {0xFFFFFF, 0xE8E8E8},
|
||||
/* SLIDER_KNOB */ {0x111111, 0xE8E8E8},
|
||||
/* SCRIM */ {0x101820, 0x000000},
|
||||
/* VEIL */ {0xFFFFFF, 0x1A1A1A},
|
||||
/* SPINNER_TRACK */ {0xD9DDE2, 0x393D42},
|
||||
/* INK */ {0x1B1B1B, 0xDADADA},
|
||||
/* INK_SOFT */ {0x3A3A3C, 0xBABABA},
|
||||
/* MUTED */ {0x616161, 0x999999},
|
||||
/* SUBTLE */ {0x6B6B6B, 0x8A8A8A},
|
||||
/* SLATE */ {0x46525E, 0x9AA6B2},
|
||||
/* ROW_ICON */ {0x4A4A4A, 0xB8B8B8},
|
||||
/* CHEVRON */ {0xB0B0B0, 0x5A5A5A},
|
||||
/* OFF */ {0x9E9E9E, 0x787878},
|
||||
/* ON_ACCENT */ {0xFFFFFF, 0xFFFFFF},
|
||||
/* ACCENT */ {0x009FE3, 0x0A93D2},
|
||||
/* ACCENT_PRESSED */ {0x0075B0, 0x0A6FA3},
|
||||
/* ACCENT_BRIGHT */ {0x00A6ED, 0x0A9ADB},
|
||||
/* ACCENT_TINT */ {0xD5EEFC, 0x12384A},
|
||||
/* ACCENT_ICON */ {0x0B6E99, 0x6CCBF5},
|
||||
/* ICON_OFF */ {0x8A8A8A, 0x7A7A7A},
|
||||
/* BUTTON_DARK */ {0x1B1B1B, 0x484848},
|
||||
/* BUTTON_DARK_PRESSED */ {0x3A3A3C, 0x585858},
|
||||
/* ROBOT_BODY */ {0xFFFFFF, 0x383D42},
|
||||
/* ROBOT_RIM */ {0xCEDDE6, 0x5A6972},
|
||||
/* ROBOT_TOP */ {0xE3EBEF, 0x4A5358},
|
||||
/* ROBOT_LENS */ {0xA8BCC8, 0x7E93A1},
|
||||
/* BATTERY */ {0x7D858D, 0x8C959D},
|
||||
/* SUN_PATH */ {0xCFD8DC, 0x3E494E},
|
||||
/* MOON */ {0xB0BEC5, 0x90A4AE},
|
||||
/* AMBER_TRACK */ {0xFDEFC3, 0x483B16},
|
||||
/* HISTORY_OFF */ {0xCFCFCF, 0x4A4A4A},
|
||||
/* HISTORY_EMPTY */ {0xE6E6E6, 0x2A2A2A},
|
||||
/* CALIBRATION_PAGE */ {0x101820, 0x101820},
|
||||
/* CALIBRATION_INK */ {0xFFFFFF, 0xFFFFFF},
|
||||
/* CALIBRATION_MARK */ {0xFFD34D, 0xFFD34D},
|
||||
};
|
||||
|
||||
// The look on screen. The board sets it through set_dark() (below) from the Dark mode setting; the table and the
|
||||
// helpers only read it.
|
||||
inline bool dark = false;
|
||||
inline uint32_t hex(Role role) { return dark ? ROLES[role].dark : ROLES[role].light; }
|
||||
|
||||
// ---- mixing, exactly as LVGL 9 mixes two colours (lv_color_mix), so a value here is the value on the glass
|
||||
inline uint32_t mix(uint32_t first, uint32_t second, uint8_t amount) {
|
||||
uint32_t out = 0;
|
||||
for (int shift = 16; shift >= 0; shift -= 8) {
|
||||
const uint32_t a = first >> shift & 0xFF, b = second >> shift & 0xFF;
|
||||
out |= (((a * amount + b * (255 - amount)) * 0x8081U) >> 23) << shift;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- named card colours: what a tile's background or an alert's colour can be
|
||||
// The add-on sends a name; a tile keeps the light value as the colour's identity (tile_palette.h), and surface() turns it
|
||||
// into what this look draws. Light: the pastels chosen for dark text. Dark: the same hue deep enough for light text.
|
||||
struct Swatch { const char *name; uint32_t light, dark; };
|
||||
inline constexpr Swatch SWATCHES[] = {
|
||||
{"red", 0xFADADD, 0x4A2422},
|
||||
{"orange", 0xFFE1C6, 0x472F17},
|
||||
{"yellow", 0xFFF0C2, 0x3E3516},
|
||||
{"green", 0xD9EEDC, 0x243A26},
|
||||
{"mint", 0xD5F0EA, 0x163631},
|
||||
{"blue", 0xD9EAFB, 0x1C3448},
|
||||
{"purple", 0xE9DDF5, 0x342C40},
|
||||
{"pink", 0xF7DDEC, 0x46202E},
|
||||
{"gray", 0xE5E7EB, 0x2A2D32},
|
||||
};
|
||||
inline constexpr size_t SWATCH_COUNT = sizeof(SWATCHES) / sizeof(SWATCHES[0]);
|
||||
inline uint32_t swatch(const std::string &name) {
|
||||
for (const auto &s : SWATCHES) if (name == s.name) return s.light;
|
||||
return 0;
|
||||
}
|
||||
// A card's own colour (a swatch's light value, or 0 for none) as this look draws it.
|
||||
inline uint32_t surface(uint32_t own) {
|
||||
if (!own) return hex(CARD);
|
||||
if (!dark) return own;
|
||||
for (const auto &s : SWATCHES) if (s.light == own) return s.dark;
|
||||
return hex(CARD);
|
||||
}
|
||||
// The hairline around a card: the card's own colour a little darker in light, a little lighter in dark.
|
||||
inline uint32_t outline(uint32_t own) {
|
||||
if (!own) return hex(LINE);
|
||||
return dark ? mix(surface(own), 0xFFFFFF, 232) : mix(own, 0x000000, 220);
|
||||
}
|
||||
|
||||
// ---- Home Assistant's state colours
|
||||
// What a state looks like in Home Assistant's frontend (its --state-*-color and --*-color variables), the same in both
|
||||
// looks: an amber light is amber at night too. They are data, drawn through state(), foreground(), tint() and icon().
|
||||
namespace ha {
|
||||
constexpr uint32_t AMBER = 0xFFC107; // a light, switch or binary sensor that is on; lux
|
||||
constexpr uint32_t ORANGE = 0xFF9800; // the sun above the horizon, a paused timer or robot, drying
|
||||
constexpr uint32_t DEEP_ORANGE = 0xFF6F22; // temperature, heating
|
||||
constexpr uint32_t RED = 0xF44336; // a robot in trouble
|
||||
constexpr uint32_t PURPLE = 0x926BC7; // covers, scenes and scripts, energy
|
||||
constexpr uint32_t DEEP_PURPLE = 0x6E41AB; // a clear night, the sun below the horizon
|
||||
constexpr uint32_t INDIGO = 0x3F51B5; // selects
|
||||
constexpr uint32_t BLUE = 0x2196F3; // cooling, a robot on its way back, the settings card, everything else
|
||||
constexpr uint32_t LIGHT_BLUE = 0x03A9F4; // media players, the weather
|
||||
constexpr uint32_t CYAN = 0x00BCD4; // fans
|
||||
constexpr uint32_t TEAL = 0x009688; // numbers, percentages, a running timer, a robot at work
|
||||
constexpr uint32_t GREEN = 0x4CAF50; // someone at home, auto mode
|
||||
constexpr uint32_t GREY = 0x9E9E9E; // anything off or away
|
||||
constexpr uint32_t SKY = 0x00A6ED; // a robot that is docked or idle
|
||||
constexpr uint32_t SWITCH_ON = 0xFFB900; // the switch on a history card while on
|
||||
constexpr uint32_t PURPLE_PRESSED = 0x7552A8; // a chosen cover key under a finger
|
||||
// The weather card's conditions and the sun card.
|
||||
constexpr uint32_t SUNNY = 0xFFB300, RAIN = 0x1E88E5, SNOW = 0x4FC3F7, LIGHTNING = 0xFFA000;
|
||||
constexpr uint32_t PARTLY_CLOUDY = 0x7E9BB5, CLOUDY = 0x78909C, NIGHT_SKY = 0x5C6BC0;
|
||||
// Warnings and good news drawn on a card: a low battery, the analog clock's second hand; charging.
|
||||
constexpr uint32_t ALARM = 0xE53935, CHARGING = 0x43A047;
|
||||
} // namespace ha
|
||||
|
||||
// ---- Home Assistant's colours on their way to the glass
|
||||
// Grey is what Home Assistant draws for something off; the add-on's timelines add a lighter grey for off and one for no
|
||||
// data. In dark those greys come from the table; every other state colour stays itself.
|
||||
constexpr uint32_t STATE_OFF = ha::GREY;
|
||||
inline uint32_t state(uint32_t color) {
|
||||
if (!dark) return color;
|
||||
if (color == STATE_OFF) return ROLES[OFF].dark;
|
||||
if (color == ROLES[HISTORY_OFF].light) return ROLES[HISTORY_OFF].dark;
|
||||
if (color == ROLES[HISTORY_EMPTY].light) return ROLES[HISTORY_EMPTY].dark;
|
||||
return color;
|
||||
}
|
||||
// Relative lightness 0-255 (Rec. 601 weights): enough to tell a colour that sinks into a dark card.
|
||||
inline uint32_t lightness(uint32_t color) {
|
||||
return ((color >> 16 & 0xFF) * 299 + (color >> 8 & 0xFF) * 587 + (color & 0xFF) * 114) / 1000;
|
||||
}
|
||||
// A state colour drawn as a glyph, a line or words on the page or a card. Dark lifts the deep ones (indigo, purple)
|
||||
// until they read on graphite.
|
||||
inline uint32_t foreground(uint32_t color) {
|
||||
color = state(color);
|
||||
if (!dark) return color;
|
||||
const uint32_t l = lightness(color);
|
||||
return l >= 110 ? color : mix(color, 0xFFFFFF, static_cast<uint8_t>(255 - (110 - l) * 3 / 2));
|
||||
}
|
||||
// The pale circle behind a tile's icon, a halo, a slider's track: the colour at `amount` over white in light, and
|
||||
// over the card in dark, where the same share would vanish, a little more.
|
||||
inline uint32_t tint(uint32_t color, uint8_t amount) {
|
||||
color = state(color);
|
||||
if (!dark) return mix(color, 0xFFFFFF, amount);
|
||||
const unsigned stronger = amount + amount / 3 + 8;
|
||||
return mix(color, hex(CARD), static_cast<uint8_t>(stronger > 255 ? 255 : stronger));
|
||||
}
|
||||
// A tile's icon in its state colour: a touch darker in light, so a pale bulb still shows; a touch lighter in dark.
|
||||
inline uint32_t icon(uint32_t color) {
|
||||
color = state(color);
|
||||
return dark ? mix(foreground(color), 0xFFFFFF, 230) : mix(color, 0x333333, 205);
|
||||
}
|
||||
// The soft area under a graph line: a fifth of the line's colour in light; less over graphite, where the same share
|
||||
// turns a large brown or purple block.
|
||||
inline uint8_t fill_opacity() { return dark ? 38 : 51; }
|
||||
// Keys drawn inside a card of any colour (a wide tile's direct controls): a shade away from the card, towards the
|
||||
// middle, so they read on white, on a pastel and on graphite alike.
|
||||
inline uint32_t key_on(uint32_t card, uint8_t depth) {
|
||||
return dark ? mix(card, 0xFFFFFF, static_cast<uint8_t>(255 - (255 - depth) * 3 / 2)) : mix(card, 0x000000, depth);
|
||||
}
|
||||
|
||||
} // namespace theme
|
||||
|
||||
#ifndef THEME_TEST
|
||||
#include "lvgl.h"
|
||||
#include <array>
|
||||
|
||||
namespace theme {
|
||||
|
||||
inline lv_color_t color(Role role) { return lv_color_hex(hex(role)); }
|
||||
inline lv_color_t rgb(uint32_t value) { return lv_color_hex(value); }
|
||||
|
||||
// ---- paints: shared styles filled from the table
|
||||
// A board profile gives a widget a paint instead of a colour (`styles: paint_card`). Its style definitions carry no
|
||||
// colour: the profile's `theme::paints` fills each one with fill(), at boot and after every change of look. The list
|
||||
// is code, not a table in RAM, and a shared style costs a widget one style reference, about what the local colour it
|
||||
// replaces cost. The firmware's own long-lived widgets take the same paints through style().
|
||||
constexpr uint8_t NONE = 0xFF;
|
||||
enum class Paint : uint8_t {
|
||||
page, page_soft, page_pressed, card, raised, key, key_pressed, track, ink, ink_soft, muted, subtle, slate,
|
||||
scrim, veil, accent_tint, accent_bright, button_dark, button_dark_pressed, knob, spinner, calibration, COUNT
|
||||
};
|
||||
struct PaintRoles { uint8_t bg, border, text, line, arc; };
|
||||
inline constexpr PaintRoles PAINTS[static_cast<size_t>(Paint::COUNT)] = {
|
||||
/* page */ {PAGE, NONE, INK, NONE, NONE},
|
||||
/* page_soft */ {PAGE_SOFT, NONE, INK, NONE, NONE},
|
||||
/* page_pressed */ {PAGE_PRESSED, NONE, NONE, NONE, NONE},
|
||||
/* card */ {CARD, LINE, INK, NONE, NONE},
|
||||
/* raised */ {RAISED, RAISED_LINE, INK, NONE, NONE},
|
||||
/* key */ {KEY, NONE, INK, NONE, NONE},
|
||||
/* key_pressed */ {KEY_PRESSED, NONE, NONE, NONE, NONE},
|
||||
/* track */ {TRACK, NONE, INK, NONE, NONE},
|
||||
/* ink */ {NONE, NONE, INK, NONE, NONE},
|
||||
/* ink_soft */ {NONE, NONE, INK_SOFT, NONE, NONE},
|
||||
/* muted */ {NONE, NONE, MUTED, NONE, NONE},
|
||||
/* subtle */ {NONE, NONE, SUBTLE, NONE, NONE},
|
||||
/* slate */ {NONE, SLATE, SLATE, SLATE, NONE},
|
||||
/* scrim */ {SCRIM, NONE, NONE, NONE, NONE},
|
||||
/* veil */ {VEIL, NONE, NONE, NONE, NONE},
|
||||
/* accent_tint */ {ACCENT_TINT, NONE, NONE, NONE, NONE},
|
||||
/* accent_bright */ {ACCENT_BRIGHT, NONE, NONE, NONE, NONE},
|
||||
/* button_dark */ {BUTTON_DARK, NONE, ON_ACCENT, NONE, NONE},
|
||||
/* button_dark_pressed */ {BUTTON_DARK_PRESSED, NONE, NONE, NONE, NONE},
|
||||
/* knob */ {KNOB, NONE, NONE, NONE, NONE},
|
||||
/* spinner */ {NONE, NONE, NONE, NONE, SPINNER_TRACK},
|
||||
/* calibration */ {CALIBRATION_PAGE, NONE, CALIBRATION_INK, NONE, NONE},
|
||||
};
|
||||
// Fill a style with a paint in the current look. Setting a property the style already has allocates nothing.
|
||||
inline void fill(lv_style_t *style, Paint paint) {
|
||||
if (!style) return;
|
||||
const PaintRoles &p = PAINTS[static_cast<size_t>(paint)];
|
||||
if (p.bg != NONE) lv_style_set_bg_color(style, color(static_cast<Role>(p.bg)));
|
||||
if (p.border != NONE) lv_style_set_border_color(style, color(static_cast<Role>(p.border)));
|
||||
if (p.text != NONE) lv_style_set_text_color(style, color(static_cast<Role>(p.text)));
|
||||
if (p.line != NONE) lv_style_set_line_color(style, color(static_cast<Role>(p.line)));
|
||||
if (p.arc != NONE) lv_style_set_arc_color(style, color(static_cast<Role>(p.arc)));
|
||||
}
|
||||
// The board profile's styles and the paint of each, as fill() calls. Set and run once in on_boot.
|
||||
inline void (*paints)() = nullptr;
|
||||
// The firmware's own shared style for a paint, made on first use.
|
||||
inline std::array<lv_style_t *, static_cast<size_t>(Paint::COUNT)> own{};
|
||||
inline lv_style_t *style(Paint paint) {
|
||||
auto *&s = own[static_cast<size_t>(paint)];
|
||||
if (!s) {
|
||||
s = new lv_style_t;
|
||||
lv_style_init(s);
|
||||
fill(s, paint);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
// What the board draws again after a change of look: the tiles, an open card, the settings page. Set by the profile.
|
||||
inline void (*redraw)() = nullptr;
|
||||
// The one way the look changes. Every paint is filled again and LVGL is told once; what code painted itself follows
|
||||
// through redraw(). A look the screen already has costs nothing, so the setting may be applied on every change.
|
||||
inline void set_dark(bool on) {
|
||||
if (on == dark) return;
|
||||
dark = on;
|
||||
if (paints) paints();
|
||||
for (size_t i = 0; i < own.size(); ++i) if (own[i]) fill(own[i], static_cast<Paint>(i));
|
||||
// One walk over every widget instead of one per paint.
|
||||
lv_obj_report_style_change(nullptr);
|
||||
if (redraw) redraw();
|
||||
}
|
||||
|
||||
} // namespace theme
|
||||
#endif
|
||||
@@ -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 <array>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
@@ -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;
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#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"; }
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-2
@@ -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.<screen>_normal_brightness` | `brightness` |
|
||||
| Dark mode (0.2.54+) | `switch.<screen>_dark_mode` | `dark_mode` |
|
||||
| Auto standby | `switch.<screen>_auto_standby` | `standby_enabled` |
|
||||
| Standby after | `number.<screen>_standby_after` | `standby_seconds` |
|
||||
| Standby brightness | `number.<screen>_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.<screen>_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 |
|
||||
|
||||
@@ -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_<name>` in the `style_definitions` of both profiles,
|
||||
and `theme::fill(id(paint_<name>), Paint::<name>);` 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.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
+164
-271
File diff suppressed because it is too large
Load Diff
+165
-275
File diff suppressed because it is too large
Load Diff
+165
-275
File diff suppressed because it is too large
Load Diff
+164
-271
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,15 @@
|
||||
## 0.2.63 (firmware 0.2.54)
|
||||
|
||||
Dark mode, for a screen beside the bed.
|
||||
|
||||
- **Dark mode** is a new switch under **Brightness** on the screen's settings page, in the **Screen settings** of ESP Screens and as `switch.<screen>_dark_mode` in Home Assistant, so an automation can turn it on at bedtime and off in the morning. The screen switches at once, open cards and an alert included, and keeps the look after a restart.
|
||||
- **The same design, darker.** Nothing moves: a black page, graphite cards with a fine edge, soft white text and grey secondary words. Home Assistant's colours stay where they tell you something: an amber lamp that is on, the orange of heating, the blue of a chosen key, the lines of a history graph. Pastel card colours become deep versions of the same colour, and a pale colour is lifted just enough to read on graphite.
|
||||
- **Every screen has it:** the tiles and their keys and sliders, the top bar, the weather, climate, light, cover, vacuum and history cards, the settings page, the alert and the busy spinner, on the CYD and the Guition.
|
||||
- **One place for colours.** Every colour the firmware draws now comes from one table (`components/smart_display/theme.h`) with a light and a dark value; the board profiles no longer write colours of their own. The light look is unchanged.
|
||||
- **A switch on the settings page shows where it is.** A switch that was on showed its knob at the left each time a settings page opened or turned, until you changed a setting. It now sits at the right from the first moment, which matters now that turning on Dark mode draws the page again.
|
||||
- **The sun card keeps its sunlight.** The area under the sun's arc turned orange after the card changed state and yellow again a minute later. It stays the soft yellow of the sun.
|
||||
- Needs firmware 0.2.54: press **Update** on the screen. ESP Screens shows the Dark mode switch only for a screen that has it.
|
||||
|
||||
## 0.2.62 (firmware 0.2.53)
|
||||
|
||||
On or off at a glance, in Home Assistant's words and colours.
|
||||
|
||||
@@ -15,7 +15,8 @@ import tile_icons
|
||||
from core import (ALERT_ENDINGS, ALERT_EVENT, ALERT_FALLBACK_ICON, ALERT_FIELDS, ALERT_LIMITS, ALERT_MAX_TIMEOUT,
|
||||
ALERT_MIN_FIRMWARE, ALERT_SUGGESTED_ICONS, AUTO_STANDBY_MIN_FIRMWARE, BROADCAST_DISMISS, BROADCAST_SHOW,
|
||||
CONTROLS, DISPLAYS, MAX_PAGES, SETTINGS_PAGE_MIN_FIRMWARE, SLOTS_PER_PAGE, TILE_BACKGROUNDS,
|
||||
TILE_EVENTS, TILE_RESULT_EVENT, WAKE_SLEEP_MIN_FIRMWARE, SETTING_ENTITIES_MIN_FIRMWARE)
|
||||
TILE_EVENTS, TILE_RESULT_EVENT, WAKE_SLEEP_MIN_FIRMWARE, SETTING_ENTITIES_MIN_FIRMWARE,
|
||||
DARK_MODE_MIN_FIRMWARE)
|
||||
|
||||
NAME = 'esp-screens'
|
||||
# claude.ai accepts at most 200 characters; Claude Code picks the skill by this sentence.
|
||||
@@ -263,6 +264,7 @@ Every screen has these entities in Home Assistant, on its ESPHome device. `<scre
|
||||
| `number.<screen>_normal_brightness` | Brightness while in use, 5 to 100 %. |
|
||||
| `number.<screen>_standby_brightness` | Brightness in standby, 0 to 100 %, at most the normal brightness. |
|
||||
| `number.<screen>_night_brightness` | Brightness in standby during the night hours, 0 to 100 %. |
|
||||
| `switch.<screen>_dark_mode` | On: the dark look, a black page with graphite cards and soft white text, for a screen beside a bed or in a dark room. Off: the light look. Firmware {DARK_MODE_MIN_FIRMWARE} or newer. |
|
||||
| `switch.<screen>_night_mode` | Night mode: the night brightness between the two times below. Firmware {SETTING_ENTITIES_MIN_FIRMWARE} or newer, like every row below it. |
|
||||
| `time.<screen>_night_starts`, `time.<screen>_night_ends` | The night hours; set them with `time.set_value`. |
|
||||
| `switch.<screen>_24_hour_clock` | On: 24-hour clock. Off: 12-hour clock. |
|
||||
@@ -342,11 +344,36 @@ actions:
|
||||
|
||||
Replace the person, light, window and screen with the real entity IDs (ask which screens when there are several), and leave out conditions the user did not ask for.
|
||||
|
||||
Dark mode follows the same pattern: switch it on and off from an automation, for example at bedtime and in the morning, or with the sun. It changes the colours only, never the tiles or the brightness; lower the brightness with the numbers above.
|
||||
|
||||
```yaml
|
||||
alias: Bedroom screen dark at night
|
||||
triggers:
|
||||
- trigger: time
|
||||
at: "21:30:00"
|
||||
id: night
|
||||
- trigger: time
|
||||
at: "07:00:00"
|
||||
id: day
|
||||
actions:
|
||||
- if:
|
||||
- condition: trigger
|
||||
id: night
|
||||
then:
|
||||
- action: switch.turn_on
|
||||
target:
|
||||
entity_id: switch.bedroom_screen_dark_mode
|
||||
else:
|
||||
- action: switch.turn_off
|
||||
target:
|
||||
entity_id: switch.bedroom_screen_dark_mode
|
||||
```
|
||||
|
||||
## The settings page on the screen itself
|
||||
|
||||
Firmware {SETTINGS_PAGE_MIN_FIRMWARE} or newer carries a settings page the user can open at the panel: hold the top bar (the strip with the screen's name and the clock) for about a second and a half, until the blue line along the top edge is full. A screen can also carry a tile for it: put the entity `screen.settings` on a screen like any other tile.
|
||||
|
||||
It holds Brightness, Night, Screen (clock, going back to page 1, swiping, rotation) and This screen (name, address, firmware, whether Home Assistant is connected, and Restart). A change there is saved on the screen and shows up in ESP Screens within a second, exactly like a change made from Home Assistant.
|
||||
It holds Brightness (with Dark mode), Night, Screen (clock, going back to page 1, swiping, rotation) and This screen (name, address, firmware, whether Home Assistant is connected, and Restart). A change there is saved on the screen and shows up in ESP Screens within a second, exactly like a change made from Home Assistant.
|
||||
|
||||
You can open it for someone who is standing at the panel:
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
||||
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
|
||||
REFS = {'cyd': 'main', 'guition': 'main'}
|
||||
# Firmware shipped with this app release; screens below it get an update offer.
|
||||
FIRMWARE_VERSION = '0.2.53'
|
||||
FIRMWARE_VERSION = '0.2.54'
|
||||
# The Auto standby switch a screen offers Home Assistant automations.
|
||||
AUTO_STANDBY_MIN_FIRMWARE = '0.2.41'
|
||||
# The settings page the screen opens itself, and the screen.settings tile that opens it.
|
||||
@@ -26,6 +26,8 @@ SETTINGS_PAGE_MIN_FIRMWARE = '0.2.44'
|
||||
WAKE_SLEEP_MIN_FIRMWARE = '0.2.45'
|
||||
# Every screen setting as an entity of the screen, which owns them (see SETTING_ENTITIES).
|
||||
SETTING_ENTITIES_MIN_FIRMWARE = '0.2.49'
|
||||
# Dark mode: the screen's dark look, a setting and entity of its own (components/smart_display/theme.h).
|
||||
DARK_MODE_MIN_FIRMWARE = '0.2.54'
|
||||
ATTRS = frozenset('brightness percentage current_position current_tilt_position current_temperature temperature current_humidity min_temp max_temp target_temp_step supported_color_modes hvac_modes hvac_action hs_color color_temp_kelvin min_color_temp_kelvin max_color_temp_kelvin fan_speed_list unit_of_measurement battery_level fan_speed volume_level is_volume_muted media_title options min max step temperature_unit supported_features device_class next_rising next_setting finishes_at duration remaining humidity wind_speed wind_speed_unit apparent_temperature fan_modes swing_modes fan_mode swing_mode'.split())
|
||||
# Attributes whose boolean value the screen needs; every other bool stays behind.
|
||||
BOOL_ATTRS = frozenset(['is_volume_muted'])
|
||||
@@ -175,12 +177,15 @@ SETTING_RULES = {
|
||||
# after this many seconds without a touch. Older firmware ignores both keys.
|
||||
'auto_home': (True, None, None),
|
||||
'auto_home_seconds': (120, 30, 3600),
|
||||
# The dark look for a screen beside a bed (firmware 0.2.54+). Only a screen that owns its settings has it: it
|
||||
# never travels in the layout message.
|
||||
'dark_mode': (False, None, None),
|
||||
}
|
||||
# Firmware before 0.2.44 accepts a `settings` object with exactly its own eleven keys and refuses any
|
||||
# other size, so everything added after it travels as its own key in the layout message. Old firmware
|
||||
# ignores a key it does not know; a new screen with an old add-on keeps what it saved itself.
|
||||
# docs/SETTINGS.md walks through adding one.
|
||||
SETTINGS_BESIDE_BLOCK = ('swipe_pages', 'rotation', 'auto_home', 'auto_home_seconds')
|
||||
SETTINGS_BESIDE_BLOCK = ('swipe_pages', 'rotation', 'auto_home', 'auto_home_seconds', 'dark_mode')
|
||||
|
||||
# ----- The screen owns its settings (firmware 0.2.49+) -----
|
||||
# A screen offers every setting as an entity of its own device, and the settings page on the screen, Home
|
||||
@@ -204,6 +209,7 @@ SETTING_ENTITIES = {
|
||||
'home_on_standby': ('switch', 'Back to page 1 on standby'),
|
||||
'swipe_pages': ('switch', 'Swipe between pages'),
|
||||
'rotation': ('select', 'Rotation'),
|
||||
'dark_mode': ('switch', 'Dark mode'),
|
||||
}
|
||||
# Entities firmware 0.2.49 added; one of them on a device means the screen owns its settings. The first five
|
||||
# existed before, so they cannot tell.
|
||||
|
||||
@@ -557,6 +557,8 @@ class Manager:
|
||||
values = validate_settings({})
|
||||
if (self.firmware_version(inbox, screen) or (0, 0, 0)) < (0, 2, 44):
|
||||
keys = [key for key in keys if key not in ('auto_home', 'auto_home_seconds')]
|
||||
# Dark mode came after the screens took over their settings: firmware that gets them with the layout lacks it.
|
||||
keys = [key for key in keys if key != 'dark_mode']
|
||||
return {'owner': 'layout', 'values': values, 'keys': keys, 'unavailable': []}
|
||||
# Only the settings this screen has an entity for: one added in later firmware stays out of the panel.
|
||||
keys = [key for key in keys if key in entities]
|
||||
|
||||
@@ -92,6 +92,7 @@ function select(id) {
|
||||
const SETTING_GROUPS = [
|
||||
{ title: "Brightness", icon: "F0599", rows: [
|
||||
{ key: "brightness", label: "Brightness", kind: "number", min: 5, max: 100, step: 5, unit: "%" },
|
||||
{ key: "dark_mode", label: "Dark mode", kind: "toggle" },
|
||||
{ key: "standby_enabled", label: "Auto standby", kind: "toggle" },
|
||||
{ key: "standby_seconds", label: "Standby after", kind: "duration", min: 60, max: 86400, needs: "standby_enabled" },
|
||||
{ key: "standby_brightness", label: "Standby brightness", kind: "number", min: 0, max: 100, step: 5, unit: "%", needs: "standby_enabled", cap: "brightness" },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: ESP Screen Manager
|
||||
version: "0.2.62"
|
||||
version: "0.2.63"
|
||||
slug: esp_screen_manager
|
||||
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
|
||||
url: https://github.com/MaxGramser/homeassistant_espscreen
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#define THEME_TEST
|
||||
#include "components/smart_display/alert_overlay.h"
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
@@ -12,11 +13,13 @@ int main() {
|
||||
// Unknown names, a codepoint the fonts lack and junk draw the warning triangle.
|
||||
for (const char *icon : {"", "unknown", "mdi:", "F0000", "F12E", "\xF0\x9F\x94\x94", "mdi:not-in-the-set"})
|
||||
assert(screen_alert::icon_codepoint(icon) == 0xF002A);
|
||||
// Colour: palette names in any case; empty, none and anything unknown keep the white card.
|
||||
// Colour: palette names in any case; empty, none and anything unknown keep the normal card.
|
||||
assert(make("x", "", "", "Red", "", 0, false, 48, 160, 12).color == 0xFADADD);
|
||||
assert(make("x", "", "", " mint ", "", 0, false, 48, 160, 12).color == 0xD5F0EA);
|
||||
for (const char *color : {"", "none", "auto", "#ff0000", "0xFF0000"})
|
||||
assert(make("x", "", "", color, "", 0, false, 48, 160, 12).color == 0xFFFFFF);
|
||||
assert(make("x", "", "", color, "", 0, false, 48, 160, 12).color == screen_alert::DEFAULT_CARD_COLOR);
|
||||
// The normal card is no colour of its own: the look draws it (theme::surface(0)).
|
||||
assert(screen_alert::DEFAULT_CARD_COLOR == 0);
|
||||
// Timeout: 0 and negatives wait for OK; a day is the ceiling.
|
||||
assert(make("x", "", "", "", "", 0, false, 48, 160, 12).timeout_seconds == 0);
|
||||
assert(make("x", "", "", "", "", -5, false, 48, 160, 12).timeout_seconds == 0);
|
||||
|
||||
@@ -34,10 +34,10 @@ class ReferenceTests(unittest.TestCase):
|
||||
|
||||
def test_colours_icons_and_fallback_match_the_firmware_headers(self):
|
||||
reference = alert_reference()
|
||||
palette = (ROOT / 'components/smart_display/tile_palette.h').read_text()
|
||||
self.assertEqual([c['name'] for c in reference['colors']], re.findall(r'if\(name=="(\w+)"\)return 0x', palette))
|
||||
palette = (ROOT / 'components/smart_display/theme.h').read_text()
|
||||
self.assertEqual([c['name'] for c in reference['colors']], re.findall(r'\{"(\w+)", 0x[0-9A-F]{6}, 0x[0-9A-F]{6}\}', palette))
|
||||
for colour in reference['colors']:
|
||||
self.assertIn(f'if(name=="{colour["name"]}")return 0x{colour["color"][1:]};', palette)
|
||||
self.assertRegex(palette, rf'\{{"{colour["name"]}", 0x{colour["color"][1:]}, 0x[0-9A-F]{{6}}\}}')
|
||||
self.assertEqual(colour['label'], TILE_BACKGROUNDS[colour['name']]['label'])
|
||||
header = (ROOT / 'components/smart_display/alert_overlay.h').read_text()
|
||||
self.assertIn(f'FALLBACK_ICON = "{reference["fallback_icon"]}"', header)
|
||||
|
||||
@@ -13,7 +13,7 @@ import claude_skill # noqa: E402
|
||||
import tile_icons # noqa: E402
|
||||
from core import (ALERT_EVENT, ALERT_FIELDS, ALERT_LIMITS, ALERT_MIN_FIRMWARE, AUTO_STANDBY_MIN_FIRMWARE, BROADCAST_DISMISS, # noqa: E402
|
||||
BROADCAST_SHOW, CONTROLS, DISPLAYS, TILE_BACKGROUNDS, TILE_EVENTS, TILE_RESULT_EVENT,
|
||||
WAKE_SLEEP_MIN_FIRMWARE)
|
||||
WAKE_SLEEP_MIN_FIRMWARE, DARK_MODE_MIN_FIRMWARE)
|
||||
|
||||
HAS_YAML = importlib.util.find_spec('yaml') is not None
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
@@ -56,6 +56,9 @@ class SkillText(unittest.TestCase):
|
||||
for entity in ('button.<screen>_wake', 'button.<screen>_sleep'):
|
||||
self.assertIn(f'`{entity}`', standby)
|
||||
self.assertIn(f'Firmware {WAKE_SLEEP_MIN_FIRMWARE} or newer', standby)
|
||||
# Dark mode (0.2.63): the switch and the firmware that has it.
|
||||
self.assertIn('`switch.<screen>_dark_mode`', standby)
|
||||
self.assertIn(f'Firmware {DARK_MODE_MIN_FIRMWARE} or newer', standby)
|
||||
self.assertIn('`button.press`', standby)
|
||||
self.assertIn('(#standby-and-brightness)', text, 'the intro links to the section')
|
||||
# Tiles (0.2.51): the events, what a tile can do per domain, how to read a screen and the rule to ask first.
|
||||
@@ -78,7 +81,7 @@ class SkillText(unittest.TestCase):
|
||||
def test_every_yaml_example_parses(self):
|
||||
import yaml
|
||||
blocks = re.findall(r'```yaml\n(.*?)```', claude_skill.text(), re.S)
|
||||
self.assertEqual(len(blocks), 9)
|
||||
self.assertEqual(len(blocks), 10)
|
||||
parsed = [yaml.safe_load(block) for block in blocks]
|
||||
# Tiles first (0.2.51): putting one on a screen, and ordering a page.
|
||||
self.assertEqual(parsed[0]['actions'][0]['event'], 'esp_screens_add_tile')
|
||||
@@ -112,6 +115,14 @@ class SkillText(unittest.TestCase):
|
||||
watched = set(awake['triggers'][0]['entity_id'])
|
||||
conditions = {branch['if'][0]['entity_id']} | {c['entity_id'] for c in branch['if'][1]['conditions']}
|
||||
self.assertEqual(watched, conditions, 'the automation reacts to every entity its condition reads')
|
||||
# Dark mode at night (0.2.63): one automation that turns the switch on at one time and off at the other.
|
||||
night = parsed[8]
|
||||
self.assertEqual({trigger['id'] for trigger in night['triggers']}, {'night', 'day'})
|
||||
branch = night['actions'][0]
|
||||
self.assertEqual((branch['if'][0]['condition'], branch['if'][0]['id']), ('trigger', 'night'))
|
||||
self.assertEqual((branch['then'][0]['action'], branch['else'][0]['action']), ('switch.turn_on', 'switch.turn_off'))
|
||||
for step in (branch['then'][0], branch['else'][0]):
|
||||
self.assertTrue(step['target']['entity_id'].endswith('_dark_mode'))
|
||||
|
||||
|
||||
class Archive(unittest.TestCase):
|
||||
|
||||
@@ -13,6 +13,7 @@ import header_bar # noqa: E402
|
||||
import tile_icons # noqa: E402
|
||||
|
||||
TILES = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
|
||||
THEME = (ROOT / 'components/smart_display/theme.h').read_text()
|
||||
|
||||
|
||||
class OffLook(unittest.TestCase):
|
||||
@@ -23,7 +24,9 @@ class OffLook(unittest.TestCase):
|
||||
|
||||
def test_the_screen_crosses_out_its_own_bulb_and_greys_what_is_off(self):
|
||||
self.assertIn('if (d == "light" && tile.state == "off") return "\\U000F0E4F";', TILES)
|
||||
self.assertIn('(t.is_switch()||d=="light"||d=="binary_sensor"||d=="person"||d=="timer") && !on ? 0x9E9E9E : accent', TILES)
|
||||
self.assertIn('(t.is_switch()||d=="light"||d=="binary_sensor"||d=="person"||d=="timer") && !on ? theme::STATE_OFF : accent', TILES)
|
||||
self.assertIn('constexpr uint32_t STATE_OFF = ha::GREY;', THEME)
|
||||
self.assertIn('constexpr uint32_t GREY = 0x9E9E9E;', THEME)
|
||||
|
||||
def test_the_top_bar_does_the_same(self):
|
||||
light = lambda state, **attrs: {'state': state, 'attributes': attrs}
|
||||
|
||||
@@ -47,11 +47,12 @@ ENTITY_IDS = {
|
||||
'clock_24h': 'switch.office_1_24_hour_clock', 'auto_home': 'switch.office_1_back_to_page_1',
|
||||
'auto_home_seconds': 'number.office_1_back_to_page_1_after', 'home_on_standby': 'switch.office_1_back_to_page_1_on_standby',
|
||||
'swipe_pages': 'switch.office_1_swipe_between_pages', 'rotation': 'select.office_1_rotation',
|
||||
'dark_mode': 'switch.office_1_dark_mode',
|
||||
}
|
||||
STATES = {'brightness': '80.0', 'standby_enabled': 'on', 'standby_seconds': '600.0', 'standby_brightness': '20.0',
|
||||
'night_enabled': 'on', 'night_start': '22:30:00', 'night_end': '07:00:00', 'night_brightness': '5.0',
|
||||
'clock_24h': 'on', 'auto_home': 'on', 'auto_home_seconds': '120.0', 'home_on_standby': 'off',
|
||||
'swipe_pages': 'on', 'rotation': '90°'}
|
||||
'swipe_pages': 'on', 'rotation': '90°', 'dark_mode': 'off'}
|
||||
|
||||
|
||||
def top_block(text, key):
|
||||
@@ -296,6 +297,34 @@ class OlderFirmware(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual([message['op'] for message, _ in m.ha.messages], ['layout'], 'the tiles are not sent again')
|
||||
self.assertEqual(m.ha.messages[0][0]['settings']['standby_seconds'], 1800)
|
||||
|
||||
async def test_dark_mode_needs_a_screen_that_owns_its_settings(self):
|
||||
# Firmware that gets its settings with the layout has no dark look: no row, no key on the wire, no change.
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = Manager(fake_ha(firmware='0.2.48', owned=False), Path(tmp) / 'screens.json')
|
||||
m.save('text.screen', {'title': 'Office 1', 'tiles': [{'entity': 'light.a', 'name': ''}],
|
||||
'settings': validate_settings({'dark_mode': True})})
|
||||
screen = m.screen('text.screen')
|
||||
self.assertNotIn('dark_mode', m.settings_view(screen)['keys'])
|
||||
message = m.layout_message('text.screen', m.layouts['text.screen'], screen)
|
||||
self.assertNotIn('dark_mode', message)
|
||||
self.assertNotIn('dark_mode', message['settings'], 'never inside the frozen eleven-key block')
|
||||
self.assertEqual(len(message['settings']), 11)
|
||||
with self.assertRaises(ValueError):
|
||||
await m.change_settings('text.screen', {'dark_mode': True})
|
||||
# Firmware 0.2.49-0.2.53 owns its settings but lacks the switch: left out, not unknown.
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = Manager(fake_ha(firmware='0.2.53'), Path(tmp) / 'screens.json')
|
||||
m.ha.registry = [item for item in m.ha.registry if item['entity_id'] != ENTITY_IDS['dark_mode']]
|
||||
view = m.settings_view(m.screen('text.screen'))
|
||||
self.assertNotIn('dark_mode', view['keys'] + view['unavailable'])
|
||||
# Firmware 0.2.54: the switch of the screen itself.
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = Manager(fake_ha(), Path(tmp) / 'screens.json')
|
||||
m.save('text.screen', {'title': 'Office 1', 'tiles': [{'entity': 'light.a', 'name': ''}]})
|
||||
self.assertFalse(m.settings_view(m.screen('text.screen'))['values']['dark_mode'])
|
||||
await m.change_settings('text.screen', {'dark_mode': True})
|
||||
self.assertEqual(m.ha.calls, [('switch.turn_on', {'entity_id': ENTITY_IDS['dark_mode']})])
|
||||
|
||||
async def test_back_to_page_1_needs_firmware_0_2_44(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = Manager(fake_ha(firmware='0.2.43', owned=False, guition=False), Path(tmp) / 'screens.json')
|
||||
|
||||
@@ -43,6 +43,11 @@ int main() {
|
||||
assert(moment_text(1320, false) == "10:00 PM" && moment_text(0, false) == "12:00 AM");
|
||||
assert(moment_text(750, false) == "12:30 PM" && moment_text(60, false) == "1:00 AM");
|
||||
|
||||
// ---- a switch's knob: inset at the left when off, flush with the same inset at the right when on ----
|
||||
// The Guition switch is 62 by 34 (a 28 px knob), the CYD one 40 by 22 (an 18 px knob).
|
||||
assert(knob_x(62, 34, false) == 3 && knob_x(62, 34, true) == 31 && 31 + 28 + 3 == 62);
|
||||
assert(knob_x(40, 22, false) == 2 && knob_x(40, 22, true) == 20 && 20 + 18 + 2 == 40);
|
||||
|
||||
// ---- rows read and write the real settings ----
|
||||
screen_settings::current = screen_settings::Settings{};
|
||||
const Page &light = pages[1];
|
||||
@@ -58,6 +63,14 @@ int main() {
|
||||
standby.write(90);
|
||||
assert(screen_settings::current.standby_brightness == 30); // never above the normal brightness
|
||||
assert(screen_settings::current.valid());
|
||||
// Dark mode (firmware 0.2.54+) sits right under Brightness, a switch of its own outside the frozen block.
|
||||
assert(light.count == 5 && light.rows[1].kind == Kind::toggle && std::string(light.rows[1].label) == "Dark mode");
|
||||
const Row &dark = row_named(light, "Dark mode");
|
||||
assert(value_text(dark) == "Off");
|
||||
dark.write(1);
|
||||
assert(dark_mode == 1 && value_text(dark) == "On");
|
||||
dark.write(0);
|
||||
assert(dark_mode == 0 && value_text(dark) == "Off");
|
||||
|
||||
const Page &screen = pages[3];
|
||||
const Row &clock = row_named(screen, "Clock");
|
||||
@@ -138,6 +151,7 @@ int main() {
|
||||
{"night_start", -5, 0}, {"night_end", 2000, 1439}, {"night_brightness", 7, 7}, {"clock_24h", 0, 0},
|
||||
{"home_on_standby", 1, 1}, {"swipe_pages", 2, 1}, {"rotation", 100, 90}, {"rotation", 400, 270},
|
||||
{"auto_home", 0, 0}, {"auto_home_seconds", 5, 30}, {"auto_home_seconds", 99999, 3600},
|
||||
{"dark_mode", 7, 1},
|
||||
};
|
||||
for (const auto &c : cases) {
|
||||
result = set(c.key, c.value);
|
||||
@@ -146,8 +160,15 @@ int main() {
|
||||
}
|
||||
assert(screen_settings::current.standby_seconds == 86400 && screen_settings::current.brightness == 100);
|
||||
assert(screen_settings::current.night_start == 0 && screen_settings::current.night_end == 1439);
|
||||
assert(swipe_pages == 1 && rotation == 270 && auto_home == 0 && auto_home_seconds == 3600);
|
||||
assert(swipe_pages == 1 && rotation == 270 && auto_home == 0 && auto_home_seconds == 3600 && dark_mode == 1);
|
||||
assert(screen_settings::current.valid());
|
||||
// Dark mode again is the same look: nothing stored, applied or reported; off is a change.
|
||||
{
|
||||
const int stored_before = stored, applied_before = applied;
|
||||
assert(set("dark_mode", 1) == SetResult::same && stored == stored_before && applied == applied_before);
|
||||
assert(set("dark_mode", 0) == SetResult::changed && last_key == "dark_mode" && last_value == 0 && dark_mode == 0);
|
||||
set("dark_mode", 1);
|
||||
}
|
||||
// A lower brightness pulls both dim levels down, and reports the brightness itself.
|
||||
set("standby_brightness", 60);
|
||||
set("night_brightness", 50);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// The colour table (firmware 0.2.54): the light look stays the design it was, the dark look reads well beside a bed.
|
||||
// clang++ -std=c++17 -Wall -Wextra -Werror -I. tests/test_theme.cpp -o /tmp/test_theme && /tmp/test_theme
|
||||
#define THEME_TEST
|
||||
#include "components/smart_display/theme.h"
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
|
||||
using namespace theme;
|
||||
|
||||
static double channel(uint32_t value) {
|
||||
const double c = value / 255.0;
|
||||
return c <= 0.04045 ? c / 12.92 : std::pow((c + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
static double luminance(uint32_t color) {
|
||||
return 0.2126 * channel(color >> 16 & 0xFF) + 0.7152 * channel(color >> 8 & 0xFF) + 0.0722 * channel(color & 0xFF);
|
||||
}
|
||||
// WCAG contrast ratio of two colours.
|
||||
static double contrast(uint32_t a, uint32_t b) {
|
||||
double la = luminance(a), lb = luminance(b);
|
||||
if (la < lb) std::swap(la, lb);
|
||||
return (la + 0.05) / (lb + 0.05);
|
||||
}
|
||||
static int channel_gap(uint32_t a, uint32_t b) {
|
||||
int gap = 0;
|
||||
for (int shift = 0; shift <= 16; shift += 8) gap = std::max(gap, std::abs(int(a >> shift & 0xFF) - int(b >> shift & 0xFF)));
|
||||
return gap;
|
||||
}
|
||||
static void look(bool on) { dark = on; }
|
||||
|
||||
int main() {
|
||||
// ---- the light look is the design as it was
|
||||
assert(ROLES[PAGE].light == 0xE7E7E7 && ROLES[CARD].light == 0xFFFFFF && ROLES[LINE].light == 0xDDDDDD);
|
||||
assert(ROLES[INK].light == 0x1B1B1B && ROLES[MUTED].light == 0x616161 && ROLES[SLATE].light == 0x46525E);
|
||||
assert(ROLES[ACCENT].light == 0x009FE3 && ROLES[OFF].light == ha::GREY && ROLES[KNOB].light == 0xFFFFFF);
|
||||
look(false);
|
||||
assert(hex(PAGE) == 0xE7E7E7 && hex(INK) == 0x1B1B1B);
|
||||
|
||||
// ---- mixing is LVGL's own: the ends are the two colours, the middle rounds down
|
||||
assert(mix(0x123456, 0xABCDEF, 255) == 0x123456 && mix(0x123456, 0xABCDEF, 0) == 0xABCDEF);
|
||||
assert(mix(0xFFFFFF, 0x000000, 128) == 0x808080);
|
||||
// The old tile circle (the state colour at 38 over white) and icon (at 205 over #333333) are exactly what tint() and
|
||||
// icon() give in the light look.
|
||||
for (uint32_t accent : {ha::AMBER, ha::TEAL, ha::INDIGO, ha::DEEP_ORANGE, ha::GREY}) {
|
||||
assert(tint(accent, 38) == mix(accent, 0xFFFFFF, 38));
|
||||
assert(icon(accent) == mix(accent, 0x333333, 205));
|
||||
assert(state(accent) == accent && foreground(accent) == accent);
|
||||
}
|
||||
// The halos and cover tracks that were written out by hand before are the same colours within two steps.
|
||||
assert(channel_gap(tint(ha::TEAL, 36), 0xDCF0EE) <= 2 && channel_gap(tint(ha::BLUE, 36), 0xE1EFFD) <= 2);
|
||||
assert(channel_gap(tint(ha::ORANGE, 36), 0xFFF0DA) <= 2 && channel_gap(tint(ha::RED, 36), 0xFDE4E2) <= 2);
|
||||
assert(channel_gap(tint(ha::SKY, 22), 0xEAF5FC) <= 2);
|
||||
assert(channel_gap(tint(ha::PURPLE, 37), 0xEFE8F7) <= 2 && channel_gap(tint(ha::PURPLE, 80), 0xDDD0EF) <= 2);
|
||||
assert(fill_opacity() == 51);
|
||||
|
||||
// ---- named card colours: identity in light, deep in dark, the normal card for none or an unknown colour
|
||||
assert(SWATCH_COUNT == 9 && swatch("red") == 0xFADADD && swatch("gray") == 0xE5E7EB && swatch("none") == 0 && swatch("") == 0);
|
||||
assert(surface(0) == 0xFFFFFF && surface(0xFADADD) == 0xFADADD && surface(0x123456) == 0x123456);
|
||||
assert(outline(0) == 0xDDDDDD && outline(0xFADADD) == mix(0xFADADD, 0x000000, 220));
|
||||
assert(key_on(0xFFFFFF, 236) == mix(0xFFFFFF, 0x000000, 236));
|
||||
|
||||
// ---- the dark look
|
||||
look(true);
|
||||
assert(hex(PAGE) == 0x000000);
|
||||
assert(surface(0) == ROLES[CARD].dark && surface(0x123456) == ROLES[CARD].dark);
|
||||
for (const auto &s : SWATCHES) {
|
||||
assert(surface(s.light) == s.dark);
|
||||
// Light words read on every dark card colour, the slate of a value too.
|
||||
assert(contrast(ROLES[INK].dark, s.dark) >= 7.0);
|
||||
assert(contrast(ROLES[SLATE].dark, s.dark) >= 4.5);
|
||||
assert(contrast(ROLES[INK].light, s.light) >= 7.0);
|
||||
}
|
||||
// Text reads in both looks: names and values, secondary and quiet words, the top bar on the page.
|
||||
for (bool on : {false, true}) {
|
||||
look(on);
|
||||
assert(contrast(hex(INK), hex(CARD)) >= 7.0 && contrast(hex(INK), hex(PAGE)) >= 7.0);
|
||||
assert(contrast(hex(MUTED), hex(CARD)) >= 4.5 && contrast(hex(SUBTLE), hex(CARD)) >= 4.5);
|
||||
assert(contrast(hex(SLATE), hex(PAGE)) >= 4.5 && contrast(hex(INK_SOFT), hex(RAISED)) >= 4.5);
|
||||
assert(contrast(hex(INK), hex(RAISED)) >= 7.0 && contrast(hex(INK), hex(TRACK)) >= 7.0);
|
||||
assert(contrast(hex(ON_ACCENT), hex(BUTTON_DARK)) >= 4.5);
|
||||
}
|
||||
// The blue keys keep at least the contrast they always had (white on #009FE3, just under 3:1).
|
||||
look(false);
|
||||
const double blue_light = contrast(hex(ON_ACCENT), hex(ACCENT));
|
||||
look(true);
|
||||
assert(blue_light > 2.9 && contrast(hex(ON_ACCENT), hex(ACCENT)) >= blue_light);
|
||||
look(true);
|
||||
// Surfaces step up from the black page, a raised card and a key under a finger stand out.
|
||||
assert(lightness(hex(PAGE)) < lightness(hex(CARD)) && lightness(hex(CARD)) < lightness(hex(LINE)));
|
||||
assert(lightness(hex(CARD)) < lightness(hex(RAISED)) && lightness(hex(KEY)) < lightness(hex(KEY_PRESSED)));
|
||||
assert(lightness(hex(CARD)) < 40 && lightness(hex(INK)) > 200);
|
||||
// Dark greys land on the same step of red, green and blue on the panel (RGB565), so they stay grey.
|
||||
for (int role = 0; role < ROLE_COUNT; ++role) {
|
||||
const uint32_t c = ROLES[role].dark;
|
||||
const uint32_t r = c >> 16 & 0xFF, g = c >> 8 & 0xFF, b = c & 0xFF;
|
||||
if (r == g && g == b && r > 0 && r < 0xFF) assert((r >> 3) * 2 == (g >> 2));
|
||||
}
|
||||
|
||||
// ---- Home Assistant's colours in the dark look
|
||||
assert(state(ha::GREY) == ROLES[OFF].dark && state(0xCFCFCF) == ROLES[HISTORY_OFF].dark && state(0xE6E6E6) == ROLES[HISTORY_EMPTY].dark);
|
||||
assert(state(ha::AMBER) == ha::AMBER && state(ha::TEAL) == ha::TEAL);
|
||||
// Bright state colours stay themselves; the deep ones are lifted until they read on graphite.
|
||||
assert(foreground(ha::AMBER) == ha::AMBER && foreground(ha::LIGHT_BLUE) == ha::LIGHT_BLUE);
|
||||
for (uint32_t deep : {ha::INDIGO, ha::DEEP_PURPLE, uint32_t{0}}) {
|
||||
assert(lightness(foreground(deep)) > lightness(deep));
|
||||
assert(contrast(foreground(deep), hex(CARD)) >= 3.0);
|
||||
}
|
||||
// A circle stays dark and tinted, its icon light; a slider track stays dark.
|
||||
for (uint32_t accent : {ha::AMBER, ha::TEAL, ha::INDIGO, ha::GREY, ha::DEEP_ORANGE}) {
|
||||
assert(lightness(tint(accent, 38)) < 110 && lightness(tint(accent, 51)) < 120);
|
||||
assert(contrast(icon(accent), tint(accent, 38)) >= 3.0);
|
||||
}
|
||||
// Keys in a card are lighter than the card, its hairline too.
|
||||
assert(lightness(key_on(hex(CARD), 236)) > lightness(hex(CARD)));
|
||||
assert(lightness(outline(0xFADADD)) > lightness(surface(0xFADADD)));
|
||||
assert(fill_opacity() < 51);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Colours live in one place, and Dark mode reaches every screen (app 0.2.63 / firmware 0.2.54).
|
||||
|
||||
components/smart_display/theme.h holds every colour the firmware chooses: roles with a light and a dark value, the named
|
||||
card colours and Home Assistant's state colours. The board profiles name paints (shared LVGL styles theme.h fills) and
|
||||
the firmware asks theme.h for roles, so a new look is a refill of a few styles plus one redraw. These tests keep it that
|
||||
way: no colour written anywhere else, every paint defined and filled, and the look applied at boot and after
|
||||
every settings change. tests/test_theme.cpp checks the values themselves.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
from core import DARK_MODE_MIN_FIRMWARE, FIRMWARE_VERSION, SETTING_ENTITIES, SETTING_RULES, SETTINGS_BESIDE_BLOCK # noqa: E402
|
||||
|
||||
COMPONENT = ROOT / 'components/smart_display'
|
||||
THEME = (COMPONENT / 'theme.h').read_text()
|
||||
PROFILES = [ROOT / 'home-like-2432s028.yaml', ROOT / 'guition-4848s040.yaml', ROOT / 'packages/cyd.yaml', ROOT / 'packages/guition.yaml']
|
||||
HEX = re.compile(r'0x[0-9A-Fa-f]{6}\b')
|
||||
# What may still carry a number that looks like a colour: the light's colour picker shows the colours a light can take,
|
||||
# a mask and a codepoint check are no colours, and the value card's colour key is a wheel of fixed hues.
|
||||
ALLOWED_IN_FIRMWARE = {
|
||||
'light_controls.h': ('constexpr uint32_t WARM = 0xFF9C32, COOL = 0xC6E6FF;',
|
||||
'inline constexpr uint32_t RAINBOW[] = {0xFF0000, 0xFFFF00, 0x00FF00, 0x00FFFF, 0x0000FF, 0xFF00FF, 0xFF0000};'),
|
||||
'tile_icon.h': ('if (cp < 0x10000 || cp > 0x10FFFF) return {};',),
|
||||
'runtime_tiles.h': ('state_color=lv_color_to_u32(lv_color_hsv_to_rgb(t.hue%360,t.saturation,100))&0xFFFFFF;',),
|
||||
}
|
||||
COLOUR_WHEEL = {'0xFF3B30', '0xFF9500', '0xFFD60A', '0x30D158', '0x32D6FF', '0x0A84FF', '0x5856D6', '0xBF5AF2'}
|
||||
|
||||
|
||||
def paint_names():
|
||||
block = re.search(r'enum class Paint : uint8_t \{(.*?)\};', THEME, re.S)[1]
|
||||
return [name.strip() for name in block.replace('\n', ' ').split(',') if name.strip() and name.strip() != 'COUNT']
|
||||
|
||||
|
||||
class OnePlace(unittest.TestCase):
|
||||
def test_no_header_writes_a_colour_of_its_own(self):
|
||||
for path in sorted(COMPONENT.glob('*.h')):
|
||||
if path.name == 'theme.h':
|
||||
continue
|
||||
for number, line in enumerate(path.read_text().splitlines(), 1):
|
||||
if HEX.search(line):
|
||||
self.assertIn(line.strip(), ALLOWED_IN_FIRMWARE.get(path.name, ()), f'{path.name}:{number}: a colour outside theme.h')
|
||||
|
||||
def test_no_profile_writes_a_colour_of_its_own(self):
|
||||
for path in PROFILES:
|
||||
text = path.read_text()
|
||||
wheel = re.findall(r'^\s+arc_color: (0x[0-9A-F]{6})$', text, re.M)
|
||||
self.assertEqual(set(wheel), COLOUR_WHEEL, path.name)
|
||||
everything = HEX.findall(text)
|
||||
self.assertEqual(sorted(everything), sorted(wheel), f'{path.name}: only the colour wheel keeps fixed hues')
|
||||
self.assertNotIn('BG_TOP_COLOR', text)
|
||||
self.assertNotIn('ACCENT_AUTO', text)
|
||||
self.assertNotRegex(text, r'^\s+(bg|text|border|bg_grad|line|outline|shadow)_color:', path.name)
|
||||
|
||||
def test_every_role_has_a_light_and_a_dark_value(self):
|
||||
roles = re.search(r'enum Role : uint8_t \{(.*?)ROLE_COUNT', THEME, re.S)[1]
|
||||
names = re.findall(r'^\s+([A-Z_]+),', roles, re.M)
|
||||
values = re.findall(r'/\* (\w+) \*/\s+\{0x([0-9A-F]{6}), 0x([0-9A-F]{6})\}', THEME)
|
||||
self.assertEqual(names, [name for name, _, _ in values], 'the table follows the enum, one row per role')
|
||||
self.assertGreater(len(names), 40)
|
||||
|
||||
|
||||
class Paints(unittest.TestCase):
|
||||
def test_every_paint_is_defined_adopted_and_used_in_every_profile(self):
|
||||
paints = paint_names()
|
||||
for path in PROFILES:
|
||||
text = path.read_text()
|
||||
defined = re.findall(r'^ - id: (paint_\w+)$', text, re.M)
|
||||
used = set(re.findall(r'styles: (paint_\w+)$', text, re.M))
|
||||
filling = text.split('theme::paints = []() {', 1)[1].split('\n };', 1)[0]
|
||||
explicit = re.findall(r'theme::fill\(id\((\w+)\), Paint::(\w+)\);', filling)
|
||||
listed = re.search(r'for \(auto \*style : \{([^}]*)\}\) theme::fill\(style, Paint::(\w+)\);', filling)
|
||||
looped = [(name, listed[2]) for name in re.findall(r'id\((\w+)\)', listed[1])]
|
||||
adopted = dict(explicit + looped)
|
||||
self.assertEqual(len(adopted), len(explicit) + len(looped), f'{path.name}: every style is filled once')
|
||||
self.assertEqual(text.count('theme::fill('), len(explicit) + 1, f'{path.name}: styles are filled in theme::paints only')
|
||||
self.assertEqual(set(defined), used, f'{path.name}: every paint style is used, every used one defined')
|
||||
for style in defined + ['style_page', 'style_tile', 'style_icon_circle', 'style_room', 'style_time', 'style_title',
|
||||
'style_value', 'style_value_big']:
|
||||
self.assertIn(style, adopted, f'{path.name}: {style} takes its colours from theme.h')
|
||||
self.assertIn(adopted[style], paints, f'{path.name}: {style}')
|
||||
for style, paint in adopted.items():
|
||||
if style.startswith('paint_'):
|
||||
self.assertEqual(style, f'paint_{paint}', f'{path.name}: a paint style is named after its paint')
|
||||
# The styles that carry colours have none written down.
|
||||
styles = re.search(r'^ style_definitions:\n(.*?)^ # ---------- ALERT', text, re.M | re.S)[1]
|
||||
self.assertNotRegex(styles, r'_color:', path.name)
|
||||
|
||||
def test_the_look_is_applied_at_boot_after_a_change_and_drawn_again(self):
|
||||
for path in PROFILES:
|
||||
text = path.read_text()
|
||||
boot = text.split('runtime_tiles::load_settings();', 1)[1].split('runtime_tiles::settings_changed', 1)[0]
|
||||
self.assertIn('theme::paints();\n theme::set_dark(settings_screen::dark_mode != 0);', boot,
|
||||
f'{path.name}: the paints filled, then the saved look, before the first frame')
|
||||
apply = text.split(' - id: apply_screen_settings\n', 1)[1].split('\n - id: ', 1)[0]
|
||||
self.assertIn('theme::set_dark(settings_screen::dark_mode != 0);', apply, f'{path.name}: a change of the setting')
|
||||
redraw = text.split('theme::redraw = []() {', 1)[1].split('};', 1)[0]
|
||||
for needle in ('runtime_tiles::restyle();', 'light_controls::restyle();', 'settings_screen::restyle();',
|
||||
'theme::surface(id(alert_card_color))', 'id(climate_card_refresh).execute();',
|
||||
'id(open_climate_mode_picker).execute();'):
|
||||
self.assertIn(needle, redraw, f'{path.name}: {needle}')
|
||||
self.assertIn('id(alert_card_color) = alert.color;', text)
|
||||
self.assertIn('lv_color_hex(theme::surface(alert.color))', text)
|
||||
|
||||
def test_the_firmware_draws_long_lived_parts_with_paints(self):
|
||||
tiles = (COMPONENT / 'runtime_tiles.h').read_text()
|
||||
for needle in ('lv_obj_add_style(w.slider,theme::style(theme::Paint::knob),LV_PART_KNOB);',
|
||||
'lv_obj_add_style(w.busy,theme::style(theme::Paint::veil),0);',
|
||||
'lv_obj_add_style(w.spinner,theme::style(theme::Paint::spinner),LV_PART_MAIN);',
|
||||
'lv_obj_add_style(part, theme::style(theme::Paint::slate), 0);',
|
||||
'inline void restyle() {'):
|
||||
self.assertIn(needle, tiles)
|
||||
# Dark mode is kept in a preference of its own, next to the others outside the frozen settings block.
|
||||
self.assertIn('make_preference<uint32_t>(0x44524B31)', tiles)
|
||||
self.assertIn('inline void restyle() {', (COMPONENT / 'settings_screen.h').read_text())
|
||||
self.assertIn('inline void restyle() {', (COMPONENT / 'light_controls.h').read_text())
|
||||
|
||||
|
||||
class RedrawnTheSame(unittest.TestCase):
|
||||
"""A change of look draws again what was drawn, and the renders compare a screen that switched with one that
|
||||
booted in that look. Two differences they found were older than Dark mode: what a page showed depended on the
|
||||
order it was drawn in."""
|
||||
|
||||
def test_a_switch_knob_follows_the_size_it_was_given(self):
|
||||
page = (COMPONENT / 'settings_screen.h').read_text()
|
||||
move = page.split('inline void move_knob(', 1)[1].split('\n}\n', 1)[0]
|
||||
# draw() ends in refresh() before LVGL has laid the new rows out, when coordinates still read 0.
|
||||
self.assertNotIn('lv_obj_get_width', move)
|
||||
self.assertNotIn('lv_obj_get_height', move)
|
||||
self.assertIn('knob_x(lv_obj_get_style_width(track, LV_PART_MAIN), lv_obj_get_style_height(track, LV_PART_MAIN), on)', move)
|
||||
pill = page.split('inline lv_obj_t *pill(', 1)[1].split('\n}\n', 1)[0]
|
||||
self.assertIn('plain(track, knob_x(w, h, on), inset, size, size)', pill)
|
||||
|
||||
def test_the_sun_path_keeps_its_own_fill_after_a_palette_change(self):
|
||||
tiles = (COMPONENT / 'runtime_tiles.h').read_text()
|
||||
self.assertIn('w.fill_color=lv_color_hex(theme::ha::SUNNY);', tiles)
|
||||
self.assertIn('if(w.extra_mode!="sunpath")w.fill_color=color;', tiles)
|
||||
self.assertEqual(len(re.findall(r'\bw\.fill_color=', tiles)), 2, 'the sun path and the palette are the only two')
|
||||
|
||||
|
||||
class Setting(unittest.TestCase):
|
||||
def test_dark_mode_is_a_setting_the_screen_owns(self):
|
||||
self.assertEqual(SETTING_RULES['dark_mode'], (False, None, None))
|
||||
self.assertIn('dark_mode', SETTINGS_BESIDE_BLOCK, 'never inside the eleven-key block older firmware insists on')
|
||||
self.assertEqual(SETTING_ENTITIES['dark_mode'], ('switch', 'Dark mode'))
|
||||
self.assertEqual(DARK_MODE_MIN_FIRMWARE, '0.2.54')
|
||||
self.assertLessEqual(tuple(map(int, DARK_MODE_MIN_FIRMWARE.split('.'))), tuple(map(int, FIRMWARE_VERSION.split('.'))))
|
||||
page = (COMPONENT / 'settings_screen.h').read_text()
|
||||
self.assertIn('toggle("Dark mode", []() -> int32_t { return dark_mode; },', page)
|
||||
self.assertIn('else if (key == "dark_mode") reported = dark_mode = flag(value);', page)
|
||||
editor = (ROOT / 'screen_manager/app/static/app.js').read_text()
|
||||
self.assertIn('{ key: "dark_mode", label: "Dark mode", kind: "toggle" },', editor)
|
||||
brightness = editor.split('{ title: "Brightness"', 1)[1].split('] },', 1)[0]
|
||||
self.assertLess(brightness.index('"brightness"'), brightness.index('"dark_mode"'), 'right under Brightness, as on the screen')
|
||||
self.assertLess(brightness.index('"dark_mode"'), brightness.index('"standby_enabled"'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,4 +1,5 @@
|
||||
// c++ -std=c++17 -Wall -Wextra -pedantic tests/test_tile_controls.cpp -o /tmp/test_tile_controls && /tmp/test_tile_controls
|
||||
#define THEME_TEST
|
||||
#include "../components/smart_display/tile_controls.h"
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
#define THEME_TEST
|
||||
#include "components/smart_display/tile_palette.h"
|
||||
#include <cassert>
|
||||
int main() {
|
||||
assert(tile_palette::color("auto")==0);
|
||||
assert(tile_palette::color("")==0);
|
||||
assert(tile_palette::color("unknown-future-choice")==0);
|
||||
assert(tile_palette::color("red")!=0);
|
||||
assert(tile_palette::color("red")==0xFADADD);
|
||||
assert(tile_palette::color("green")!=tile_palette::color("red"));
|
||||
// "None" is not a colour: the card is hidden, and old firmware falls back to automatic.
|
||||
assert(tile_palette::color("none")==0);
|
||||
|
||||
@@ -12,8 +12,9 @@ def luminance(color):
|
||||
|
||||
class PaletteTests(unittest.IsolatedAsyncioTestCase):
|
||||
def test_palette_matches_firmware_and_has_readable_text(self):
|
||||
header=(Path(__file__).resolve().parents[1]/'components/smart_display/tile_palette.h').read_text()
|
||||
native=dict(re.findall(r'name=="(\w+)"\)return 0x([A-F0-9]+)',header))
|
||||
# The named card colours live in theme.h (firmware 0.2.54+): name, light value, dark value.
|
||||
header=(Path(__file__).resolve().parents[1]/'components/smart_display/theme.h').read_text()
|
||||
native={name:light for name,light,_ in re.findall(r'\{"(\w+)", 0x([A-F0-9]{6}), 0x([A-F0-9]{6})\}',header)}
|
||||
expected={k:v['color'][1:] for k,v in TILE_BACKGROUNDS.items() if v['color']}
|
||||
self.assertEqual(native,expected)
|
||||
for color in expected.values():
|
||||
|
||||
Reference in New Issue
Block a user