Free drag & drop with fixed positions, empty cells and kept pages (0.2.31)

Tiles get an absolute grid slot instead of a packed order: a tile lands exactly
where it is dropped, dropping on another tile swaps the two, everything else
stays put, and empty cells and empty pages persist. The mockup previews the
result while dragging, a new page waits below the last one, "Pagina toevoegen"
keeps an empty page, empty cells accept the next tile from the picker, and the
mouse no longer selects text while dragging.

Storage and protocol stay version 1: `tiles[].slot` (0-47) and `pages` (1-8)
are additive, the layout message carries `slots` and `pages`. Firmware 0.2.26
places tiles on those slots (`place()` next to `pack()`); older firmware packs
in order as before and the editor says so.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-14 13:00:39 +02:00
parent ad0385d60f
commit 95cdfa30cf
19 changed files with 676 additions and 140 deletions
+46
View File
@@ -1,4 +1,5 @@
#pragma once
#include <algorithm>
#include <array>
#include <string>
#include <vector>
@@ -8,6 +9,9 @@
namespace runtime_tiles {
constexpr size_t MAX_TILES = 20;
constexpr size_t SLOTS_PER_PAGE = 6;
// Explicit grid positions (0.2.26+) address at most eight pages of six slots.
constexpr size_t MAX_PAGES = 8;
constexpr size_t MAX_SLOTS = MAX_PAGES * SLOTS_PER_PAGE;
inline bool valid_entity(const std::string &entity) {
if (entity.size() > 120) return false;
auto dot = entity.find('.');
@@ -95,19 +99,46 @@ inline unsigned pack(const std::array<Tile, MAX_TILES> &tiles, size_t count, std
unsigned pages = (position + SLOTS_PER_PAGE - 1) / SLOTS_PER_PAGE;
return pages ? pages : 1;
}
// Grid position per tile: the explicit slots when the manager sent them (a wide
// card always starts in the left column), else the in-order packing. Returns the
// page count (at least one); an empty page between two used ones stays a page.
struct Model;
inline unsigned place(const Model &m, std::array<Placement, MAX_TILES> &out);
struct Model {
std::array<Tile, MAX_TILES> tiles;
// Absolute grid slot per tile when the manager sent `slots` (0.2.26+): gaps stay
// empty and a tile keeps its place. Without them the tiles pack in order.
std::array<uint8_t, MAX_TILES> slots{};
bool explicit_slots = false;
// Pages the manager wants shown even when the last ones are still empty (0.2.26+).
uint8_t pages = 1;
size_t count = 0;
std::string title = "Kies tegels in HA";
bool configured = false;
bool set_layout(const std::vector<std::string> &entities, const std::string &name, bool &changed) {
bool moved = false;
return set_layout(entities, name, changed, {}, moved);
}
// `changed`: the tiles differ, states restart. `moved`: same tiles on other
// positions, the pages re-place without touching states or an open card.
bool set_layout(const std::vector<std::string> &entities, const std::string &name, bool &changed,
const std::vector<uint8_t> &positions, bool &moved) {
if (entities.size() > MAX_TILES || name.size() > 96) return false;
for (size_t i = 0; i < entities.size(); ++i) {
if (!valid_entity(entities[i])) return false;
for (size_t j = 0; j < i; ++j) if (entities[i] == entities[j]) return false;
}
if (!positions.empty()) {
if (positions.size() != entities.size()) return false;
for (size_t i = 0; i < positions.size(); ++i) {
if (positions[i] >= MAX_SLOTS) return false;
for (size_t j = 0; j < i; ++j) if (positions[i] == positions[j]) return false;
}
}
changed = !configured || count != entities.size();
for (size_t i = 0; i < entities.size(); ++i) if (tiles[i].entity != entities[i]) changed = true;
moved = !changed && (explicit_slots != !positions.empty());
for (size_t i = 0; i < positions.size() && !changed; ++i) if (slots[i] != positions[i]) moved = true;
// A title-only update must not interrupt an open control card.
title = name.empty() ? "Thuis" : name;
if (changed) {
@@ -115,6 +146,9 @@ struct Model {
count = entities.size();
for (size_t i = 0; i < count; ++i) tiles[i].entity = entities[i];
}
explicit_slots = !positions.empty();
slots.fill(0);
for (size_t i = 0; i < positions.size(); ++i) slots[i] = positions[i];
configured = true;
return true;
}
@@ -127,4 +161,16 @@ struct Model {
return configured && index < count && tiles[index].entity == entity;
}
};
inline unsigned place(const Model &m, std::array<Placement, MAX_TILES> &out) {
if (!m.explicit_slots) return pack(m.tiles, m.count, out);
unsigned last = 0;
for (size_t i = 0; i < m.count && i < MAX_TILES; ++i) {
unsigned slot = m.slots[i];
if (m.tiles[i].wide) slot &= ~1u;
out[i] = {static_cast<uint8_t>(slot / SLOTS_PER_PAGE), static_cast<uint8_t>(slot % SLOTS_PER_PAGE)};
last = std::max(last, slot + (m.tiles[i].wide ? 2u : 1u));
}
unsigned pages = (last + SLOTS_PER_PAGE - 1) / SLOTS_PER_PAGE;
return std::max({pages, 1u, std::min<unsigned>(m.pages, MAX_PAGES)});
}
} // namespace runtime_tiles
+16 -4
View File
@@ -160,9 +160,20 @@ inline std::string receive(const std::string &payload) {
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;
if (!model.set_layout(entities, string(root["title"], 96), changed)) return false;
bool changed = false, moved = false;
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);
}
@@ -177,6 +188,7 @@ inline std::string receive(const std::string &payload) {
}
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", "tegels verplaatst: pagina's opnieuw ingedeeld");
if (root["keepalive"].is<unsigned>()) keepalive_seconds = root["keepalive"].as<unsigned>();
last_received = esphome::millis();
if (layout_changed) layout_changed();
@@ -1506,7 +1518,7 @@ inline bool check_tile_geometry() {
inline unsigned page_count() {
std::array<Placement,MAX_TILES> placement;
return pack(model.tiles,model.count,placement);
return place(model,placement);
}
// Page switches feel immediate: the new page's card frames (right widths, no
// contents, light skeleton style) appear in the very next frame, and a one-shot
@@ -1518,7 +1530,7 @@ inline lv_timer_t *page_timer=nullptr;
// Slot assignment plus card widths and visibility for a page; contents are untouched.
inline int place_page(int page) {
std::array<Placement,MAX_TILES> placement;
int pages=pack(model.tiles,model.count,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;}