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