Files
homeassistant_espscreen/components/smart_display/runtime_tiles.h
T
Max Gramser 0ebb23a1f4 Make page swipes smooth and give lights a new colour card (0.2.42)
Page switches show the new page as empty cards at once and draw two cards per
LVGL refresh, so touch is polled between the steps; on the Guition the longest
loop gap during a swipe dropped from 276 to 74 ms (docs/SWIPE_PROFILE.md).
Custom parts and control panels are reused instead of rebuilt, state updates
and ticks redraw only their own card, clocks redraw once a minute, and a swipe
past the last page draws nothing.

Slider handles sit inside the fill (grey when the card is off), the busy sheet
covers the whole card, and the light colour card is redesigned with rounded
tracks and colour knobs. Firmware 0.2.36; SWIPE_PROFILE builds carry a
per-swipe profiler for measurements.

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

2081 lines
132 KiB
C++

#pragma once
#include "runtime_model.h"
#include "header_bar.h"
#include "tile_palette.h"
#include "tile_icon.h"
#include "screen_settings.h"
#include "esphome/core/preferences.h"
#include "cyd_ui.h"
#include "light_controls.h"
#include "tile_controls.h"
#include "swipe_profile.h"
#include "esphome/components/json/json_util.h"
#include "esphome/components/api/api_server.h"
#include "esphome/core/hal.h"
#include "esphome/core/util.h"
#include "esphome/core/time.h"
#include "lvgl.h"
#include <functional>
#include <algorithm>
namespace runtime_tiles {
inline bool enabled = false;
inline bool swipe_pages = false;
inline uint32_t rotation = 0;
inline bool rotation_supported = false;
inline esphome::ESPPreferenceObject rotation_preference;
inline esphome::ESPPreferenceObject swipe_preference;
inline Model model;
inline std::string inbox;
inline const lv_font_t *watch_font = nullptr;
inline const lv_font_t *mini_icon_font = nullptr;
inline const lv_font_t *watch_value_font = nullptr, *watch_icon_font = nullptr;
// Big digits for the clock card; the board profile sets it with the local time source.
inline const lv_font_t *clock_font = nullptr;
// Text in the -/+ pill and the run key of direct controls; the board profile sets it.
inline const lv_font_t *control_font = nullptr;
inline lv_obj_t *room_label = nullptr; // remembered by render() so page switches can render synchronously
// Top bar (0.2.32+): the profile's clock label only lends its place and margin; values use the
// profile's text font, icons its small icon font. Set by the board profile at boot.
inline header_bar::Bar header;
inline lv_obj_t *time_label = nullptr;
inline const lv_font_t *header_text_font = nullptr, *header_icon_font = nullptr;
inline void render_header();
inline std::function<esphome::ESPTime()> now_time;
inline uint32_t now_epoch() { if (!now_time) return 0; auto t = now_time(); return t.is_valid() ? static_cast<uint32_t>(t.timestamp) : 0; }
inline void tick();
inline void refresh_tile(size_t index);
#ifdef SWIPE_PROFILE
inline void swipe_test(unsigned count, unsigned interval_ms, unsigned back_ms, JsonObject tuning);
#endif
inline void refresh_header_only();
inline void refresh_all();
inline void refresh_detail(unsigned index);
inline const char *weather_icon(const std::string &condition);
inline const char *weather_text(const std::string &condition);
inline std::string timer_text(const Tile &t);
inline std::string last_run_text(uint32_t epoch);
inline int active_index = -1;
inline uint32_t last_received = 0;
// Seconds between the manager's full repeats; every layout message declares it (app
// 0.2.26+). Older managers get the 120 s that app 0.2.20 introduced.
inline uint32_t keepalive_seconds = 120;
// Revision of the layout the manager sent last (app 0.2.39+). A keepalive ping carries the
// manager's revision; a mismatch (a restart, a demo layout) asks for the whole layout again.
inline std::string layout_rev;
inline std::function<void()> layout_changed, refresh, dismiss, settings_changed;
inline esphome::ESPPreferenceObject settings_preference;
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);
uint32_t swipe_saved=0;
if(swipe_preference.load(&swipe_saved))swipe_pages=swipe_saved==1;
if(rotation_supported){
rotation_preference=esphome::global_preferences->make_preference<uint32_t>(0x524F5431);
uint32_t saved=0;
if(rotation_preference.load(&saved) && saved<=270 && saved%90==0)rotation=saved;
}
screen_settings::Settings saved;
if (settings_preference.load(&saved) && saved.valid()) screen_settings::current = saved;
}
inline bool parse_settings(JsonObject obj, screen_settings::Settings &s) {
// Require the complete known schema; validate before touching any runtime state.
if (obj.size() != 11) return false;
const char *flags[] = {"standby_enabled", "night_enabled", "show_clock", "clock_24h", "home_on_standby"};
int32_t *flag_values[] = {&s.standby_enabled, &s.night_enabled, &s.show_clock, &s.clock_24h, &s.home_on_standby};
for (int i = 0; i < 5; ++i) {
if (!obj[flags[i]].is<bool>()) return false;
*flag_values[i] = obj[flags[i]].as<bool>();
}
const char *numbers[] = {"standby_seconds", "brightness", "standby_brightness", "night_start", "night_end", "night_brightness"};
int32_t *values[] = {&s.standby_seconds, &s.brightness, &s.standby_brightness, &s.night_start, &s.night_end, &s.night_brightness};
for (int i = 0; i < 6; ++i) {
if (!obj[numbers[i]].is<int>() || obj[numbers[i]].is<bool>()) return false;
*values[i] = obj[numbers[i]].as<int>();
}
return s.valid();
}
inline std::function<void(Tile &)> detail, detail_update;
struct Widgets {
lv_obj_t *tile{}, *title{}, *value{}, *circle{}, *icon{}; size_t index{}; int cached_active = -1; lv_obj_t *slider{}, *progress{}, *unit{};
int title_x=0,title_y=0,value_x=0,value_y=0; const lv_font_t *value_font{}, *icon_font{};
// Wide cards span both columns; custom cards (clock, forecast, graph) draw into `extra`.
bool wide=false; int base_width=0; lv_obj_t *extra{}; std::string extra_mode; std::array<lv_obj_t *, 20> parts{}; lv_point_precise_t *points{};
// Soft area under a polyline (graph, sun path), painted by the extra container's draw event.
const lv_point_precise_t *fill_points{}; unsigned fill_count=0; int fill_x=0, fill_y=0, fill_base=0; lv_color_t fill_color{}; lv_opa_t fill_opa=0;
// Direct controls on a wide card: a panel at the right with pill keys, a -/+ pill,
// a slider or a toggle. Objects are rebuilt only when the control set changes.
lv_obj_t *panel{}; std::string panel_mode; bool panel_dirty=false; int panel_w=0;
std::array<lv_obj_t *,3> keys{}, key_icons{}; std::array<int,3> key_commands{}; std::array<std::string,3> key_args; std::array<int,3> key_checked{};
lv_obj_t *pill{}, *pill_value{}, *knob{}, *control_slider{}; int knob_on=-1;
lv_color_t panel_accent{}, panel_text{};
// Busy sheet: a translucent white cover with a small spinner while a command is under way.
lv_obj_t *busy{}, *spinner{};
// Page fill skeleton: a sheet in the card's colour over its contents until the card is drawn.
lv_obj_t *veil{};
};
constexpr unsigned POINT_BUFFER = 128;
inline std::array<Widgets, 10> widgets;
// All icon fonts carry the same generated glyph set, so the first bound one answers for all.
inline bool has_icon_glyph(uint32_t codepoint) {
for (auto &w : widgets) if (w.icon_font) { lv_font_glyph_dsc_t dsc; return lv_font_get_glyph_dsc(w.icon_font, &dsc, codepoint, 0); }
return false;
}
// Home Assistant's API link as ESPHome itself tracks it: gone the moment the socket drops,
// back the moment HA reconnects, no guessing from message age.
inline bool ha_connected() { return esphome::api_is_connected(); }
// The manager repeats the whole layout every keepalive; one missed round plus its 20 s
// loop slack and the sending itself are tolerated before the feed counts as gone.
inline bool feed_alive() { return esphome::millis() - last_received < keepalive_seconds * 2000 + 60000; }
inline bool fresh() { return model.ready() && ha_connected() && feed_alive(); }
// A dropped tap is logged with its reason, so a missed touch can be read from the ESPHome log
// instead of guessed: moved too far, too short, already used by this contact, or bounce.
inline bool allowed(uint32_t now, int tile, const std::string &what) {
if (cyd::touch_guard.accept(now, tile)) return true;
ESP_LOGI("touch", "tap on %s ignored: %s", what.c_str(), cyd::touch_guard.reason().c_str());
return false;
}
inline float number(JsonVariant value, float fallback = NAN) {
if (!value.is<float>() && !value.is<int>()) return fallback;
float n = value.as<float>();
return std::isfinite(n) ? n : fallback;
}
inline std::string string(JsonVariant value, size_t maximum = 160) {
if (!value.is<const char *>()) return {};
std::string s = value.as<std::string>();
if (s.size() > maximum) {
while (maximum > 0 && (static_cast<unsigned char>(s[maximum]) & 0xC0) == 0x80) --maximum;
s.resize(maximum);
}
return s;
}
inline std::string list(JsonVariant value) {
if (!value.is<JsonArray>()) return {};
std::string out;
serializeJson(value, out);
return out.size() <= 512 ? out : "";
}
inline std::string receive(const std::string &payload) {
if (!enabled) return "Use the Easy Setup profile";
if (payload.size() > 4096) return "Error: message too large";
std::string result = "Error: invalid message";
esphome::json::parse_json(payload, [&](JsonObject root) -> bool {
if (root["v"].as<int>() != 1) { result = "Error: protocol version"; return false; }
auto op = string(root["op"]);
if (op == "layout") {
if (!root["entities"].is<JsonArray>() || !root["title"].is<const char *>()) return false;
auto settings = screen_settings::current;
if (!root["settings"].isNull() && (!root["settings"].is<JsonObject>() ||
!parse_settings(root["settings"].as<JsonObject>(), settings))) {
result = "Error: screen settings"; return false;
}
std::vector<std::string> entities;
for (JsonVariant entity : root["entities"].as<JsonArray>()) {
if (!entity.is<const char *>() || entities.size() == MAX_TILES) return false;
entities.push_back(entity.as<std::string>());
}
if(!root["swipe_pages"].isNull() && !root["swipe_pages"].is<bool>())return false;
if(!root["rotation"].isNull() && (!root["rotation"].is<unsigned>() ||
root["rotation"].as<unsigned>()>270 || root["rotation"].as<unsigned>()%90!=0))return false;
if(!root["keepalive"].isNull() && (!root["keepalive"].is<unsigned>() ||
root["keepalive"].as<unsigned>()<5 || root["keepalive"].as<unsigned>()>3600))return false;
// Explicit grid positions (0.2.26+), one absolute slot per entity; absent on older managers.
std::vector<uint8_t> positions;
if (!root["slots"].isNull()) {
if (!root["slots"].is<JsonArray>()) return false;
for (JsonVariant slot : root["slots"].as<JsonArray>()) {
if (!slot.is<unsigned>() || slot.as<unsigned>() >= MAX_SLOTS || positions.size() == MAX_TILES) return false;
positions.push_back(static_cast<uint8_t>(slot.as<unsigned>()));
}
}
inbox = string(root["inbox"], 160);
bool changed = false, moved = false, was_configured = model.configured;
if (!model.set_layout(entities, string(root["title"], 96), changed, positions, moved)) return false;
// Empty pages the user keeps on purpose; absent on older managers.
model.pages = root["pages"].is<unsigned>() ? static_cast<uint8_t>(std::clamp<unsigned>(root["pages"].as<unsigned>(), 1, MAX_PAGES)) : 1;
if(root["swipe_pages"].is<bool>() && swipe_pages!=root["swipe_pages"].as<bool>()){
swipe_pages=root["swipe_pages"].as<bool>();uint32_t saved=swipe_pages?1:0;swipe_preference.save(&saved);
}
bool rotation_changed=false;
if(rotation_supported && root["rotation"].is<unsigned>() && rotation!=root["rotation"].as<unsigned>()){
rotation=root["rotation"].as<unsigned>();rotation_preference.save(&rotation);rotation_changed=true;
}
if (!(settings == screen_settings::current)) {
screen_settings::current = settings;
settings_preference.save(&settings); // ESPHome batches flash writes; no write on keepalive.
rotation_changed=true;
}
if(rotation_changed && settings_changed)settings_changed();
if (changed) { active_index = -1; for (auto &w : widgets) w.cached_active = -1; if (dismiss) dismiss(); }
if (moved) ESP_LOGI("runtime", "tiles moved: pages rearranged");
if (root["keepalive"].is<unsigned>()) keepalive_seconds = root["keepalive"].as<unsigned>();
layout_rev = string(root["rev"], 16);
last_received = esphome::millis();
if (layout_changed) layout_changed();
refresh_all();
// A repeat of the same layout keeps the inbox state as it is: no new recorder row.
result = changed || moved || !was_configured ? "Layout received" : model.ready() ? "Synced" : "Loading tiles";
return true;
}
if (op == "ping") {
// Keepalive without content (app 0.2.39+): only the revision of the layout the manager holds.
if (!root["rev"].is<const char *>()) return false;
if(!root["keepalive"].isNull() && (!root["keepalive"].is<unsigned>() ||
root["keepalive"].as<unsigned>()<5 || root["keepalive"].as<unsigned>()>3600))return false;
if (root["keepalive"].is<unsigned>()) keepalive_seconds = root["keepalive"].as<unsigned>();
last_received = esphome::millis();
if (!model.configured || layout_rev != string(root["rev"], 16)) { result = "Resend needed"; return true; }
result = model.ready() ? "Synced" : "Loading tiles";
return true;
}
if (op == "header") {
// Validate every item before replacing the bar; an icon these fonts lack is left out.
if (!root["items"].is<JsonArray>()) return false;
header_bar::Bar next;
for (JsonVariant value : root["items"].as<JsonArray>()) {
if (next.count == header_bar::MAX_ITEMS || !value.is<JsonObject>()) return false;
header_bar::Item item;
item.kind = header_bar::kind(string(value["k"], 8));
if (item.kind == header_bar::Kind::none) return false;
uint32_t icon = tile_icon::codepoint(string(value["i"], 8));
item.icon = icon && has_icon_glyph(icon) ? icon : 0;
item.text = string(value["t"], header_bar::TEXT_BYTES);
item.epoch = value["e"].is<unsigned>() ? value["e"].as<uint32_t>() : 0;
item.has_color = header_bar::color(string(value["c"], 8), item.color);
if (item.kind == header_bar::Kind::ago && item.epoch <= 0) return false;
next.items[next.count++] = item;
}
next.received = true;
bool same = header.received && header.count == next.count;
for (size_t i = 0; same && i < next.count; ++i) same = header.items[i] == next.items[i];
header = next;
last_received = esphome::millis();
if (!same) refresh_header_only();
// The same status as a tile state, so a changing value never flips the inbox entity.
result = model.ready() ? "Synced" : "Loading tiles";
return true;
}
#ifdef SWIPE_PROFILE
if (op == "swipe_test") {
// Diagnostic builds only: page switches without a finger, `n` of them `ms` apart; `back`
// (ms) swipes straight back after each one, like a quick second swipe.
swipe_test(root["n"] | 20u, root["ms"] | 1200u, root["back"] | 0u, root);
result = "Swipe test started";
return true;
}
#endif
if (op != "state" || !root["i"].is<unsigned>() || !root["a"].is<JsonObject>()) return false;
unsigned index = root["i"].as<unsigned>();
std::string entity = string(root["entity"], 120);
if (!model.accepts(index, entity)) { result = "Error: outdated tile"; return false; }
Tile &tile = model.tiles[index];
auto a = root["a"].as<JsonObject>();
// ArduinoJson clears its destination string: serialize attributes first, then add state.
std::string attributes; serializeJson(a, attributes);
std::string revision = state_revision(string(root["state"]), attributes);
bool was_confirmed=tile.confirmed;
tile.observe(revision);
if(tile.pending && !tile.local_feedback && !was_confirmed && tile.confirmed)
ESP_LOGI("runtime_action","HA state received entity=%s elapsed=%u ms",entity.c_str(),(unsigned)(esphome::millis()-tile.pending_since));
auto options = root["o"];
std::string background=string(options["background"],16);
tile.background = tile_palette::color(background);
tile.transparent = tile_palette::transparent(background);
// An icon these fonts lack (a newer set than this firmware) keeps the domain icon.
uint32_t icon = tile_icon::codepoint(string(options["icon"], 8));
tile.icon = icon && has_icon_glyph(icon) ? tile_icon::utf8(icon) : "";
tile.tap = string(options["tap"]); if (tile.tap.empty()) tile.tap="auto";
tile.display = string(options["display"]); if (tile.display.empty()) tile.display="standard";
tile.inline_control = string(options["inline"]); if (tile.inline_control.empty()) tile.inline_control="none";
// Direct controls (0.2.19+): the manager sends only the set a wide card really shows.
tile.controls = string(options["controls"], 16);
// Width arrives with the state, after the layout: re-pack the pages when it changes.
bool was_wide = tile.wide;
tile.wide = string(options["size"]) == "wide";
bool repack = was_wide != tile.wide;
// Pre-computed extras: the manager converts time zones and fetches forecasts.
auto extra = root["x"];
tile.forecast.clear();
if (extra["days"].is<JsonArray>()) for (JsonVariant day : extra["days"].as<JsonArray>()) {
if (tile.forecast.size() == 5) break;
tile.forecast.emplace_back(); auto &f = tile.forecast.back();
f.day = string(day["d"], 3); f.condition = string(day["c"], 20); f.high = number(day["h"]); f.low = number(day["l"]);
f.rain = number(day["p"]); f.mm = number(day["r"]);
}
tile.hours.clear();
if (extra["hours"].is<JsonArray>()) for (JsonVariant hour : extra["hours"].as<JsonArray>()) {
if (tile.hours.size() == 8) break;
tile.hours.emplace_back(); auto &h = tile.hours.back();
h.time = string(hour["t"], 5); h.condition = string(hour["c"], 20); h.temp = number(hour["h"]); h.rain = number(hour["p"]); h.mm = number(hour["r"]);
}
tile.last_run = extra["last"].is<unsigned>() ? extra["last"].as<uint32_t>() : 0;
tile.sunrise = string(extra["rise"], 5); tile.sunset = string(extra["set"], 5);
tile.timer_end = extra["end"].is<unsigned>() ? extra["end"].as<uint32_t>() : 0;
tile.duration = string(extra["dur"], 16); tile.remaining = string(extra["rem"], 16);
tile.has_history=false;
if (root["history"]["values"].is<JsonArray>()) {
tile.history.fill(NAN);unsigned j=0;
for (JsonVariant value:root["history"]["values"].as<JsonArray>()) {
if(j==24) break; tile.history[j++]=number(value); }
tile.has_history=j>0;tile.history_hours=std::clamp(root["history"]["hours"].as<unsigned>(),1u,24u);
}
tile.option_count=0;
if (a["options"].is<JsonArray>()) for(JsonVariant option:a["options"].as<JsonArray>()) {
if(tile.option_count==8)break;tile.options[tile.option_count++]=string(option,48); }
tile.battery=number(a["battery_level"]);tile.volume=number(a["volume_level"]);
tile.muted=a["is_volume_muted"].is<bool>() && a["is_volume_muted"].as<bool>();
tile.device_class=string(a["device_class"],24);tile.hvac_action=string(a["hvac_action"],24);
tile.media_title=string(a["media_title"],80);tile.supported=a["supported_features"].as<uint32_t>();
tile.name = string(root["name"], 80);
tile.state = string(root["state"], 160);
tile.unit = string(a["unit_of_measurement"], 20);
tile.brightness = number(a["brightness"]);
tile.percentage = number(a["percentage"]);
tile.position = number(a["current_position"]);
tile.current = number(a["current_temperature"]);
tile.target = number(a["temperature"]);
tile.humidity = number(a["current_humidity"]);
tile.minimum = number(a["min_temp"], 7);
tile.maximum = number(a["max_temp"], 35);
tile.step = number(a["target_temp_step"], 0.5f);
if(tile.domain()=="number" || tile.domain()=="input_number") {
tile.minimum=number(a["min"],0);tile.maximum=number(a["max"],100);tile.step=number(a["step"],1); }
if(tile.domain()=="weather") {
tile.current=number(a["temperature"]);tile.unit=string(a["temperature_unit"],12);
tile.humidity=number(a["humidity"]);tile.wind=number(a["wind_speed"]);tile.wind_unit=string(a["wind_speed_unit"],8);tile.feels=number(a["apparent_temperature"]);
}
tile.modes = list(a["supported_color_modes"]);
tile.hvac_modes = list(a["hvac_modes"]);
tile.fan_modes = list(a["fan_modes"]); tile.swing_modes = list(a["swing_modes"]);
tile.fan_mode = string(a["fan_mode"], 48); tile.swing_mode = string(a["swing_mode"], 48);
float hue = number(a["hs_color"][0]);
float saturation = number(a["hs_color"][1]);
tile.has_hs_color = std::isfinite(hue) && std::isfinite(saturation);
tile.saturation = tile.has_hs_color ? std::lround(std::clamp(saturation, 0.0f, 100.0f)) : 0;
if (std::isfinite(hue)) tile.hue = std::lround(std::clamp(hue, 0.0f, 360.0f));
float kelvin = number(a["color_temp_kelvin"]);
if (std::isfinite(kelvin)) tile.kelvin = std::lround(std::clamp(kelvin, 1000.0f, 15000.0f));
tile.min_kelvin = std::clamp(number(a["min_color_temp_kelvin"], 0), 0.0f, 15000.0f);
tile.max_kelvin = std::clamp(number(a["max_color_temp_kelvin"], 0), 0.0f, 15000.0f);
tile.fan_speed = string(a["fan_speed"], 48);
tile.fan_speed_count = 0;
if (a["fan_speed_list"].is<JsonArray>()) for (JsonVariant speed : a["fan_speed_list"].as<JsonArray>()) {
if (tile.fan_speed_count == 4) break;
tile.fan_speeds[tile.fan_speed_count++] = string(speed, 48);
}
// Home Assistant reports the edited value: the -/+ pill follows its state again.
if(std::isfinite(tile.edit_value) && tile.edit_sent && std::fabs(tile_controls::edit_target(tile)-tile.edit_value)<0.051f)tile.edit_value=NAN;
tile.received = true;
for(auto &w:widgets)if(w.index==index)w.cached_active=-1;
last_received = esphome::millis();
if (repack && layout_changed) layout_changed();
refresh_tile(index);
if (active_index == static_cast<int>(index) && detail_update) detail_update(tile);
refresh_detail(index);
result = model.ready() ? "Synced" : "Loading tiles";
return true;
});
return result;
}
inline void action(const std::string &service, const std::string &entity, const std::string &key="", const std::string &value="") {
if (!fresh() || !valid_entity(entity)) return;
esphome::api::HomeassistantActionRequest request;
request.service = esphome::StringRef(service);
request.data.init(key.empty() ? 1 : 2);
esphome::api::HomeassistantServiceMap entry;
entry.key = esphome::StringRef("entity_id");
entry.value = esphome::StringRef(entity);
request.data.push_back(entry);
if(!key.empty()) {esphome::api::HomeassistantServiceMap param;param.key=esphome::StringRef(key);param.value=esphome::StringRef(value);request.data.push_back(param);}
for(size_t i=0;i<model.tiles.size();++i) if(model.tiles[i].entity==entity){model.tiles[i].begin(esphome::millis());refresh_tile(i);}
esphome::api::global_api_server->send_homeassistant_action(request);
ESP_LOGI("runtime_action","Sent service=%s entity=%s",service.c_str(),entity.c_str());
}
inline void setting_event(const std::string &key, int value) {
if(inbox.empty())return;
esphome::api::HomeassistantActionRequest request;request.service=esphome::StringRef("esphome.screen_setting");request.is_event=true;
std::string number=std::to_string(value);request.data.init(3);
const std::string keys[]={"inbox","key","value"},values[]={inbox,key,number};
for(int i=0;i<3;++i){esphome::api::HomeassistantServiceMap entry;entry.key=esphome::StringRef(keys[i]);entry.value=esphome::StringRef(values[i]);request.data.push_back(entry);}
esphome::api::global_api_server->send_homeassistant_action(request);
}
} // namespace runtime_tiles
// Small shared native-LVGL detail cards. No images, canvas buffers or free scrolling.
namespace runtime_tiles {
inline lv_obj_t *detail_root=nullptr;
inline unsigned detail_index=0;
inline const lv_font_t *detail_font=nullptr;
inline lv_obj_t *detail_actions[16]{};
inline unsigned detail_action_count=0;
inline lv_obj_t *detail_status=nullptr;
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_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){
if(t.domain()=="person")return t.state=="home"?"Home":t.state=="not_home"?"Away":t.state;
if(t.domain()=="sun")return t.state=="above_horizon"?"Above the horizon":"Below the horizon";
if(t.domain()=="timer")return timer_text(t);
if(t.domain()=="script"||t.domain()=="scene"||t.domain()=="button"||t.domain()=="input_button")return t.state=="on"?"Running...":last_run_text(t.last_run);
if(t.state=="on")return "On";
if(t.state=="off")return "Off";
if(t.state=="docked")return "Docked";
if(t.state=="cleaning")return "Cleaning";
if(t.state=="paused")return "Paused";
if(t.state=="returning")return "Returning to dock";
if(t.state=="idle")return "Idle";
if(t.state=="error")return "Check the robot in HA";
if(!t.available())return "Unavailable";
return t.state;
}
inline void hide_detail(){if(detail_root)lv_obj_add_flag(detail_root,LV_OBJ_FLAG_HIDDEN);}
inline int slider_value(const Tile &t){
auto d=t.domain();float value=0;
if(d=="light")value=std::isfinite(t.brightness)?t.brightness/255:0;
if(d=="fan")value=std::isfinite(t.percentage)?t.percentage/100:0;
if(d=="cover")value=std::isfinite(t.position)?t.position/100:0;
if(d=="media_player")value=std::isfinite(t.volume)?t.volume:0;
if(d=="number"||d=="input_number") {char *end;float state=strtof(t.state.c_str(),&end);if(end!=t.state.c_str() && t.maximum>t.minimum)value=(state-t.minimum)/(t.maximum-t.minimum);}
return std::clamp((int)std::lround(value*1000),0,1000);
}
inline lv_obj_t *captured_slider=nullptr;
inline bool slider_changed=false;
inline void slider_event(lv_event_t *e);
inline void commit_slider(unsigned i,int raw){
if(i>=model.count || !fresh())return;auto &t=model.tiles[i];if(!t.available() || t.loading(esphome::millis()))return;
float value=std::clamp(raw,0,1000)/1000.0f;auto d=t.domain();
if(d=="light")action("light.turn_on",t.entity,"brightness",std::to_string((int)std::lround(value*255)));
if(d=="fan")action("fan.set_percentage",t.entity,"percentage",std::to_string((int)std::lround(value*100)));
if(d=="cover")action("cover.set_cover_position",t.entity,"position",std::to_string((int)std::lround(value*100)));
if(d=="media_player")action("media_player.volume_set",t.entity,"volume_level",std::to_string(value));
if(d=="number"||d=="input_number") {
if(!std::isfinite(t.minimum)||!std::isfinite(t.maximum)||t.maximum<=t.minimum||t.step<=0)return;
value=std::clamp(t.minimum+std::round(value*(t.maximum-t.minimum)/t.step)*t.step,t.minimum,t.maximum);
action(d+".set_value",t.entity,"value",std::to_string(value)); }
}
inline void slider_event(lv_event_t *e){
auto *slider=lv_event_get_target_obj(e);auto code=lv_event_get_code(e);
if(code==LV_EVENT_PRESSED){captured_slider=slider;slider_changed=false;}
// The range reaches below zero only to draw the round end at 0 (slider_handle).
if(code==LV_EVENT_VALUE_CHANGED && lv_slider_get_value(slider)<0)lv_slider_set_value(slider,0,LV_ANIM_OFF);
if(code==LV_EVENT_VALUE_CHANGED && captured_slider==slider)slider_changed=true;
if(code==LV_EVENT_PRESS_LOST && captured_slider==slider){captured_slider=nullptr;slider_changed=false;}
if(code==LV_EVENT_RELEASED && captured_slider==slider){
unsigned index=(uintptr_t)lv_event_get_user_data(e);
for(auto &w:widgets)if(w.slider==slider || w.control_slider==slider){index=w.index;break;}
bool changed=slider_changed;captured_slider=nullptr;slider_changed=false;
if(changed && cyd::touch_guard.accept_slider(esphome::millis(),200+index))commit_slider(index,lv_slider_get_value(slider));
}
}
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_add_event_cb(button,[](lv_event_t *e){
int cmd=(intptr_t)lv_event_get_user_data(e);if(cmd==-1){hide_detail();return;}
if(!fresh()||detail_index>=model.count || !allowed(esphome::millis(),300+cmd,"card button "+model.tiles[detail_index].entity))return;
auto &t=model.tiles[detail_index];if(!t.available()||t.loading(esphome::millis()))return;
if(cmd<4){const char *services[]={"vacuum.start","vacuum.pause","vacuum.return_to_base","vacuum.locate"};action(services[cmd],t.entity);}
if(cmd>=10 && cmd<14 && cmd-10<(int)t.fan_speed_count)action("vacuum.set_fan_speed",t.entity,"fan_speed",t.fan_speeds[cmd-10]);
if(cmd==20)action("media_player.media_play_pause",t.entity);
if(cmd==21)action("media_player.media_previous_track",t.entity);
if(cmd==22)action("media_player.media_next_track",t.entity);
if(cmd>=30 && cmd<38 && cmd-30<(int)t.option_count)action(t.domain()+".select_option",t.entity,"option",t.options[cmd-30]);
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_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<16)detail_actions[detail_action_count++]=button;
return button;
}
// ---- Weather card: now, the next hours and the coming days, with rain ----
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;
}
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;
}
// "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];
if(std::isfinite(chance) && chance>=0){
if(with_mm && std::isfinite(mm) && mm>=0.05f){snprintf(b,sizeof(b),"%d%% · %.1f mm",(int)std::lround(chance),mm);return b;}
snprintf(b,sizeof(b),"%d%%",(int)std::lround(chance));return b;
}
if(std::isfinite(mm) && mm>=0.05f){snprintf(b,sizeof(b),mm<10?"%.1f mm":"%.0f mm",mm);return b;}
return "";
}
inline std::string degrees(float value){ if(!std::isfinite(value))return "--"; char b[16];snprintf(b,sizeof(b),"%.0f°",value);return b; }
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_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);
return card;
}
// Two cards: "now" with the next hours, and the coming days. Bold highs, muted lows,
// rain in blue with a drop, so the eye finds temperature first and rain second.
inline void render_weather_detail(const Tile &t,bool large,int width,int height,int pad){
if(detail_status){lv_obj_add_flag(detail_status,LV_OBJ_FLAG_HIDDEN);detail_status=nullptr;}
const lv_font_t *big=watch_value_font?watch_value_font:detail_font;
const lv_font_t *icon_font=widgets[0].icon_font?widgets[0].icon_font:detail_font;
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;
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;
unsigned columns=std::min<unsigned>(t.hours.size(),6);
int hours_h=columns?small_h+mini_h+text_h+small_h+(large?16:6):0;
int card_a_h=card_pad+hero+(columns?(large?14:8)+hours_h:0)+card_pad;
int y=large?62:38;
auto *now=detail_card(pad,y,width-2*pad,card_a_h);
// Now: icon, temperature, condition, then feels-like / humidity / wind in one muted line.
int cy=card_pad;char b[48];
detail_text(now,t.available()?weather_icon(t.state):"\U000F0595",card_pad,cy+(hero-icon_h)/2,icon_h+8,icon_font,LV_TEXT_ALIGN_LEFT,weather_accent(t.state));
int temp_x=card_pad+icon_h+(large?14:6),temp_w=large?92:50;
detail_text(now,degrees(t.current),temp_x,cy+(hero-big_h)/2,temp_w,big,LV_TEXT_ALIGN_LEFT,ink);
int text_x=temp_x+temp_w+(large?4:2),text_w=width-2*pad-card_pad-text_x;
int lines_h=text_h+small_h+(large?2:0);
detail_text(now,t.available()?weather_text(t.state):"Unavailable",text_x,cy+(hero-lines_h)/2,text_w,detail_font,LV_TEXT_ALIGN_LEFT,ink);
std::string details;
if(std::isfinite(t.feels)){snprintf(b,sizeof(b),"Feels like %.0f°",t.feels);details=b;}
if(std::isfinite(t.humidity)){snprintf(b,sizeof(b),"%d%%",(int)std::lround(t.humidity));details+=(details.empty()?"":" · ")+std::string(b);}
if(std::isfinite(t.wind)){snprintf(b,sizeof(b),"%.0f %s",t.wind,t.wind_unit.empty()?"km/h":t.wind_unit.c_str());details+=(details.empty()?"":" · ")+std::string(b);}
detail_text(now,details,text_x,cy+(hero-lines_h)/2+text_h+(large?2:0),text_w,small,LV_TEXT_ALIGN_LEFT,muted);
// Next hours inside the same card: time, icon, temperature, rain per column.
if(columns){
int hy=cy+hero+(large?14:8),col=inner/(int)columns;
for(unsigned i=0;i<columns;++i){
const auto &h=t.hours[i];int x=card_pad+i*col;
detail_text(now,h.time,x,hy,col,small,LV_TEXT_ALIGN_CENTER,muted);
detail_text(now,weather_icon(h.condition),x,hy+small_h+(large?4:1),col,mini,LV_TEXT_ALIGN_CENTER,weather_accent(h.condition));
detail_text(now,std::isfinite(h.temp)?degrees(h.temp):"",x,hy+small_h+mini_h+(large?8:2),col,detail_font,LV_TEXT_ALIGN_CENTER,ink);
detail_text(now,rain_text(h.rain,h.mm,false),x,hy+small_h+mini_h+text_h+(large?8:3),col,small,LV_TEXT_ALIGN_CENTER,rain);
}
}
y+=card_a_h+(large?12:6);
// Coming days: a heading and a card with one row per day.
if(!t.forecast.size()){detail_text(detail_root,"No daily forecast from Home Assistant",pad,y,width-2*pad,small,LV_TEXT_ALIGN_LEFT,muted);return;}
if(large){detail_text(detail_root,"Coming days",pad+4,y,width-2*pad,detail_font,LV_TEXT_ALIGN_LEFT,muted);y+=text_h+8;}
int card_b_h=height-y-(large?10:4);
auto *days=detail_card(pad,y,width-2*pad,card_b_h);
int row_pad=large?8:4,row=(card_b_h-2*row_pad)/(int)t.forecast.size();
int day_w=large?46:26,icon_x=card_pad+day_w,cond_x=icon_x+mini_h+(large?12:5);
int high_w=large?52:30,low_w=large?46:28,rain_w=large?120:60,drop_w=tiny_h+(large?4:2);
int temps_x=width-2*pad-card_pad-high_w-low_w,rain_x=temps_x-(large?14:6)-rain_w;
for(unsigned i=0;i<t.forecast.size();++i){
const auto &f=t.forecast[i];int ry=row_pad+i*row,tcy=ry+(row-text_h)/2,scy=ry+(row-small_h)/2;
detail_text(days,f.day,card_pad,tcy,day_w,detail_font,LV_TEXT_ALIGN_LEFT,ink);
detail_text(days,weather_icon(f.condition),icon_x,ry+(row-mini_h)/2,mini_h+6,mini,LV_TEXT_ALIGN_LEFT,weather_accent(f.condition));
detail_text(days,weather_text(f.condition),cond_x,scy,std::max(1,rain_x-cond_x-4),small,LV_TEXT_ALIGN_LEFT,muted);
std::string wet=rain_text(f.rain,f.mm,large);
if(!wet.empty()){
detail_text(days,"\U000F058E",rain_x,ry+(row-tiny_h)/2,drop_w,tiny,LV_TEXT_ALIGN_LEFT,rain);
detail_text(days,wet,rain_x+drop_w,scy,rain_w-drop_w,small,LV_TEXT_ALIGN_LEFT,rain);
}
detail_text(days,std::isfinite(f.high)?degrees(f.high):"",temps_x,tcy,high_w,detail_font,LV_TEXT_ALIGN_RIGHT,ink);
detail_text(days,std::isfinite(f.low)?degrees(f.low):"",temps_x+high_w,scy,low_w,small,LV_TEXT_ALIGN_RIGHT,muted);
}
}
inline void show_detail(unsigned index){
if(index>=model.count)return;detail_index=index;auto &t=model.tiles[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;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);
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;
auto *heading=detail_label(detail_root,t.name,pad,large?24:12,width-80);if(watch_font){lv_obj_set_style_text_font(heading,watch_font,0);lv_obj_set_height(heading,lv_font_get_line_height(watch_font));}
detail_button("X",width-58,8,48,40,-1);
std::string state=detail_state(t);
detail_status=detail_label(detail_root,state+(t.unit.empty()?"":" "+t.unit),pad,large?60:35,width-2*pad);
auto d=t.domain();
if(t.is_switch()){
detail_label(detail_root,"Tap to toggle",pad,top,width-2*pad);
detail_switch=lv_switch_create(detail_root);
lv_obj_set_size(detail_switch,large?240:140,large?112:64);
lv_obj_set_pos(detail_switch,(width-(large?240:140))/2,top+(large?65:30));
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),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_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){
if(detail_index>=model.count)return;
auto &tile=model.tiles[detail_index];auto *control=lv_event_get_target_obj(e);
bool allowed=fresh() && tile.available() && !tile.awaiting_action(esphome::millis()) &&
cyd::touch_guard.accept(esphome::millis(),350);
bool requested_on=lv_obj_has_state(control,LV_STATE_CHECKED);
// Only HA's reported state is authoritative, including a refused/failed command.
if(tile.state=="on")lv_obj_add_state(control,LV_STATE_CHECKED);else lv_obj_remove_state(control,LV_STATE_CHECKED);
if(allowed)action(tile.domain()+(requested_on?".turn_on":".turn_off"),tile.entity);
},LV_EVENT_VALUE_CHANGED,nullptr);
detail_actions[detail_action_count++]=detail_switch;
}else if(d=="vacuum"){
// Native shapes keep the robot crisp without image buffers or extra layers.
auto shape=[&](lv_obj_t *parent,int x,int y,int w,int h,uint32_t 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_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;
};
uint32_t surface=0xFFFFFF, muted=0xEAF5FC;
if(large){
auto *hero=shape(detail_root,pad,100,width-2*pad,148,surface,24);
shape(hero,18,14,120,120,muted,60);
auto *robot=shape(hero,35,27,86,86,0xFFFFFF,43);
lv_obj_set_style_border_width(robot,2,0);lv_obj_set_style_border_color(robot,lv_color_hex(0xCEDDE6),0);
shape(robot,27,10,30,30,0xE3EBEF,15);shape(robot,35,18,14,14,0xA8BCC8,7);
shape(robot,29,59,26,5,0x00A6ED,3);
detail_label(hero,t.state=="cleaning"?"Working":t.state=="returning"?"Charging":t.state=="paused"?"Paused":"Ready for your home",154,24,260);
auto *badge=shape(hero,154,59,240,32,muted,16);
auto *status=detail_label(badge,t.awaiting_action(esphome::millis())?"Command sent...":state,12,5,218);
detail_badge_status=status;
lv_obj_set_style_text_color(status,lv_color_hex(0x087BA8),0);
detail_label(hero,std::isfinite(t.battery)?"Battery "+std::to_string((int)t.battery)+"%":"Connected via Home Assistant",154,107,260);
detail_button(t.state=="cleaning"?"Pause cleaning":"Start cleaning",pad,260,width-2*pad,58,t.state=="cleaning"?1:0);
detail_button("Return to dock",pad,330,cw,48,2);
detail_button("Find my robot",pad+cw+gap,330,cw,48,3);
detail_label(detail_root,"Suction power",pad,394,width-2*pad);
top=424;
}else{
auto *robot=shape(detail_root,pad,64,46,46,muted,23);
shape(robot,16,8,14,14,0xA8BCC8,7);shape(robot,15,32,16,3,0x00A6ED,2);
detail_label(detail_root,std::isfinite(t.battery)?"Battery "+std::to_string((int)t.battery)+"%":"Robot vacuum",pad+58,66,width-2*pad-58);
detail_label(detail_root,"Choose an action",pad+58,87,width-2*pad-58);
detail_button(t.state=="cleaning"?"Pause":"Clean",pad,120,cw,38,t.state=="cleaning"?1:0);
detail_button("To dock",pad+cw+gap,120,cw,38,2);
detail_label(detail_root,"Suction power",pad,168,width-2*pad);top=194;
}
if(!t.fan_speed_count)detail_label(detail_root,"Automatic suction power",pad,top,width-2*pad);
int count=std::max(1,(int)t.fan_speed_count),sw=(width-2*pad-gap*(count-1))/count;
for(unsigned i=0;i<t.fan_speed_count;++i){
std::string name=t.fan_speeds[i];
if(name=="quiet")name="Quiet";else if(name=="balanced")name="Normal";else if(name=="turbo")name="Turbo";else if(name=="max")name="Max";
auto *button=detail_button(name.c_str(),pad+i*(sw+gap),top,sw,large?36:30,10+i);
bool selected=t.fan_speed==t.fan_speeds[i];
lv_obj_set_style_bg_color(button,lv_color_hex(selected?0x009FE3:surface),0);
if(selected)lv_obj_set_style_text_color(lv_obj_get_child(button,0),lv_color_hex(0xFFFFFF),0);
}
}else if(d=="sensor"){
float minimum=INFINITY,maximum=-INFINITY;for(float value:t.history)if(t.has_history&&std::isfinite(value)){minimum=std::min(minimum,value);maximum=std::max(maximum,value);}
if(!std::isfinite(minimum)){detail_label(detail_root,"No numeric HA history",pad,top,width-2*pad);return;}
char text[100];snprintf(text,sizeof(text),"%u hours / %.2f - %.2f %s",t.history_hours,minimum,maximum,t.unit.c_str());detail_label(detail_root,text,pad,top,width-2*pad);
int chart_y=top+(large?46:28),chart_h=height-chart_y-30,bar_w=(width-pad*2)/24;
for(unsigned i=0;i<24;++i){if(!std::isfinite(t.history[i]))continue;int h=maximum>minimum?8+(chart_h-8)*(t.history[i]-minimum)/(maximum-minimum):chart_h/2;
auto *bar=lv_obj_create(detail_root);lv_obj_remove_style_all(bar);lv_obj_set_pos(bar,pad+i*bar_w,chart_y+chart_h-h);lv_obj_set_size(bar,std::max(2,bar_w-2),h);lv_obj_set_style_bg_color(bar,lv_color_hex(0x16A5E6),0);lv_obj_set_style_bg_opa(bar,LV_OPA_COVER,0);}
detail_label(detail_root,std::to_string(t.history_hours)+" hours ago",pad,height-24,(width-2*pad)/2);
auto *now=detail_label(detail_root,"Now",width/2,height-24,width/2-pad);lv_obj_set_style_text_align(now,LV_TEXT_ALIGN_RIGHT,0);
}else if(d=="select"||d=="input_select"){
for(unsigned i=0;i<t.option_count;++i)detail_button(t.options[i].c_str(),pad+(i%2)*(cw+gap),top+(i/2)*(bh+gap),cw,bh,30+i);
}else if(d=="number"||d=="input_number"||d=="media_player"){
if(d=="media_player"){
detail_label(detail_root,t.media_title,pad,top,width-2*pad);top+=large?45:25;
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,d=="media_player"?"Volume":"Value",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);
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);
}else if(d=="timer"){
detail_label(detail_root,t.state=="active"?"Running":t.state=="paused"?"Paused":"Stopped",pad,top,width-2*pad);
detail_button(t.state=="active"?"Pause":"Start",pad,top+(large?50:30),cw,bh,40);
detail_button("Cancel",pad+cw+gap,top+(large?50:30),cw,bh,41);
}else if(d=="sun"){
detail_label(detail_root,"Sunrise "+t.sunrise,pad,top,width-2*pad);
detail_label(detail_root,"Sunset "+t.sunset,pad,top+lv_font_get_line_height(detail_font)+(large?10:4),width-2*pad);
}
}
}
namespace runtime_tiles {
inline void refresh_detail(unsigned index){
if(!detail_root || lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN) || detail_index!=index)return;
auto *input=lv_indev_get_next(nullptr);if(input && lv_indev_get_state(input)==LV_INDEV_STATE_PRESSED)return;
show_detail(index);
}
// Home Assistant weather conditions mapped to Material Design Icons glyphs.
inline const char *weather_icon(const std::string &condition) {
if (condition == "sunny") return "\U000F0599";
if (condition == "clear-night") return "\U000F0594";
if (condition == "cloudy") return "\U000F0590";
if (condition == "partlycloudy") return "\U000F0595";
if (condition == "rainy") return "\U000F0597";
if (condition == "pouring") return "\U000F0596";
if (condition == "snowy") return "\U000F0598";
if (condition == "snowy-rainy") return "\U000F067F";
if (condition == "fog") return "\U000F0591";
if (condition == "hail") return "\U000F0592";
if (condition == "lightning") return "\U000F0593";
if (condition == "lightning-rainy") return "\U000F067E";
if (condition == "windy" || condition == "windy-variant") return "\U000F059D";
if (condition == "exceptional") return "\U000F05D6";
return "\U000F0595";
}
inline const char *weather_text(const std::string &condition) {
if (condition == "sunny") return "Sunny";
if (condition == "clear-night") return "Clear, night";
if (condition == "cloudy") return "Cloudy";
if (condition == "partlycloudy") return "Partly cloudy";
if (condition == "rainy") return "Rainy";
if (condition == "pouring") return "Pouring";
if (condition == "snowy") return "Snowy";
if (condition == "snowy-rainy") return "Snowy, rainy";
if (condition == "fog") return "Fog";
if (condition == "hail") return "Hail";
if (condition == "lightning") return "Lightning";
if (condition == "lightning-rainy") return "Lightning, rainy";
if (condition == "windy" || condition == "windy-variant") return "Windy";
if (condition == "exceptional") return "Exceptional";
return condition.c_str();
}
inline const char *icon_for(const Tile &tile) {
if (!tile.icon.empty()) return tile.icon.c_str();
auto d = tile.domain();
if (d == "light") return "\U000F0335";
if (d == "climate") return "\U000F001B";
if (d == "vacuum") return "\U000F070D";
if (d == "fan") return "\U000F0210";
if (d == "cover") return "\U000F111C";
if (d == "scene" || d == "script") return "\U000F04B9";
if (d == "weather") return tile.available() ? weather_icon(tile.state) : "\U000F0595";
if (d == "sensor" || d == "binary_sensor") return "\U000F029A";
if (d == "sun") return tile.state == "above_horizon" ? "\U000F059B" : "\U000F059C";
if (d == "timer") return "\U000F051B";
if (d == "person") return "\U000F0004";
if (d == "screen") return "\U000F0150";
return "\U000F0425";
}
// HA duration strings ("0:05:00") to seconds; 0 when unusable.
inline uint32_t duration_seconds(const std::string &text) {
unsigned h = 0, m = 0, s = 0;
if (sscanf(text.c_str(), "%u:%u:%u", &h, &m, &s) == 3) return h * 3600 + m * 60 + s;
if (sscanf(text.c_str(), "%u:%u", &m, &s) == 2) return m * 60 + s;
return 0;
}
inline std::string countdown(uint32_t seconds) {
char b[16];
if (seconds >= 3600) snprintf(b, sizeof(b), "%u:%02u:%02u", seconds / 3600, seconds / 60 % 60, seconds % 60);
else snprintf(b, sizeof(b), "%u:%02u", seconds / 60, seconds % 60);
return b;
}
// "Last 14:32" today, "Yesterday 14:32", else "Last 13 Sep"; scripts and scenes have no useful on/off.
inline std::string month_short(const esphome::ESPTime &now);
inline std::string last_run_text(uint32_t epoch) {
if (!epoch) return "Never run";
auto when = esphome::ESPTime::from_epoch_local(epoch);
auto now = now_time ? now_time() : esphome::ESPTime{};
if (!when.is_valid()) return "Never run";
char clock[8]; snprintf(clock, sizeof(clock), "%02d:%02d", when.hour, when.minute);
if (now.is_valid() && now.year == when.year && now.day_of_year == when.day_of_year) return std::string("Last ") + clock;
if (now.is_valid() && now.year == when.year && now.day_of_year == when.day_of_year + 1) return std::string("Yesterday ") + clock;
return "Last " + std::to_string(when.day_of_month) + " " + month_short(when);
}
inline std::string timer_text(const Tile &t) {
if (t.state == "active") { uint32_t now = now_epoch(); return countdown(t.timer_end > now && now ? t.timer_end - now : 0); }
if (t.state == "paused") return "Paused " + countdown(duration_seconds(t.remaining));
return t.duration.empty() ? "Off" : countdown(duration_seconds(t.duration));
}
inline std::string weekday_text(const esphome::ESPTime &now) {
static const char *days[] = {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"};
return now.is_valid() && now.day_of_week >= 1 && now.day_of_week <= 7 ? days[now.day_of_week - 1] : "";
}
inline std::string month_short(const esphome::ESPTime &now) {
static const char *months[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
return now.is_valid() && now.month >= 1 && now.month <= 12 ? months[now.month - 1] : "";
}
inline std::string date_text(const esphome::ESPTime &now) {
static const char *months[] = {"January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"};
if (!now.is_valid() || now.day_of_week < 1 || now.day_of_week > 7 || now.month < 1 || now.month > 12) return "";
return weekday_text(now) + " " + std::to_string(now.day_of_month) + " " + months[now.month - 1];
}
inline void event(lv_event_t *event) {
auto &w = *static_cast<Widgets *>(lv_event_get_user_data(event));
if (!enabled || !fresh() || w.index >= model.count) return;
auto code = lv_event_get_code(event);
if (code != LV_EVENT_SHORT_CLICKED && code != LV_EVENT_LONG_PRESSED) return;
if (!allowed(esphome::millis(), 100 + w.index, model.tiles[w.index].entity)) return;
auto &tile = model.tiles[w.index];
auto d = tile.domain();
// Scenes/scripts often have timestamps or 'off'; unavailable devices never act.
if (!tile.available() || tile.loading(esphome::millis()) || tile.tap=="none") return;
bool open = code == LV_EVENT_LONG_PRESSED || d == "climate" || d == "vacuum" || d == "cover";
if(code==LV_EVENT_SHORT_CLICKED && tile.tap=="detail")open=true;
if(code==LV_EVENT_SHORT_CLICKED && tile.tap=="toggle")open=false;
if(d=="media_player" && tile.tap=="toggle" && code==LV_EVENT_SHORT_CLICKED){action("media_player.toggle",tile.entity);return;}
if(d=="climate" && tile.tap=="toggle" && code==LV_EVENT_SHORT_CLICKED){action("climate.toggle",tile.entity);return;}
if(tile.builtin())return;
// A short tap runs or pauses the timer; holding opens the card with a cancel button.
if(d=="timer" && !open){action(tile.state=="active"?"timer.pause":"timer.start",tile.entity);return;}
if(d=="sensor" || d=="binary_sensor" || d=="weather" || d=="number" || d=="input_number" || d=="select" || d=="input_select" || d=="media_player" || d=="vacuum" || d=="sun" || d=="person" || d=="timer") { tile.begin(esphome::millis(),true); active_index=w.index; show_detail(w.index); return; }
if (open) {
if (d == "light" || d == "climate" || d == "vacuum" || d == "fan" || d == "cover") {
active_index = w.index;
tile.begin(esphome::millis(),true);
if (detail) detail(tile);
} else { active_index=w.index;show_detail(w.index); }
return;
}
if (d == "light" || d == "switch" || d == "input_boolean" || d == "fan") action(d + ".toggle", tile.entity);
if (d == "scene" || d == "script") action(d + ".turn_on", tile.entity);
if (d == "button" || d == "input_button") action(d + ".press", tile.entity);
}
// The same guard for any local style: a page switch mostly hands a slot the colours it already has,
// and each real set refreshes the style and invalidates the object (about 0.3 ms on the Guition).
inline void set_color(lv_obj_t *obj, lv_style_prop_t prop, lv_color_t color, lv_style_selector_t selector = 0) {
lv_style_value_t current;
if (lv_obj_get_local_style_prop(obj, prop, &current, selector) == LV_STYLE_RES_FOUND && lv_color_eq(current.color, color)) return;
lv_style_value_t value{}; value.color = color;
lv_obj_set_local_style_prop(obj, prop, value, selector);
}
inline void set_number(lv_obj_t *obj, lv_style_prop_t prop, int32_t number, lv_style_selector_t selector = 0) {
lv_style_value_t current;
if (lv_obj_get_local_style_prop(obj, prop, &current, selector) == LV_STYLE_RES_FOUND && current.num == number) return;
lv_style_value_t value{}; value.num = number;
lv_obj_set_local_style_prop(obj, prop, value, selector);
}
// The white handle of a card slider sits inside the rounded end of the fill, like Home Assistant's
// slider. LVGL centres the knob on the end of the fill, so the knob is narrowed and moved back by a
// quarter of the height; the range starts below zero, so at 0 the fill is still one round end that
// holds the handle. Values below zero never leave the slider (see slider_event).
inline void slider_handle(lv_obj_t *slider, int width, int height) {
int handle = height > 20 ? 6 : 4, inset = height / 4 + handle / 2, half = height >> 1;
set_number(slider, LV_STYLE_PAD_LEFT, inset + handle / 2 - half, LV_PART_KNOB);
set_number(slider, LV_STYLE_PAD_RIGHT, handle / 2 - inset - (height - half), LV_PART_KNOB);
set_number(slider, LV_STYLE_PAD_TOP, -(height / 4), LV_PART_KNOB);
set_number(slider, LV_STYLE_PAD_BOTTOM, -(height / 4), LV_PART_KNOB);
int below = width > height ? 1000 * height / (width - height) : 0;
if (lv_slider_get_min_value(slider) != -below || lv_slider_get_max_value(slider) != 1000) lv_slider_set_range(slider, -below, 1000);
}
inline void set_hidden(lv_obj_t *obj, bool hidden) { if (hidden) lv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN); else lv_obj_remove_flag(obj, LV_OBJ_FLAG_HIDDEN); }
// A card's size as its styles request it. LVGL's own getters only follow after a layout pass, and
// that pass walks every object on the screen, so the cards are laid out without asking for one.
inline int tile_width(const Widgets &w) {
int32_t width = lv_obj_get_style_width(w.tile, LV_PART_MAIN);
return LV_COORD_IS_PX(width) ? width : lv_obj_get_width(w.tile);
}
inline int content_width(const Widgets &w) {
return tile_width(w) - lv_obj_get_style_space_left(w.tile, LV_PART_MAIN) - lv_obj_get_style_space_right(w.tile, LV_PART_MAIN);
}
inline int content_height(const Widgets &w) {
return lv_obj_get_height(w.tile) - lv_obj_get_style_space_top(w.tile, LV_PART_MAIN) - lv_obj_get_style_space_bottom(w.tile, LV_PART_MAIN);
}
inline void bind(size_t index, lv_obj_t *tile, lv_obj_t *title, lv_obj_t *value, lv_obj_t *circle, lv_obj_t *icon) {
lv_obj_update_layout(tile);
// Compact cards need room for two text lines and a separate dimmer track.
if(lv_obj_get_height(tile)<=80){lv_obj_set_style_pad_top(tile,4,0);lv_obj_set_style_pad_bottom(tile,4,0);}
lv_obj_set_style_border_width(tile,1,0);
lv_obj_update_layout(tile);
widgets[index] = {tile, title, value, circle, icon, index};
auto &w=widgets[index]; w.title_x=lv_obj_get_x(title);w.title_y=lv_obj_get_y(title);w.value_x=lv_obj_get_x(value);w.value_y=lv_obj_get_y(value);w.value_font=lv_obj_get_style_text_font(value,LV_PART_MAIN);
w.base_width=lv_obj_get_width(tile);
w.icon_font=lv_obj_get_style_text_font(icon,LV_PART_MAIN);
w.unit=lv_label_create(tile);lv_obj_set_style_text_font(w.unit,w.value_font,0);lv_obj_remove_flag(w.unit,LV_OBJ_FLAG_CLICKABLE);lv_obj_add_flag(w.unit,LV_OBJ_FLAG_HIDDEN);
// Fixed one-line boxes prevent wrapped names from overlapping the state on both boards.
lv_obj_set_height(title,lv_font_get_line_height(lv_obj_get_style_text_font(title,LV_PART_MAIN)));
lv_obj_set_height(value,lv_font_get_line_height(w.value_font));
lv_label_set_long_mode(title,LV_LABEL_LONG_DOT);lv_label_set_long_mode(value,LV_LABEL_LONG_DOT);
w.progress=lv_obj_create(tile);lv_obj_remove_style_all(w.progress);lv_obj_set_size(w.progress,0,3);lv_obj_align(w.progress,LV_ALIGN_BOTTOM_LEFT,0,0);lv_obj_add_flag(w.progress,LV_OBJ_FLAG_HIDDEN);
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);
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,14,LV_PART_MAIN);lv_obj_set_style_radius(w.slider,14,LV_PART_INDICATOR);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);
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);
lv_obj_add_event_cb(tile, event, LV_EVENT_SHORT_CLICKED, &widgets[index]);
lv_obj_add_event_cb(tile, event, LV_EVENT_LONG_PRESSED, &widgets[index]);
}
inline void label(lv_obj_t *obj, const std::string &text) {
if (text != lv_label_get_text(obj)) lv_label_set_text(obj, text.c_str());
}
// lv_obj_set_style_* always invalidates the object; these only do so on a real change,
// which keeps a one-second clock tick or a busy spinner from redrawing whole cards.
inline void set_font(lv_obj_t *obj, const lv_font_t *value) { if (lv_obj_get_style_text_font(obj, LV_PART_MAIN) != value) lv_obj_set_style_text_font(obj, value, 0); }
inline void set_text_align(lv_obj_t *obj, lv_text_align_t value) { if (lv_obj_get_style_text_align(obj, LV_PART_MAIN) != value) lv_obj_set_style_text_align(obj, value, 0); }
inline void pad_vertical(lv_obj_t *obj, int value) {
if (lv_obj_get_style_pad_top(obj, LV_PART_MAIN) != value) lv_obj_set_style_pad_top(obj, value, 0);
if (lv_obj_get_style_pad_bottom(obj, LV_PART_MAIN) != value) lv_obj_set_style_pad_bottom(obj, value, 0);
}
inline void set_line_width(lv_obj_t *obj, int value) { if (lv_obj_get_style_line_width(obj, LV_PART_MAIN) != value) lv_obj_set_style_line_width(obj, value, 0); }
// 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) {
auto d=t.domain();
if(d=="light" || d=="switch" || d=="input_boolean" || d=="binary_sensor")return 0xFFC107;
if(d=="climate"){
if(t.state=="cool")return 0x2196F3;
if(t.state=="fan_only")return 0x00BCD4;
if(t.state=="auto")return 0x4CAF50;
if(t.state=="heat_cool")return 0xFFC107;
return t.state=="heat"?0xFF6F22:0xFF9800;
}
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=="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;
}
return 0x2196F3;
}
// 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.
// A slot whose next card draws no custom part only hides them: paging back to the clock or
// graph in that slot reuses its objects instead of creating them again (tens of ms per card).
inline void hide_extra(Widgets &w) {
if(!w.extra)return;
if(!lv_obj_has_flag(w.extra,LV_OBJ_FLAG_HIDDEN))lv_obj_add_flag(w.extra,LV_OBJ_FLAG_HIDDEN);
w.fill_points=nullptr;w.fill_count=0;
}
inline void end_extra(Widgets &w) {
if(!w.extra)return;
hide_extra(w);
if(!w.extra_mode.empty()){lv_obj_clean(w.extra);w.parts.fill(nullptr);w.extra_mode.clear();delete[] w.points;w.points=nullptr;}
}
// Two triangles per segment between the polyline and its baseline. No canvas
// buffer is needed, so the CYD can afford it as well.
inline void extra_draw(lv_event_t *e) {
auto &w=*static_cast<Widgets *>(lv_event_get_user_data(e));
if(!w.fill_points || w.fill_count<2 || !w.fill_opa)return;
auto *layer=lv_event_get_layer(e);lv_area_t area;lv_obj_get_coords(w.extra,&area);
lv_draw_triangle_dsc_t dsc;lv_draw_triangle_dsc_init(&dsc);dsc.color=w.fill_color;dsc.opa=w.fill_opa;
const lv_value_precise_t ox=area.x1+w.fill_x, oy=area.y1+w.fill_y, base=oy+w.fill_base;
for(unsigned i=0;i+1<w.fill_count;++i){
const auto &a=w.fill_points[i],&b=w.fill_points[i+1];
dsc.p[0]={ox+a.x,oy+a.y};dsc.p[1]={ox+b.x,oy+b.y};dsc.p[2]={ox+b.x,base};lv_draw_triangle(layer,&dsc);
dsc.p[1]=dsc.p[2];dsc.p[2]={ox+a.x,base};lv_draw_triangle(layer,&dsc);
}
}
inline void begin_extra(Widgets &w,const char *mode,int width,int height) {
if(!w.extra){
w.extra=lv_obj_create(w.tile);lv_obj_remove_style_all(w.extra);lv_obj_remove_flag(w.extra,LV_OBJ_FLAG_CLICKABLE);lv_obj_remove_flag(w.extra,LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_event_cb(w.extra,extra_draw,LV_EVENT_DRAW_MAIN,&w);
}
if(w.extra_mode!=mode){end_extra(w);w.extra_mode=mode;w.points=new lv_point_precise_t[POINT_BUFFER];w.cached_active=-1;}
w.fill_points=nullptr;w.fill_count=0;
// Parts that were hidden kept the colours of the card they last showed.
if(lv_obj_has_flag(w.extra,LV_OBJ_FLAG_HIDDEN)){w.cached_active=-1;lv_obj_remove_flag(w.extra,LV_OBJ_FLAG_HIDDEN);}
lv_obj_set_pos(w.extra,0,0);lv_obj_set_size(w.extra,width,height);
}
// Catmull-Rom curve through the samples: the trend reads smoothly without extra data.
inline unsigned smooth(const lv_point_precise_t *in,unsigned n,lv_point_precise_t *out,unsigned capacity,int width,int height) {
if(n<2 || capacity<2){for(unsigned i=0;i<n && i<capacity;++i)out[i]=in[i];return n<capacity?n:capacity;}
const unsigned steps=4;unsigned count=0;
for(unsigned i=0;i+1<n;++i){
const auto &p0=in[i?i-1:0],&p1=in[i],&p2=in[i+1],&p3=in[i+2<n?i+2:n-1];
for(unsigned s=0;s<steps && count<capacity-1;++s){
float t=float(s)/steps,t2=t*t,t3=t2*t;
float x=0.5f*(2*p1.x+(-p0.x+p2.x)*t+(2*p0.x-5*p1.x+4*p2.x-p3.x)*t2+(-p0.x+3*p1.x-3*p2.x+p3.x)*t3);
float y=0.5f*(2*p1.y+(-p0.y+p2.y)*t+(2*p0.y-5*p1.y+4*p2.y-p3.y)*t2+(-p0.y+3*p1.y-3*p2.y+p3.y)*t3);
out[count++]={(lv_value_precise_t)std::clamp(x,0.0f,float(width-1)),(lv_value_precise_t)std::clamp(y,0.0f,float(height-1))};
}
}
out[count++]=in[n-1];return count;
}
inline lv_obj_t *part_label(Widgets &w,unsigned i,const lv_font_t *font,int x,int y,int width,lv_text_align_t align,const std::string &text) {
auto *&p=w.parts[i];
if(!p){p=lv_label_create(w.extra);lv_label_set_long_mode(p,LV_LABEL_LONG_CLIP);lv_obj_remove_flag(p,LV_OBJ_FLAG_CLICKABLE);}
set_font(p,font);set_text_align(p,align);
lv_obj_set_pos(p,x,y);lv_obj_set_size(p,std::max(1,width),lv_font_get_line_height(font));label(p,text);return p;
}
inline lv_obj_t *part_dot(Widgets &w,unsigned i,int x,int y,int size) {
auto *&p=w.parts[i];
if(!p){p=lv_obj_create(w.extra);lv_obj_remove_style_all(p);lv_obj_set_style_bg_opa(p,LV_OPA_COVER,0);lv_obj_set_style_radius(p,LV_RADIUS_CIRCLE,0);lv_obj_remove_flag(p,LV_OBJ_FLAG_CLICKABLE);lv_obj_remove_flag(p,LV_OBJ_FLAG_SCROLLABLE);}
lv_obj_set_pos(p,x,y);lv_obj_set_size(p,size,size);return p;
}
// Points are relative to (x,y): the line object then covers only its own rectangle.
inline lv_obj_t *part_line(Widgets &w,unsigned i,lv_point_precise_t *points,unsigned count,int width,int x=0,int y=0) {
auto *&p=w.parts[i];
if(!p){p=lv_line_create(w.extra);lv_obj_remove_flag(p,LV_OBJ_FLAG_CLICKABLE);lv_obj_set_style_line_rounded(p,true,0);}
set_line_width(p,width);lv_line_set_points(p,points,count);lv_obj_set_pos(p,x,y);return p;
}
inline std::string time_text(esphome::ESPTime now) {
return now.is_valid() ? now.strftime(screen_settings::current.clock_24h ? "%H:%M" : "%I:%M") : "--:--";
}
// Digital: big time over the date. Analog: index strokes (numerals at 12/3/6/9 on
// large cards) with hour and minute hands. A single card adds a calendar block
// beside the dial (weekday, big day number, short month); a wide card adds the
// digital time and the date instead. Parts: 0-11 marks, 12-13 hands, 14 centre,
// 15-17 text.
inline void render_clock(Widgets &w,const Tile &t,bool large,int width,int height) {
bool analog=t.display=="analog";
begin_extra(w,analog?(w.wide?"analog":"calendar"):"digital",width,height);
auto now=now_time?now_time():esphome::ESPTime{};
const lv_font_t *big=clock_font?clock_font:watch_value_font?watch_value_font:w.value_font;
const lv_font_t *small=lv_obj_get_style_text_font(w.title,LV_PART_MAIN);
// The date line only appears when both lines fit the card height.
bool with_date=lv_font_get_line_height(big)+2+lv_font_get_line_height(small)<=height;
int text_h=lv_font_get_line_height(big)+(with_date?2+lv_font_get_line_height(small):0);
if(!analog){
int y=std::max(0,(height-text_h)/2);
part_label(w,15,big,0,y,width,LV_TEXT_ALIGN_CENTER,time_text(now));
part_label(w,16,small,0,with_date?y+lv_font_get_line_height(big)+2:y,width,LV_TEXT_ALIGN_CENTER,with_date?date_text(now):"");
return;
}
// The dial keeps the same size with or without a card behind it.
int dial=std::min(height,width),cx=dial/2,cy=height/2,outer=dial/2-1,radius=dial/2-(large?4:2);
for(int i=0;i<12;++i){
float a=i*3.14159265f/6;bool cardinal=i%3==0;
if(cardinal && large){
// Numerals replace the four cardinal strokes; the box is one line high and wide.
int box=lv_font_get_line_height(w.value_font)+4,ring=outer-11;
part_label(w,i,w.value_font,cx+std::lround(ring*sinf(a))-box/2,cy-std::lround(ring*cosf(a))-box/2,box,LV_TEXT_ALIGN_CENTER,i==0?"12":std::to_string(i));
continue;
}
int length=cardinal?(large?9:5):(large?5:3);
auto *p=w.points+4+2*i;
p[0]={(lv_value_precise_t)(cx+outer*sinf(a)),(lv_value_precise_t)(cy-outer*cosf(a))};
p[1]={(lv_value_precise_t)(cx+(outer-length)*sinf(a)),(lv_value_precise_t)(cy-(outer-length)*cosf(a))};
part_line(w,i,p,2,cardinal?(large?3:2):(large?2:1));
}
float hour=((now.is_valid()?now.hour%12:0)+(now.is_valid()?now.minute:0)/60.0f)*3.14159265f/6, minute=(now.is_valid()?now.minute:0)*3.14159265f/30;
w.points[0]={(lv_value_precise_t)cx,(lv_value_precise_t)cy};w.points[1]={(lv_value_precise_t)(cx+radius*0.52f*sinf(hour)),(lv_value_precise_t)(cy-radius*0.52f*cosf(hour))};
w.points[2]={(lv_value_precise_t)cx,(lv_value_precise_t)cy};w.points[3]={(lv_value_precise_t)(cx+radius*0.82f*sinf(minute)),(lv_value_precise_t)(cy-radius*0.82f*cosf(minute))};
part_line(w,12,w.points,2,large?5:3);part_line(w,13,w.points+2,2,large?3:2);
int center=large?8:4;part_dot(w,14,cx-center/2,cy-center/2,center);
std::string day=now.is_valid()?std::to_string(now.day_of_month):"--";
if(w.wide){
int x=dial+(large?16:8),y=std::max(0,(height-text_h)/2);
part_label(w,15,big,x,y,width-x,LV_TEXT_ALIGN_CENTER,time_text(now));
part_label(w,16,small,x,with_date?y+lv_font_get_line_height(big)+2:y,width-x,LV_TEXT_ALIGN_CENTER,with_date?date_text(now):"");
return;
}
int x=dial+(large?10:6),room=std::max(1,width-x);
if(!large){
// Compact cards: "13 sep" in the large-value font beside the dial.
const lv_font_t *font=watch_value_font?watch_value_font:w.value_font;
part_label(w,16,font,x,std::max(0,int(height-lv_font_get_line_height(font))/2),room,LV_TEXT_ALIGN_CENTER,day+" "+month_short(now));
return;
}
// Calendar block: weekday over a big day number with the short month beside it.
int top=std::max(0,int(height-lv_font_get_line_height(w.value_font)-lv_font_get_line_height(big))/2);
part_label(w,15,w.value_font,x,top,room,LV_TEXT_ALIGN_CENTER,weekday_text(now));
std::string month=month_short(now);
lv_point_t day_size,month_size;
lv_text_get_size(&day_size,day.c_str(),big,0,0,LV_COORD_MAX,LV_TEXT_FLAG_EXPAND);
lv_text_get_size(&month_size,month.c_str(),small,0,0,LV_COORD_MAX,LV_TEXT_FLAG_EXPAND);
int gap=6,sx=x+std::max(0,int(room-(day_size.x+gap+month_size.x))/2),day_y=top+int(lv_font_get_line_height(w.value_font));
part_label(w,16,big,sx,day_y,std::min(room,(int)day_size.x+2),LV_TEXT_ALIGN_LEFT,day);
// Both baselines line up: LVGL measures base_line from the bottom of the line box.
int month_y=day_y+(big->line_height-big->base_line)-(small->line_height-small->base_line);
part_label(w,17,small,sx+day_size.x+gap,std::max(0,month_y),std::max(1,int(x+room-(sx+day_size.x+gap))),LV_TEXT_ALIGN_LEFT,month);
}
// Current conditions on the left, five day columns on the right (wide cards only).
inline void render_forecast(Widgets &w,const Tile &t,bool large,int width,int height) {
begin_extra(w,"forecast",width,height);
const lv_font_t *title_font=lv_obj_get_style_text_font(w.title,LV_PART_MAIN);
const lv_font_t *temp_font=watch_value_font?watch_value_font:w.value_font,*day_icon=mini_icon_font?mini_icon_font:w.icon_font;
int left=large?150:96,icon_h=lv_font_get_line_height(w.icon_font),temp_h=lv_font_get_line_height(temp_font),text_h=lv_font_get_line_height(w.value_font);
int block=std::max(icon_h,temp_h)+2+text_h,y=std::max(0,(height-block)/2);
char b[24];snprintf(b,sizeof(b),"%.0f°",t.current);
auto *icon=part_label(w,0,w.icon_font,0,y+(std::max(icon_h,temp_h)-icon_h)/2,icon_h+4,LV_TEXT_ALIGN_LEFT,t.available()?weather_icon(t.state):"\U000F0595");
lv_obj_set_width(icon,lv_font_get_line_height(w.icon_font)+4);
part_label(w,1,temp_font,icon_h+6,y+(std::max(icon_h,temp_h)-temp_h)/2,left-icon_h-6,LV_TEXT_ALIGN_LEFT,std::isfinite(t.current)?b:"");
part_label(w,2,w.value_font,0,y+std::max(icon_h,temp_h)+2,left-4,LV_TEXT_ALIGN_LEFT,weather_text(t.state));
int column=(width-left)/5,day_h=lv_font_get_line_height(title_font),icon_col=lv_font_get_line_height(day_icon);
for(unsigned k=0;k<5;++k){
int x=left+k*column;bool has=k<t.forecast.size();const auto &f=t.forecast[k];
char temps[24];if(has && std::isfinite(f.high))snprintf(temps,sizeof(temps),std::isfinite(f.low)?"%.0f/%.0f":"%.0f",f.high,f.low);else temps[0]=0;
if(large){
int rows=day_h+icon_col+text_h,top=std::max(0,(height-rows)/2);
part_label(w,3+k*3,title_font,x,top,column,LV_TEXT_ALIGN_CENTER,has?f.day:"");
part_label(w,4+k*3,day_icon,x,top+day_h,column,LV_TEXT_ALIGN_CENTER,has?weather_icon(f.condition):"");
part_label(w,5+k*3,w.value_font,x,top+day_h+icon_col,column,LV_TEXT_ALIGN_CENTER,temps);
}else{
// Two rows on the CYD: day beside its icon, then the high/low pair.
int rows=std::max(day_h,icon_col)+text_h,top=std::max(0,(height-rows)/2),day_w=column-icon_col-2;
part_label(w,3+k*3,title_font,x,top+(std::max(day_h,icon_col)-day_h)/2,day_w,LV_TEXT_ALIGN_RIGHT,has?f.day:"");
part_label(w,4+k*3,day_icon,x+day_w+2,top,icon_col,LV_TEXT_ALIGN_LEFT,has?weather_icon(f.condition):"");
part_label(w,5+k*3,w.value_font,x,top+std::max(day_h,icon_col),column,LV_TEXT_ALIGN_CENTER,temps);
}
}
}
// Smoothed trend of the manager's 24 history samples with a soft fill beneath.
inline void render_graph(Widgets &w,const Tile &t,bool large,int x,int y,int width,int height) {
begin_extra(w,"graph",x+width,y+height);
float minimum=INFINITY,maximum=-INFINITY;for(float v:t.history)if(std::isfinite(v)){minimum=std::min(minimum,v);maximum=std::max(maximum,v);}
lv_point_precise_t raw[24];unsigned n=0;int stroke=large?3:2,top=stroke;
for(unsigned i=0;i<24;++i){
if(!std::isfinite(t.history[i]))continue;
float level=maximum>minimum?(t.history[i]-minimum)/(maximum-minimum):0.5f;
raw[n++]={(lv_value_precise_t)(stroke/2+i*(width-stroke-1)/23),(lv_value_precise_t)(height-stroke/2-1-level*(height-stroke-1-top))};
}
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;
}
// 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.
inline int minutes_of(const std::string &clock) {
unsigned h=0,m=0;return sscanf(clock.c_str(),"%u:%u",&h,&m)==2 && h<24 && m<60 ? int(h*60+m) : -1;
}
inline void render_sunpath(Widgets &w,const Tile &t,bool large,int width,int height) {
begin_extra(w,"sunpath",width,height);
const lv_font_t *title_font=lv_obj_get_style_text_font(w.title,LV_PART_MAIN);
int title_h=lv_font_get_line_height(title_font),text_h=lv_font_get_line_height(w.value_font);
int horizon=height-text_h-(large?4:2),top=title_h+(large?4:2),x0=large?14:8,x1=width-x0;
part_label(w,0,title_font,0,0,width,LV_TEXT_ALIGN_LEFT,t.name.empty()?"Sun":t.name);
part_label(w,1,w.value_font,0,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_LEFT,"rise "+t.sunrise);
part_label(w,2,w.value_font,width/2,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_RIGHT,"set "+t.sunset);
auto now=now_time?now_time():esphome::ESPTime{};
int rise=minutes_of(t.sunrise),set=minutes_of(t.sunset),minute=now.is_valid()?now.hour*60+now.minute:-1;
bool day=t.state=="above_horizon";float fraction=0.5f;
if(rise>=0 && set>=0 && minute>=0){
if(day){int span=(set-rise+1440)%1440;if(!span)span=1;fraction=std::clamp(float((minute-rise+1440)%1440)/span,0.0f,1.0f);}
else{int span=(rise-set+1440)%1440;if(!span)span=1;fraction=std::clamp(float((minute-set+1440)%1440)/span,0.0f,1.0f);}
}
const unsigned segments=40;float amplitude=day?float(horizon-top):float(height-text_h-horizon-(large?2:1));
auto *arc=w.points,*travelled=w.points+segments+1,*line=w.points+2*segments+3;
for(unsigned i=0;i<=segments;++i){
float a=3.14159265f*i/segments;
arc[i]={(lv_value_precise_t)(x0+(x1-x0)*float(i)/segments),(lv_value_precise_t)(day?horizon-sinf(a)*amplitude:horizon+sinf(a)*amplitude)};
}
unsigned filled=std::min(segments,unsigned(fraction*segments));
for(unsigned i=0;i<=filled;++i)travelled[i]=arc[i];
float sa=3.14159265f*fraction;
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;
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;}
}
// ---- Direct controls on wide cards (Home Assistant entity-row style) ----
struct PanelMetrics { int key_w, key_h, radius, gap, pill_w, pill_key, slider_w, slider_h, toggle_w, toggle_h, run_pad, text_gap, ext; };
inline PanelMetrics panel_metrics(bool large) {
return large ? PanelMetrics{60,46,14,8,196,52,140,44,76,40,22,8,4} : PanelMetrics{40,34,9,4,128,36,90,30,48,26,14,6,6};
}
inline lv_obj_t *panel_obj(lv_obj_t *parent,bool clickable) {
auto *o=lv_obj_create(parent);lv_obj_remove_style_all(o);lv_obj_remove_flag(o,LV_OBJ_FLAG_SCROLLABLE);
if(clickable)lv_obj_add_flag(o,LV_OBJ_FLAG_CLICKABLE);else lv_obj_remove_flag(o,LV_OBJ_FLAG_CLICKABLE);
return o;
}
// Like the custom parts: a card without controls hides the slot's panel and a later card with
// the same control set shows it again.
inline void hide_panel(Widgets &w) {
if(!w.panel)return;
if(!lv_obj_has_flag(w.panel,LV_OBJ_FLAG_HIDDEN))lv_obj_add_flag(w.panel,LV_OBJ_FLAG_HIDDEN);
w.panel_w=0;
if(captured_slider && captured_slider==w.control_slider)captured_slider=nullptr;
}
inline void end_panel(Widgets &w) {
if(!w.panel)return;
hide_panel(w);
if(!w.panel_mode.empty()){
lv_obj_clean(w.panel);w.keys.fill(nullptr);w.key_icons.fill(nullptr);w.key_checked.fill(-1);
w.pill=w.pill_value=w.knob=w.control_slider=nullptr;w.knob_on=-1;w.panel_mode.clear();
}
}
inline void control_event(lv_event_t *e);
// A pill key: rounded, pressed darker, checked in the accent, disabled faded.
inline lv_obj_t *panel_key(Widgets &w,unsigned n,lv_obj_t *parent,const PanelMetrics &m,int width,int height,bool transparent) {
auto *key=panel_obj(parent,true);lv_obj_set_size(key,width,height);
lv_obj_set_style_radius(key,transparent?LV_RADIUS_CIRCLE:m.radius,0);
lv_obj_set_style_bg_opa(key,transparent?LV_OPA_TRANSP:LV_OPA_COVER,0);
lv_obj_set_style_bg_opa(key,LV_OPA_COVER,LV_STATE_PRESSED);
lv_obj_set_style_bg_opa(key,LV_OPA_COVER,LV_STATE_CHECKED);
lv_obj_set_style_opa(key,LV_OPA_40,LV_STATE_DISABLED);
lv_obj_set_ext_click_area(key,m.ext);
size_t slot=&w-widgets.data();
lv_obj_add_event_cb(key,control_event,LV_EVENT_SHORT_CLICKED,(void*)(uintptr_t)(slot*16+n));
w.keys[n]=key;w.key_checked[n]=-1;
return key;
}
inline lv_obj_t *panel_icon(Widgets &w,unsigned n,const lv_font_t *font) {
auto *icon=lv_label_create(w.keys[n]);lv_obj_remove_flag(icon,LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_text_font(icon,font,0);lv_obj_center(icon);w.key_icons[n]=icon;return icon;
}
// Build (once per control set) and lay out the panel; returns the width it takes
// from the text, including the gap, or 0 when the card shows no panel.
inline int layout_panel(Widgets &w,const Tile &t,bool large,int content_w,int content_h) {
std::string mode=tile_controls::panel_kind(t);
const PanelMetrics m=panel_metrics(large);
const lv_font_t *icon_font=mini_icon_font?mini_icon_font:w.icon_font;
const lv_font_t *text_font=control_font?control_font:lv_obj_get_style_text_font(w.title,LV_PART_MAIN);
auto d=t.domain();
if(!w.panel){w.panel=panel_obj(w.tile,false);}
if(w.panel_mode!=mode){
end_panel(w);w.panel_mode=mode;w.panel_dirty=true;
if(tile_controls::is_key_row(mode)){
for(unsigned n=0;n<3;++n){panel_key(w,n,w.panel,m,m.key_w,m.key_h,false);panel_icon(w,n,icon_font);}
}else if(mode=="setpoint"||mode=="stepper"){
w.pill=panel_obj(w.panel,false);lv_obj_set_size(w.pill,m.pill_w,m.key_h+2);
lv_obj_set_style_radius(w.pill,LV_RADIUS_CIRCLE,0);lv_obj_set_style_bg_opa(w.pill,LV_OPA_COVER,0);
panel_key(w,0,w.pill,m,m.pill_key,m.key_h+2,true);panel_icon(w,0,icon_font);lv_label_set_text(w.key_icons[0],tile_controls::glyph::MINUS);
panel_key(w,1,w.pill,m,m.pill_key,m.key_h+2,true);panel_icon(w,1,icon_font);lv_label_set_text(w.key_icons[1],tile_controls::glyph::PLUS);
// Holding -/+ keeps stepping (LVGL repeats while pressed); one call goes out after the finger rests.
for(unsigned n=0;n<2;++n)lv_obj_add_event_cb(w.keys[n],control_event,LV_EVENT_LONG_PRESSED_REPEAT,(void*)(uintptr_t)((&w-widgets.data())*16+n));
lv_obj_set_pos(w.keys[0],0,0);lv_obj_set_pos(w.keys[1],m.pill_w-m.pill_key,0);
w.pill_value=lv_label_create(w.pill);lv_obj_remove_flag(w.pill_value,LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_text_font(w.pill_value,text_font,0);lv_obj_set_style_text_align(w.pill_value,LV_TEXT_ALIGN_CENTER,0);
lv_label_set_long_mode(w.pill_value,LV_LABEL_LONG_CLIP);
lv_obj_set_size(w.pill_value,m.pill_w-2*m.pill_key,lv_font_get_line_height(text_font));
lv_obj_set_pos(w.pill_value,m.pill_key,(m.key_h+2-lv_font_get_line_height(text_font))/2);
w.key_commands[0]=tile_controls::STEP_DOWN;w.key_commands[1]=tile_controls::STEP_UP;
}else if(tile_controls::is_slider(mode)){
int h=mode=="volume"?m.slider_h:m.slider_h;
auto *slider=lv_slider_create(w.panel);w.control_slider=slider;
lv_obj_remove_flag(slider,LV_OBJ_FLAG_GESTURE_BUBBLE);lv_obj_remove_flag(slider,LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(slider,mode=="volume"?m.slider_w:m.pill_w,h);
lv_obj_set_style_pad_all(slider,0,LV_PART_MAIN);
lv_obj_set_style_radius(slider,LV_RADIUS_CIRCLE,LV_PART_MAIN);lv_obj_set_style_radius(slider,LV_RADIUS_CIRCLE,LV_PART_INDICATOR);
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);
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);
lv_obj_add_event_cb(slider,slider_event,LV_EVENT_ALL,(void*)(uintptr_t)w.index);
if(mode=="volume"){panel_key(w,0,w.panel,m,m.key_h,m.key_h,false);panel_icon(w,0,icon_font);w.key_commands[0]=tile_controls::MEDIA_MUTE;}
}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);
w.key_commands[0]=tile_controls::TOGGLE;
}else if(mode=="run"){
const char *text=tile_controls::run_label(d);
lv_point_t size;lv_text_get_size(&size,text,text_font,0,0,LV_COORD_MAX,LV_TEXT_FLAG_EXPAND);
panel_key(w,0,w.panel,m,std::max(m.key_w*3/2,(int)size.x+2*m.run_pad),m.key_h,false);lv_obj_set_style_radius(w.keys[0],LV_RADIUS_CIRCLE,0);
panel_icon(w,0,text_font);lv_label_set_text(w.key_icons[0],text);
w.key_commands[0]=tile_controls::RUN;
}else{end_panel(w);return 0;}
}
// Per-render contents: which keys, their icons and states; then the panel size and place.
int panel_w=0,panel_h=m.key_h;uint32_t now=esphome::millis();
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);
};
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)){
std::array<tile_controls::Key,3> keys;unsigned count=tile_controls::keys_for(t,keys);
for(unsigned n=0;n<3;++n){
if(n>=count){lv_obj_add_flag(w.keys[n],LV_OBJ_FLAG_HIDDEN);w.key_commands[n]=tile_controls::NONE;continue;}
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);
set_checked(n,keys[n].checked);set_disabled(n,keys[n].disabled);
}
panel_w=count?count*m.key_w+(count-1)*m.gap:0;
}else if(mode=="setpoint"||mode=="stepper"){
float shown=std::isfinite(t.edit_value)?t.edit_value:tile_controls::edit_target(t);
std::string suffix=d=="climate"?"°":t.unit.empty()?"":" "+t.unit;
label(w.pill_value,tile_controls::format_value(shown,tile_controls::edit_step(t),suffix.c_str()));
panel_w=m.pill_w;panel_h=m.key_h+2;
}else if(tile_controls::is_slider(mode)){
bool has_slider=mode!="volume" || (t.supported & tile_controls::feature::MEDIA_VOLUME_SET);
if(has_slider){
lv_obj_remove_flag(w.control_slider,LV_OBJ_FLAG_HIDDEN);lv_obj_set_pos(w.control_slider,0,(panel_h-m.slider_h)/2);
// Keep the dragged value while the command is under way; HA's report takes over afterwards.
if(!lv_obj_has_state(w.control_slider,LV_STATE_PRESSED) && !(t.pending && !t.confirmed))lv_slider_set_value(w.control_slider,slider_value(t),LV_ANIM_OFF);
panel_w=mode=="volume"?m.slider_w:m.pill_w;
}else lv_obj_add_flag(w.control_slider,LV_OBJ_FLAG_HIDDEN);
if(mode=="volume"){
bool has_mute=t.supported & tile_controls::feature::MEDIA_VOLUME_MUTE;
if(has_mute){
lv_obj_remove_flag(w.keys[0],LV_OBJ_FLAG_HIDDEN);lv_obj_set_pos(w.keys[0],panel_w?panel_w+m.gap:0,0);
label(w.key_icons[0],t.muted?tile_controls::glyph::MUTED:tile_controls::glyph::VOLUME);
if(t.muted && w.key_checked[0]!=1)lv_obj_set_style_bg_color(w.keys[0],w.panel_accent,LV_STATE_CHECKED);
set_checked(0,t.muted);panel_w+=(panel_w?m.gap:0)+m.key_h;
}else lv_obj_add_flag(w.keys[0],LV_OBJ_FLAG_HIDDEN);
}
}else if(mode=="toggle"){
bool on=t.pending && !t.confirmed ? t.optimistic_on : t.state=="on";
if(on && w.key_checked[0]!=1)lv_obj_set_style_bg_color(w.keys[0],w.panel_accent,LV_STATE_CHECKED);
set_checked(0,on);
if(w.knob_on!=(int)on){w.knob_on=on;lv_obj_set_x(w.knob,on?m.toggle_w-(m.toggle_h-8)-4:4);}
panel_w=m.toggle_w;panel_h=m.toggle_h;
}else if(mode=="run"){
// The requested width: a key created in this pass has no coordinates before LVGL's layout.
panel_w=lv_obj_get_style_width(w.keys[0],LV_PART_MAIN);
}
(void)now;
if(!panel_w){hide_panel(w);return 0;}
// A panel shown again kept the colours of the card it last served.
if(lv_obj_has_flag(w.panel,LV_OBJ_FLAG_HIDDEN)){lv_obj_remove_flag(w.panel,LV_OBJ_FLAG_HIDDEN);w.panel_dirty=true;}
lv_obj_set_size(w.panel,panel_w,panel_h);lv_obj_set_pos(w.panel,content_w-panel_w,std::max(0,(content_h-panel_h)/2));
w.panel_w=panel_w+m.text_gap;
return w.panel_w;
}
// 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);
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);
}
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){
lv_color_t fill=fresh() && t.active()?accent:lv_color_hex(0x9E9E9E);
set_color(w.control_slider,LV_STYLE_BG_COLOR,lv_color_mix(fill,card,76),LV_PART_MAIN);
set_color(w.control_slider,LV_STYLE_BG_COLOR,fill,LV_PART_INDICATOR);
}
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);}
}
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;
if(slot>=widgets.size() || n>=3)return;
auto &w=widgets[slot];
if(!enabled || !fresh() || w.index>=model.count || !w.keys[n])return;
if(lv_obj_has_state(w.keys[n],LV_STATE_DISABLED))return;
uint32_t now=esphome::millis();
auto &t=model.tiles[w.index];
int command=w.key_commands[n];
bool step=command==tile_controls::STEP_DOWN || command==tile_controls::STEP_UP;
bool held=lv_event_get_code(e)==LV_EVENT_LONG_PRESSED_REPEAT;
if(held){ if(!step || now-t.edit_since<300)return; } // three steps a second while holding
else if(step){ if(!cyd::touch_guard.accept_repeat(now,400+slot*16+n)){ESP_LOGI("touch","tap on control %u ignored: %s",(unsigned)slot,cyd::touch_guard.reason().c_str());return;} }
else if(!allowed(now,400+slot*16+n,"control "+std::to_string(slot)))return;
if(!t.available())return;
if(step){
// Local at once, tap after tap; tick() sends the last value after a short pause.
float current=std::isfinite(t.edit_value)?t.edit_value:tile_controls::edit_target(t);
t.edit_value=tile_controls::step_value(current,tile_controls::edit_step(t),t.minimum,t.maximum,command==tile_controls::STEP_UP?1:-1);
t.edit_since=now;t.edit_sent=false;
if(w.pill_value){std::string suffix=t.domain()=="climate"?"°":t.unit.empty()?"":" "+t.unit;label(w.pill_value,tile_controls::format_value(t.edit_value,tile_controls::edit_step(t),suffix.c_str()));}
return;
}
if(t.awaiting_action(now))return;
if(command==tile_controls::TOGGLE)t.optimistic_on=t.state!="on";
auto a=tile_controls::key_action(t,command,w.key_args[n]);
if(a.valid())action(a.service,t.entity,a.key,a.value);
}
// A busy card is covered by a translucent white sheet with a small spinner until
// Home Assistant confirms; the sheet also swallows taps meanwhile.
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_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_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
}
// Cover the whole card, padding included, at the width the card asks for: a slot that just turned
// wide is still single width in LVGL's own coordinates. Unchanged sizes cost LVGL nothing.
lv_obj_set_pos(w.busy,-lv_obj_get_style_pad_left(w.tile,LV_PART_MAIN),-lv_obj_get_style_pad_top(w.tile,LV_PART_MAIN));
lv_obj_set_size(w.busy,tile_width(w),lv_obj_get_height(w.tile));
lv_obj_remove_flag(w.busy,LV_OBJ_FLAG_HIDDEN);
// Controls and custom parts created after the sheet would otherwise paint over its right side.
if(lv_obj_get_index(w.busy)!=(int32_t)lv_obj_get_child_count(w.tile)-1)lv_obj_move_foreground(w.busy);
}
// One card: name, status, icon, layout and palette. Everything reads the model; nothing is created
// unless the card's custom parts or controls change kind.
inline void render_slot(size_t slot) {
swipe_profile::SlotTimer slot_timer(slot);
swipe_profile::Lap lap;
auto &w=widgets[slot];
if(!w.tile || w.index>=model.count)return;
const auto &t = model.tiles[w.index];
auto d=t.domain();
label(w.title, t.name.empty() ? t.entity : t.name);
label(w.icon, icon_for(t));
bool watch=t.display=="watch";
std::string unit=watch?t.unit:"";
std::string value = t.state;
if (!fresh() || !t.available()) value = "Unavailable";
else if (d == "light" && t.state == "on" && std::isfinite(t.brightness)) value = std::to_string(static_cast<int>(std::lround(std::clamp(t.brightness, 0.0f, 255.0f) * 100 / 255))) + " %";
else if (d == "climate" && std::isfinite(t.target)) { char b[32]; snprintf(b, sizeof(b), "%.1f°", t.target); value = b; }
else if (d == "person") value = t.state=="home"?"Home":t.state=="not_home"?"Away":t.state;
else if (d == "sun") value = !t.sunrise.empty() && !t.sunset.empty() ? t.sunrise+" - "+t.sunset : t.state=="above_horizon"?"Above the horizon":"Below the horizon";
else if (d == "timer") value = timer_text(t);
else if (d == "script" || d == "scene" || d == "button" || d == "input_button") value = t.state == "on" ? "Running..." : last_run_text(t.last_run);
else if (value == "on") value = "On";
else if (value == "off") value = "Off";
else if (value == "cleaning") value = "Cleaning";
else if (value == "docked") value = "Docked";
else if (!t.unit.empty() && !watch) value += " " + t.unit;
bool pending=t.loading(esphome::millis());
if(d=="weather" && std::isfinite(t.current)) {char b[32];snprintf(b,sizeof(b),"%.1f %s",t.current,t.unit.c_str());value=b;if(watch){snprintf(b,sizeof(b),"%.1f",t.current);value=b;}}
if(d=="vacuum" && std::isfinite(t.battery))value += " / "+std::to_string((int)t.battery)+"%";
// Direct controls: only a wide card in the standard layout has room for the panel.
bool with_panel=w.wide && !t.controls.empty() && !t.builtin() && !watch && t.inline_control!="slider" && fresh() && t.available();
if(with_panel){std::string status=tile_controls::status_text(t);if(!status.empty())value=status;}
label(w.value, value);
bool mini=t.inline_control=="slider" && !watch && t.available();
bool large_tile=lv_obj_get_height(w.tile)>80;
lap(swipe_profile::TEXT);
// Cards that replace the name/status layout entirely.
bool clock=t.builtin(), forecast=d=="weather" && t.display=="forecast" && w.wide && t.forecast.size()>0 && fresh() && t.available();
bool sunpath=d=="sun" && t.display=="sunpath" && w.wide && !t.sunrise.empty() && !t.sunset.empty() && fresh() && t.available();
bool graph=d=="sensor" && t.display=="graph" && t.has_history && !clock;
bool custom=clock||forecast||sunpath;
if(!large_tile)pad_vertical(w.tile,watch||custom||graph?2:4);
set_font(w.value,watch && watch_value_font ? watch_value_font : w.value_font);
// Both text boxes are one line high; the sizes come from the styles, not from a layout pass.
int title_height=lv_font_get_line_height(lv_obj_get_style_text_font(w.title,LV_PART_MAIN));
int value_height=lv_font_get_line_height(lv_obj_get_style_text_font(w.value,LV_PART_MAIN));
lv_obj_set_height(w.value,value_height);
int content_w=content_width(w),content_h=content_height(w);
lap(swipe_profile::LAYOUT);
set_busy(w,pending && !t.builtin(),large_tile);
lap(swipe_profile::BUSY);
for(auto *o:{w.title,w.value,w.circle,w.unit})set_hidden(o,custom);
if(custom){
lv_obj_add_flag(w.slider,LV_OBJ_FLAG_HIDDEN);hide_panel(w);
lap(swipe_profile::GEOMETRY);
if(clock)render_clock(w,t,large_tile,content_w,content_h);
else if(sunpath)render_sunpath(w,t,large_tile,content_w,content_h);
else render_forecast(w,t,large_tile,content_w,content_h);
lap(swipe_profile::CUSTOM);
}else{
int line_gap=large_tile?2:1,text_height=title_height+line_gap+value_height;
int slider_height=large_tile?28:8;
// A single-width graph takes the slider strip; a wide graph takes the right half.
bool graph_strip=graph && !w.wide, graph_side=graph && w.wide;
int chart_w=graph_side?content_w*55/100:0;
lap(swipe_profile::GEOMETRY);
int panel_w=with_panel && !graph?layout_panel(w,t,large_tile,content_w,content_h):0;
if(!panel_w)hide_panel(w);
lap(swipe_profile::PANEL);
int header_height=(mini||graph_strip)?content_h-slider_height-(large_tile?6:3):content_h;
int text_y=std::max(0,(header_height-text_height)/2);
int circle_size=watch?(large_tile?26:18):(mini||graph_strip)?(large_tile?36:24):(large_tile?54:36);
lv_obj_set_size(w.circle,circle_size,circle_size);
const lv_font_t *icon_font=watch && watch_icon_font ? watch_icon_font : (mini||graph_strip) && mini_icon_font ? mini_icon_font : w.icon_font;
if(lv_obj_get_style_text_font(w.icon,LV_PART_MAIN)!=icon_font){set_font(w.icon,icon_font);lv_obj_center(w.icon);}
int text_x=watch?0:(mini||graph_strip)?circle_size+(large_tile?8:6):w.title_x;
lv_obj_set_pos(w.title,text_x,watch?0:(mini||graph_strip||!large_tile)?text_y:w.title_y);
lv_obj_set_pos(w.value,watch?0:(mini||graph_strip)?text_x:w.value_x,
watch?(large_tile?42:19):(mini||graph_strip||!large_tile)?text_y+title_height+line_gap:w.value_y);
lv_obj_set_pos(w.circle,0,(mini||graph_strip||!large_tile)?std::max(0,(header_height-circle_size)/2):12);
// Use the requested coordinates: LVGL getters still return the previous
// layout until its next pass when a slot changes from watch/slider to normal.
int text_room=content_w-chart_w-(graph_side?(large_tile?10:6):0)-panel_w;
lv_obj_set_width(w.title,std::max(1,text_room-text_x));
lv_obj_set_width(w.value,std::max(1,text_room-(watch?0:(mini||graph_strip)?text_x:w.value_x)));
if(watch){
int gap=large_tile?6:2,header=std::max(circle_size,title_height);
int group_y=std::max(0,(content_h-header-gap-value_height)/2);
int value_y=group_y+header+gap;
lv_obj_set_pos(w.circle,0,group_y+(header-circle_size)/2);
lv_obj_set_pos(w.title,circle_size+(large_tile?6:4),group_y+(header-title_height)/2);
lv_obj_set_width(w.title,text_room-circle_size-(large_tile?6:4));
label(w.unit,unit);
lv_point_t size;lv_text_get_size(&size,unit.c_str(),w.value_font,0,0,LV_COORD_MAX,LV_TEXT_FLAG_EXPAND);
int unit_width=unit.empty()?0:std::min((int)size.x,text_room-20);
int number_width=text_room-(unit_width?unit_width+(large_tile?6:3):0);
lv_obj_set_pos(w.value,0,value_y);lv_obj_set_width(w.value,number_width);
lv_obj_set_pos(w.unit,text_room-unit_width,value_y+value_height-lv_font_get_line_height(w.value_font));
lv_obj_set_size(w.unit,unit_width,lv_font_get_line_height(w.value_font));
set_hidden(w.unit,!unit_width);
}else lv_obj_add_flag(w.unit,LV_OBJ_FLAG_HIDDEN);
lv_obj_set_size(w.slider,content_w,slider_height);
slider_handle(w.slider,content_w,slider_height);
if(mini){lv_obj_remove_flag(w.slider,LV_OBJ_FLAG_HIDDEN);if(!lv_obj_has_state(w.slider,LV_STATE_PRESSED) && lv_slider_get_value(w.slider)!=slider_value(t))lv_slider_set_value(w.slider,slider_value(t),LV_ANIM_OFF);}
else lv_obj_add_flag(w.slider,LV_OBJ_FLAG_HIDDEN);
lap(swipe_profile::GEOMETRY);
if(graph_strip)render_graph(w,t,large_tile,0,content_h-slider_height,content_w,slider_height);
else if(graph_side)render_graph(w,t,large_tile,content_w-chart_w,0,chart_w,content_h);
else hide_extra(w);
lap(swipe_profile::CUSTOM);
}
bool on = fresh() && t.active();
bool available=fresh() && t.available();
int palette_state=(available?2:0)|(on?1:0);
if (w.cached_active == palette_state && !w.panel_dirty) { lap(swipe_profile::GEOMETRY); return; }
w.cached_active = palette_state;w.panel_dirty=false;
uint32_t accent = domain_accent(t);
auto color=lv_color_hex((t.is_switch()||d=="person"||d=="timer") && !on ? 0x9E9E9E : 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);
// Off: the round end that holds the handle is grey, so the slider reads as off too.
auto fill_color=on?color:lv_color_hex(0x9E9E9E);
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),30),LV_PART_MAIN);
set_color(w.tile,LV_STYLE_BG_COLOR,lv_color_hex(t.background ? t.background : 0xFFFFFF));
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.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));
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;
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<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);
}
// What the next render() draws besides the name and the top bar: the cards of the tiles a state
// message or a tick named, or every card. A refresh that names nothing (the board's own triggers,
// such as the minute tick and time sync) draws every card.
inline uint32_t dirty_tiles=0;
inline bool dirty_all=false, dirty_header=false;
inline void refresh_tile(size_t index) { if(index<32)dirty_tiles|=1u<<index; else dirty_all=true; if(refresh)refresh(); }
inline void refresh_header_only() { dirty_header=true; if(refresh)refresh(); }
inline void refresh_all() { dirty_all=true; if(refresh)refresh(); }
// Slots whose new page content is still to come: the page fill draws them, render() leaves them.
inline std::array<bool,SLOTS_PER_PAGE> slot_pending{};
// Cards per fill step, the swipe pass included; the next waiting slot; the step timer, and whether
// LVGL refreshed the screen since the last step.
inline size_t FILL_STEP_CARDS=2;
inline size_t fill_next=SLOTS_PER_PAGE;
inline lv_timer_t *fill_timer=nullptr;
inline bool fill_refreshed=false;
inline uint32_t fill_step_ms=0;
inline bool fill_cards(size_t cards);
inline void render(lv_obj_t *room) {
if (!enabled) return;
room_label=room; swipe_profile::Lap lap;
label(room, !model.configured ? "Choose tiles in HA" : !model.ready() ? "Loading tiles..." : !ha_connected() ? "HA not connected" : !feed_alive() ? "ESP Screens not active" : model.title);
render_header();
lap(swipe_profile::HEADER);
bool all=dirty_all || (!dirty_tiles && !dirty_header);
uint32_t tiles=dirty_tiles;
dirty_all=dirty_header=false;dirty_tiles=0;
for (size_t slot = 0; slot < SLOTS_PER_PAGE; ++slot) {
const auto &w=widgets[slot];
if(slot_pending[slot] || w.index>=model.count)continue;
if(all || (w.index<32 && (tiles>>w.index)&1))render_slot(slot);
}
}
// ---- Top bar ----
// 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; };
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);
}
// Ink edges of a text from its label's left edge, and its advance (what LVGL sizes a label by).
struct TextInk { int left = 0, right = 0, advance = 0; };
inline TextInk text_ink(const lv_font_t *font, const std::string &text) {
TextInk ink;
bool first = true;
size_t i = 0;
for (uint32_t cp = header_bar::next_codepoint(text, i); cp; cp = header_bar::next_codepoint(text, i)) {
lv_font_glyph_dsc_t g;
if (!lv_font_get_glyph_dsc(font, &g, cp, 0)) continue;
if (g.box_w > 0) {
if (first) { ink.left = ink.advance + g.ofs_x; first = false; }
ink.right = ink.advance + g.ofs_x + g.box_w;
}
ink.advance += g.adv_w;
}
return ink;
}
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);
return part;
}
// `live`: Home Assistant's values may show; without its link or the manager's feed only clocks stay.
inline void draw_header(bool live) {
if (!room_label || !time_label || !header_text_font || !header_icon_font) return;
set_visible(time_label, false);
auto *page = lv_obj_get_parent(room_label);
if (!header_root) {
header_root = lv_obj_create(page);
lv_obj_remove_style_all(header_root);
lv_obj_remove_flag(header_root, LV_OBJ_FLAG_CLICKABLE);
lv_obj_remove_flag(header_root, LV_OBJ_FLAG_SCROLLABLE);
// The clock label's place in the drawing order: under the tiles, cards and overlays.
lv_obj_move_to_index(header_root, lv_obj_get_index(time_label));
for (auto &slot : header_slots) {
slot.icon = header_part(header_root);
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_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_flag(hand, LV_OBJ_FLAG_HIDDEN);
}
// A long name ends in dots instead of running under the items.
lv_label_set_long_mode(room_label, LV_LABEL_LONG_DOT);
}
// Geometry from the profile's own widgets: the name's margin and baseline, the clock's right margin.
const lv_font_t *name_font = lv_obj_get_style_text_font(room_label, LV_PART_MAIN);
int page_w = lv_obj_get_width(page), left = lv_obj_get_x(room_label);
// int32_t is long on the ESP32 toolchain: keep the arithmetic in int.
int width = std::max(0, page_w + static_cast<int>(lv_obj_get_style_x(time_label, LV_PART_MAIN)) - left);
int baseline = lv_obj_get_y(room_label) + (name_font->line_height - name_font->base_line);
lv_obj_set_pos(header_root, 0, 0);
lv_obj_set_size(header_root, page_w, baseline + name_font->line_height);
lv_font_glyph_dsc_t zero, dial_glyph;
if (!lv_font_get_glyph_dsc(header_text_font, &zero, '0', 0) || !zero.box_h) return;
// Twice the digits' ink centre keeps the halves exact.
int middle2 = 2 * (baseline - zero.ofs_y) - zero.box_h;
auto gaps = header_bar::gaps(zero.box_h);
// The dial is as large as a round icon (clock-outline) of the icon font.
int dial = lv_font_get_glyph_dsc(header_icon_font, &dial_glyph, 0xF0150, 0) && dial_glyph.box_h ? dial_glyph.box_h : zero.box_h * 3 / 2;
// The manager's items; until it sends them, the clock of show_clock as before the top bar.
header_bar::Bar fallback;
if (!header.received && screen_settings::current.show_clock) { fallback.items[0].kind = header_bar::Kind::clock; fallback.count = 1; }
const header_bar::Bar &bar = header.received ? header : fallback;
auto now = now_time ? now_time() : esphome::ESPTime{};
struct Part { size_t item = 0; uint32_t icon = 0; int icon_left = 0, icon_w = 0, text_left = 0, text_w = 0, width = 0; bool dial = false; std::string text; };
std::array<Part, header_bar::MAX_ITEMS> parts;
std::array<int, header_bar::MAX_ITEMS> widths{};
size_t count = 0;
for (size_t i = 0; i < bar.count; ++i) {
const auto &item = bar.items[i];
using header_bar::Kind;
if ((item.kind == Kind::text || item.kind == Kind::ago) && !live) continue;
Part p;
p.item = i;
if (item.kind == Kind::analog) { p.dial = true; p.width = dial; }
else {
p.text = item.kind == Kind::clock ? (now.is_valid() ? now.strftime(screen_settings::current.clock_24h ? "%H:%M" : "%I:%M") : std::string("--:--"))
: item.kind == Kind::date ? (now.is_valid() ? header_bar::date_text(now.day_of_week, now.day_of_month, now.month) : std::string("—"))
: item.kind == Kind::ago ? header_bar::ago_text(item.epoch, now_epoch()) : item.text;
lv_font_glyph_dsc_t g;
if (item.icon && lv_font_get_glyph_dsc(header_icon_font, &g, item.icon, 0) && g.box_w) { p.icon = item.icon; p.icon_left = g.ofs_x; p.icon_w = g.box_w; }
auto ink = text_ink(header_text_font, p.text);
p.text_left = ink.left;
p.text_w = std::max(0, ink.right - ink.left);
p.width = p.icon_w + (p.icon && p.text_w ? gaps.icon : 0) + p.text_w;
}
if (!p.width) continue;
widths[count] = p.width;
parts[count++] = p;
}
lv_point_t name_size;
lv_text_get_size(&name_size, lv_label_get_text(room_label), name_font, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_EXPAND);
auto placement = header_bar::place(widths.data(), count, gaps, width, name_size.x);
lv_obj_set_width(room_label, std::max(1, std::min<int>(name_size.x, placement.name_room)));
lv_obj_set_height(room_label, lv_font_get_line_height(name_font));
std::array<bool, header_bar::MAX_ITEMS> icon_on{}, text_on{};
bool dial_on = false;
for (size_t k = placement.first; k < count; ++k) {
const auto &p = parts[k];
auto &slot = header_slots[k];
int x = left + placement.x[k];
if (p.dial) {
int top = (middle2 - dial) / 2, stroke = std::max(1, (dial + 5) / 10), hand = std::max(1, (dial * 75 + 500) / 1000);
lv_obj_set_pos(header_ring, x, top);
lv_obj_set_size(header_ring, dial, dial);
if (lv_obj_get_style_border_width(header_ring, LV_PART_MAIN) != stroke) lv_obj_set_style_border_width(header_ring, stroke, 0);
int minute = now.is_valid() ? now.hour * 60 + now.minute : 0;
int key = ((minute * 1024 + x) * 1024 + top) * 64 + dial;
if (key != header_dial_key) {
header_dial_key = key;
float centre = dial / 2.0f, hour_angle = (minute % 720) * 3.14159265f / 360, minute_angle = (minute % 60) * 3.14159265f / 30;
header_points[0] = header_points[2] = {(lv_value_precise_t)centre, (lv_value_precise_t)centre};
header_points[1] = {(lv_value_precise_t)(centre + 0.24f * dial * sinf(hour_angle)), (lv_value_precise_t)(centre - 0.24f * dial * cosf(hour_angle))};
header_points[3] = {(lv_value_precise_t)(centre + 0.34f * dial * sinf(minute_angle)), (lv_value_precise_t)(centre - 0.34f * dial * cosf(minute_angle))};
for (size_t h = 0; h < header_hands.size(); ++h) {
lv_line_set_points(header_hands[h], header_points + 2 * h, 2);
lv_obj_set_pos(header_hands[h], x, top);
set_line_width(header_hands[h], hand);
}
}
dial_on = true;
continue;
}
if (p.icon) {
lv_font_glyph_dsc_t g;
lv_font_get_glyph_dsc(header_icon_font, &g, p.icon, 0);
label(slot.icon, tile_icon::utf8(p.icon));
// LVGL draws a glyph's ink from (line_height - base_line) - box_h - ofs_y below the label top.
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; }
icon_on[k] = true;
x += p.icon_w + (p.text_w ? gaps.icon : 0);
}
if (p.text_w) {
label(slot.text, p.text);
lv_obj_set_pos(slot.text, x - p.text_left, baseline - (header_text_font->line_height - header_text_font->base_line));
text_on[k] = true;
}
}
for (size_t k = 0; k < header_slots.size(); ++k) { set_visible(header_slots[k].icon, icon_on[k]); set_visible(header_slots[k].text, text_on[k]); }
set_visible(header_ring, dial_on);
for (auto *hand : header_hands) set_visible(hand, dial_on);
}
inline void render_header() {
if (enabled) draw_header(ha_connected() && feed_alive());
}
// Inspect actual LVGL coordinates, including padding and the loaded font metrics.
inline bool check_tile_geometry() {
bool ok=true;
if(fill_next<SLOTS_PER_PAGE){ESP_LOGW("ui_test","page fill still under way at the check");fill_cards(SLOTS_PER_PAGE);}
for(auto &w:widgets){
if(!w.tile || lv_obj_has_flag(w.tile,LV_OBJ_FLAG_HIDDEN))continue;
lv_obj_update_layout(w.tile);
lv_area_t title,value,track,content;
lv_obj_get_content_coords(w.tile,&content);
lv_obj_get_coords(w.title,&title);lv_obj_get_coords(w.value,&value);
bool custom=lv_obj_has_flag(w.title,LV_OBJ_FLAG_HIDDEN);
bool fits=true;
if(w.wide && widgets[1].tile){
// A wide card ends exactly where the right column ends.
lv_area_t left,right;lv_obj_get_coords(w.tile,&left);lv_obj_get_coords(widgets[1].tile,&right);
int expected=lv_obj_get_x(widgets[1].tile)-lv_obj_get_x(widgets[0].tile)+w.base_width;
fits=lv_obj_get_width(w.tile)==expected;
if(!fits)ESP_LOGE("ui_test","Wide width FAIL slot=%u width=%d expected=%d",(unsigned)w.index,lv_obj_get_width(w.tile),expected);
}
if(!custom){
fits=fits && title.x1>=content.x1 && title.x2<=content.x2 &&
value.x1>=content.x1 && value.x2<=content.x2 && title.y2<value.y1 && value.y2<=content.y2;
if(!lv_obj_has_flag(w.slider,LV_OBJ_FLAG_HIDDEN)){
lv_obj_get_coords(w.slider,&track);
fits=fits && value.y2<track.y1 && track.y2<=content.y2;
}
if(!lv_obj_has_flag(w.circle,LV_OBJ_FLAG_HIDDEN)){
lv_area_t circle;lv_obj_get_coords(w.circle,&circle);
bool mini=!lv_obj_has_flag(w.slider,LV_OBJ_FLAG_HIDDEN);
fits=fits && circle.x1>=content.x1 && circle.x2<title.x1 && circle.y1>=content.y1;
if(mini)fits=fits && circle.y2<track.y1;
else fits=fits && circle.y2<=content.y2;
if(!fits)ESP_LOGE("ui_test","Icon bounds slot=%u circle=%d,%d..%d,%d title_x=%d content=%d,%d..%d,%d",(unsigned)w.index,circle.x1,circle.y1,circle.x2,circle.y2,title.x1,content.x1,content.y1,content.x2,content.y2);
bool watch=w.index<model.count && model.tiles[w.index].display=="watch";
bool graph=w.extra && !lv_obj_has_flag(w.extra,LV_OBJ_FLAG_HIDDEN) && w.extra_mode=="graph";
if(!watch && !graph && (mini || lv_obj_get_height(w.tile)<=80)){
int header_bottom=mini?track.y1-(lv_obj_get_height(w.tile)>80?6:3)-1:content.y2;
int center_twice=content.y1+header_bottom;
fits=fits && std::abs(circle.y1+circle.y2-center_twice)<=2 &&
std::abs(title.y1+value.y2-center_twice)<=2;
}
}
if(!lv_obj_has_flag(w.unit,LV_OBJ_FLAG_HIDDEN)){
lv_area_t unit;lv_obj_get_coords(w.unit,&unit);
fits=fits && value.x2<unit.x1 && unit.x2<=content.x2 && unit.y2<=content.y2 && unit.y1>title.y2;
}
}
if(w.panel && !lv_obj_has_flag(w.panel,LV_OBJ_FLAG_HIDDEN)){
// Direct controls stay inside the card, right of the name and status, and inside their panel.
lv_area_t panel;lv_obj_get_coords(w.panel,&panel);
bool inside=panel.x1>=content.x1 && panel.x2<=content.x2 && panel.y1>=content.y1 && panel.y2<=content.y2 && (custom || (title.x2<panel.x1 && value.x2<panel.x1));
for(uint32_t i=0;i<lv_obj_get_child_count(w.panel);++i){
auto *child=lv_obj_get_child(w.panel,i);if(lv_obj_has_flag(child,LV_OBJ_FLAG_HIDDEN))continue;
lv_area_t part;lv_obj_get_coords(child,&part);
inside=inside && part.x1>=panel.x1 && part.x2<=panel.x2 && part.y1>=panel.y1 && part.y2<=panel.y2;
}
if(!inside)ESP_LOGE("ui_test","Panel bounds slot=%u mode=%s panel=%d,%d..%d,%d title_x2=%d content=%d,%d..%d,%d",(unsigned)w.index,w.panel_mode.c_str(),panel.x1,panel.y1,panel.x2,panel.y2,title.x2,content.x1,content.y1,content.x2,content.y2);
fits=fits && inside;
}
if(w.extra && !lv_obj_has_flag(w.extra,LV_OBJ_FLAG_HIDDEN)){
// Custom parts stay inside the card; a graph never runs into the text.
lv_area_t extra;lv_obj_get_coords(w.extra,&extra);
fits=fits && extra.x1>=content.x1 && extra.x2<=content.x2 && extra.y1>=content.y1 && extra.y2<=content.y2;
for(auto *p:w.parts){
if(!p || lv_obj_has_flag(p,LV_OBJ_FLAG_HIDDEN))continue;
lv_area_t part;lv_obj_get_coords(p,&part);
bool inside=part.x1>=content.x1 && part.x2<=content.x2 && part.y1>=content.y1 && part.y2<=content.y2;
if(!inside)ESP_LOGE("ui_test","Part bounds slot=%u mode=%s part=%d,%d..%d,%d content=%d,%d..%d,%d",(unsigned)w.index,w.extra_mode.c_str(),part.x1,part.y1,part.x2,part.y2,content.x1,content.y1,content.x2,content.y2);
fits=fits && inside;
if(w.extra_mode=="graph" && !custom)fits=fits && (w.wide?part.x1>value.x2:part.y1>value.y2);
}
}
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));
if(!palette_ok)ESP_LOGE("ui_test","Tile palette FAIL slot=%u",(unsigned)w.index);
fits=fits && palette_ok;
}
if(w.index<model.count){
bool bare=model.tiles[w.index].transparent;
bool opa_ok=(lv_obj_get_style_bg_opa(w.tile,LV_PART_MAIN)==LV_OPA_TRANSP)==bare && (lv_obj_get_style_border_opa(w.tile,LV_PART_MAIN)==LV_OPA_TRANSP)==bare;
if(!opa_ok)ESP_LOGE("ui_test","Tile background FAIL slot=%u transparent=%d",(unsigned)w.index,bare);
fits=fits && opa_ok;
}
ok=ok && fits;
}
return ok;
}
inline unsigned page_count() {
std::array<Placement,MAX_TILES> placement;
return place(model,placement);
}
// Page switches feel immediate without blocking touch: the swipe pass places the new page (page
// number, card widths), draws its first two cards and gives the others a light skeleton frame, all
// in the very next frame. Every following LVGL refresh draws the next two cards, so the loop, and
// the touch polling in it, runs between the steps, and a new swipe drops a fill still under way.
// Keepalives and re-packing on the same page draw at once. Nothing is allocated.
inline lv_obj_t *nav_prev=nullptr,*nav_next=nullptr,*nav_number=nullptr;
inline int applied_page=-1;
// Slot assignment plus card widths and visibility for a page; contents are untouched.
inline int place_page(int page) {
swipe_profile::Lap lap;
std::array<Placement,MAX_TILES> placement;
int pages=place(model,placement);
page=std::clamp(page,0,pages-1);
int wide_width=widgets[0].tile && widgets[1].tile ? lv_obj_get_x(widgets[1].tile)-lv_obj_get_x(widgets[0].tile)+widgets[0].base_width : 2*widgets[0].base_width;
for(size_t slot=0;slot<widgets.size();++slot){widgets[slot].index=MAX_TILES;widgets[slot].wide=false;widgets[slot].cached_active=-1;}
for(size_t i=0;i<model.count;++i)if(placement[i].page==page){auto &w=widgets[placement[i].slot];w.index=i;w.wide=model.tiles[i].wide;}
for(size_t slot=0;slot<widgets.size();++slot){
auto &w=widgets[slot];if(!w.tile)continue;
if(slot<SLOTS_PER_PAGE && w.index<model.count){lv_obj_set_width(w.tile,w.wide?wide_width:w.base_width);lv_obj_remove_flag(w.tile,LV_OBJ_FLAG_HIDDEN);}
else{lv_obj_add_flag(w.tile,LV_OBJ_FLAG_HIDDEN);hide_extra(w);hide_panel(w);}
}
for(auto *control:{nav_prev,nav_next,nav_number})set_hidden(control,pages<=1);
if(page==0)lv_obj_add_state(nav_prev,LV_STATE_DISABLED);else lv_obj_remove_state(nav_prev,LV_STATE_DISABLED);
if(page==pages-1)lv_obj_add_state(nav_next,LV_STATE_DISABLED);else lv_obj_remove_state(nav_next,LV_STATE_DISABLED);
for(auto *control:{nav_prev,nav_next})if(lv_obj_get_child_count(control))
set_number(lv_obj_get_child(control,0),LV_STYLE_TEXT_OPA,lv_obj_has_state(control,LV_STATE_DISABLED)?LV_OPA_30:LV_OPA_COVER);
label(nav_number,std::to_string(page+1)+" / "+std::to_string(pages));
lap(swipe_profile::PLACE);
return page;
}
// The skeleton frame is the empty card: its contents hidden under a sheet in the card's own colour
// (the page colour for a card without a background) that covers the content area. The sheet is a
// plain rectangle inside the card's padding, so it has no corners, border or layer to render, and
// while it is up LVGL draws nothing under it. Drawing the card takes the sheet away again.
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);
}
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);
if(!w.veil){
w.veil=lv_obj_create(w.tile);lv_obj_remove_style_all(w.veil);
lv_obj_remove_flag(w.veil,LV_OBJ_FLAG_CLICKABLE);lv_obj_remove_flag(w.veil,LV_OBJ_FLAG_SCROLLABLE);
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));
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);
if(lv_obj_get_index(w.veil)!=(int32_t)lv_obj_get_child_count(w.tile)-1)lv_obj_move_foreground(w.veil);
}
inline void drop_veil(Widgets &w) {
if(w.veil && !lv_obj_has_flag(w.veil,LV_OBJ_FLAG_HIDDEN))lv_obj_add_flag(w.veil,LV_OBJ_FLAG_HIDDEN);
}
// Draws up to `cards` waiting cards in slot order; true once the page is complete.
inline bool fill_cards(size_t cards) {
for(size_t drawn=0;fill_next<SLOTS_PER_PAGE && drawn<cards;++fill_next){
if(!slot_pending[fill_next])continue;
slot_pending[fill_next]=false;render_slot(fill_next);drop_veil(widgets[fill_next]);++drawn;
}
while(fill_next<SLOTS_PER_PAGE && !slot_pending[fill_next])++fill_next;
fill_step_ms=esphome::millis();fill_refreshed=false;
if(fill_next<SLOTS_PER_PAGE)return false;
if(fill_timer)lv_timer_pause(fill_timer);
swipe_profile::content_complete();
return true;
}
inline void cancel_fill() {
fill_next=SLOTS_PER_PAGE;slot_pending.fill(false);
if(fill_timer)lv_timer_pause(fill_timer);
}
inline void fill_timer_done(lv_timer_t *) {
// One step per refresh, so a step's frame reaches the glass before the next step is drawn. A step
// that changed no pixel starts no refresh; the fill then moves on after a short wait.
if(fill_next>=SLOTS_PER_PAGE || (!fill_refreshed && esphome::millis()-fill_step_ms<40))return;
swipe_profile::FillTimer timer;
fill_cards(FILL_STEP_CARDS);
}
inline void apply_page(int page) {
cancel_fill();
applied_page=place_page(page);
for(auto &w:widgets)drop_veil(w);
if(room_label){dirty_all=true;render(room_label);}else refresh_all();
}
inline int *shown_page=nullptr;
inline void show_page(int &page, lv_obj_t *previous, lv_obj_t *next, lv_obj_t *number) {
nav_prev=previous;nav_next=next;nav_number=number;shown_page=&page;
int requested=page;
page=std::clamp(page,0,int(page_count())-1);
// A swipe past the first or last page: the page on screen is already right, so nothing is
// drawn again. A layout change always lands in range or on a different page.
if(requested!=page && page==applied_page)return;
if(applied_page<0 || page==applied_page || !room_label){apply_page(page);return;}
swipe_profile::begin(applied_page,page);
swipe_profile::SkeletonTimer timer;
cancel_fill();
applied_page=place_page(page);
swipe_profile::Lap lap;
for(size_t slot=0;slot<SLOTS_PER_PAGE;++slot){
auto &w=widgets[slot];
slot_pending[slot]=w.tile && w.index<model.count;
if(slot_pending[slot])skeleton(w);
}
for(size_t slot=SLOTS_PER_PAGE;slot<widgets.size();++slot)drop_veil(widgets[slot]);
lap(swipe_profile::SKELETON);
// The skeleton frame goes out first; the fill starts on the refresh after it.
fill_next=0;fill_step_ms=esphome::millis();fill_refreshed=false;
if(!fill_timer){
fill_timer=lv_timer_create(fill_timer_done,1,nullptr);
if(auto *display=lv_display_get_default())
lv_display_add_event_cb(display,[](lv_event_t *){fill_refreshed=true;},LV_EVENT_REFR_READY,nullptr);
}
lv_timer_resume(fill_timer);
}
#ifdef SWIPE_PROFILE
inline unsigned swipe_test_left=0, swipe_test_back=0;
inline bool swipe_test_returning=false;
inline lv_timer_t *swipe_test_timer=nullptr;
inline void swipe_test_step(lv_timer_t *timer) {
if(!shown_page || !swipe_test_left || page_count()<2){lv_timer_pause(timer);swipe_test_left=0;ESP_LOGI("swipe_prof","swipe test finished");return;}
int pages=page_count(),from=*shown_page;
*shown_page=swipe_test_returning?from-1:(from+1<pages?from+1:0);
if(*shown_page<0)*shown_page=pages-1;
show_page(*shown_page,nav_prev,nav_next,nav_number);
if(swipe_test_back && !swipe_test_returning){swipe_test_returning=true;lv_timer_set_period(timer,swipe_test_back);return;}
swipe_test_returning=false;--swipe_test_left;
lv_timer_set_period(timer,lv_timer_get_user_data(timer)?(uint32_t)(uintptr_t)lv_timer_get_user_data(timer):1200);
}
inline void swipe_test(unsigned count, unsigned interval_ms, unsigned back_ms, JsonObject tuning) {
// Cards per fill step for the measurement.
if(tuning["cards"].is<unsigned>())FILL_STEP_CARDS=std::clamp<unsigned>(tuning["cards"].as<unsigned>(),1,6);
ESP_LOGI("swipe_prof","tuning: %u cards per step",(unsigned)FILL_STEP_CARDS);
swipe_test_left=std::min(count,200u);swipe_test_back=back_ms;swipe_test_returning=false;
interval_ms=std::clamp(interval_ms,200u,10000u);
if(!swipe_test_timer)swipe_test_timer=lv_timer_create(swipe_test_step,interval_ms,(void*)(uintptr_t)interval_ms);
lv_timer_set_user_data(swipe_test_timer,(void*)(uintptr_t)interval_ms);
lv_timer_set_period(swipe_test_timer,interval_ms);lv_timer_reset(swipe_test_timer);lv_timer_resume(swipe_test_timer);
ESP_LOGI("swipe_prof","swipe test: %u page switches every %u ms, back after %u ms",swipe_test_left,interval_ms,back_ms);
}
#endif
inline uint32_t last_live_second=0;
inline int last_clock_minute=-2;
inline bool was_fresh=false;
inline void tick() {
if(detail_root && !lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN) && detail_index<model.count){
auto &t=model.tiles[detail_index];bool waiting=t.awaiting_action(esphome::millis());
for(unsigned i=0;i<detail_action_count;++i){if(waiting||!fresh()||!t.available())lv_obj_add_state(detail_actions[i],LV_STATE_DISABLED);else lv_obj_remove_state(detail_actions[i],LV_STATE_DISABLED);}
std::string status=waiting?(t.confirmed?"Confirmed by Home Assistant":"Command sent..."):detail_state(t);
if(detail_status)label(detail_status,status+(!waiting && !t.unit.empty()?" "+t.unit:""));
if(detail_badge_status)label(detail_badge_status,status);
if(detail_switch && !lv_obj_has_state(detail_switch,LV_STATE_PRESSED)){
if(t.state=="on")lv_obj_add_state(detail_switch,LV_STATE_CHECKED);
else lv_obj_remove_state(detail_switch,LV_STATE_CHECKED);
}
}
if(!enabled)return;
// Only the cards that change are drawn again: a clock or a finished command redraws its own card.
bool redraw=false;
auto card=[&](size_t index){ if(index<32)dirty_tiles|=1u<<index; else dirty_all=true; redraw=true; };
// HA dropping or returning and the feed timing out change every card at once.
bool now_fresh=fresh();
if(now_fresh!=was_fresh){was_fresh=now_fresh;dirty_all=true;redraw=true;}
for(size_t i=0;i<model.tiles.size();++i){auto &t=model.tiles[i];if(t.pending && !t.loading(esphome::millis())){t.pending=false;card(i);}}
// A -/+ edit goes out as one call once the finger rests; a value HA never reports is dropped after a while.
for(size_t i=0;i<model.count;++i){
auto &t=model.tiles[i];if(!std::isfinite(t.edit_value))continue;
uint32_t now=esphome::millis();
if(!t.edit_sent){
if(now-t.edit_since<700 || t.awaiting_action(now))continue;
auto a=tile_controls::edit_action(t,t.edit_value);
if(a.valid()){t.edit_sent=true;t.edit_since=now;action(a.service,t.entity,a.key,a.value);}else t.edit_value=NAN;
}else if(now-t.edit_since>10000){t.edit_value=NAN;card(i);}
}
// Running timers advance once per second without any HA traffic; clocks show hours and minutes,
// so they are drawn again only when the minute (or the time's validity) changes.
uint32_t second=esphome::millis()/1000;
if(second!=last_live_second){
last_live_second=second;
auto now=now_time?now_time():esphome::ESPTime{};
int minute=now.is_valid()?now.day_of_year*1440+now.hour*60+now.minute:-1;
bool new_minute=minute!=last_clock_minute;last_clock_minute=minute;
for(size_t slot=0;slot<SLOTS_PER_PAGE;++slot){
auto &w=widgets[slot];if(!w.tile || w.index>=model.count || lv_obj_has_flag(w.tile,LV_OBJ_FLAG_HIDDEN))continue;
const auto &t=model.tiles[w.index];
if((t.builtin() && new_minute) || (t.domain()=="timer" && t.state=="active") || (t.domain()=="sun" && second%60==0))card(w.index);
}
}
if(redraw && refresh)refresh();
}
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];
return index < tile.fan_speed_count ? tile.fan_speeds[index] : "";
}
}