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;}
+15 -5
View File
@@ -106,9 +106,17 @@ wifi-slaapvertraging op Home Assistant.
Selecteer je scherm, vul de titel in en zoek entiteiten. De kiezer heeft
domeinfilters met gekleurde iconen. Je kunt maximaal twintig tegels toevoegen.
Het schermvoorbeeld toont hun plaats: twee kolommen, zes tegels per pagina.
Sleep een tegel naar een andere tegel om te ordenen. Klik op een tegel voor
een eigen naam, de pijltjes voor volgorde en **Bediening & weergave instellen**:
Het schermvoorbeeld toont hun plaats: twee kolommen, zes plekken per pagina,
maximaal acht pagina's. Elke tegel heeft een vaste plek die alleen verandert als
jij hem versleept; lege plekken blijven leeg, waar je ze ook laat. Sleep een tegel
op een lege plek en hij staat daar; sleep hem op een andere tegel en die twee
wisselen (de ander neemt de vrijgekomen plek, of anders de dichtstbijzijnde vrije
plek); de rest blijft staan. Tijdens het slepen zie je in het voorbeeld al waar
alles komt; onder de laatste pagina staat dan een lege pagina klaar. **Pagina
toevoegen** maakt een lege pagina die bewaard blijft; een lege pagina heeft
**Pagina weghalen**. Klik op een lege plek om de volgende tegel uit de kiezer
daar te zetten. Met de pijltjestoetsen verplaats je een gefocuste tegel.
Klik op een tegel voor een eigen naam en **Bediening & weergave instellen**:
klikgedrag, een mini-slider, een grote waarde, een grafiek (sensoren), een
weersvoorspelling (weer) of de breedte **Dubbelbreed**. Een dubbelbrede tegel van
een klimaat, schakelaar, lamp, ventilator, stofzuiger, zonwering, mediaspeler,
@@ -122,8 +130,10 @@ zwenkstanden. De ingebouwde **Klok**
staat bovenaan de kiezer; zon, kookwekkers en personen vind je via de filters. Kies bij
**Pastel achtergrond** een eigen kleur met donkere tekst; **Standaard** herstelt
de normale weergave en **Geen** laat de kaart weg, zodat de inhoud even groot
direct op de schermachtergrond staat (firmware 0.2.16+). Hiervoor is firmware 0.2.10+ nodig. Een lege
tegel brengt je naar de zoekbalk; toevoegen vult de eerstvolgende vrije positie.
direct op de schermachtergrond staat (firmware 0.2.16+). Hiervoor is firmware 0.2.10+ nodig.
Toevoegen zonder gekozen plek vult de eerste vrije plek. Vaste plekken en lege
plekken werken op het scherm vanaf firmware 0.2.26; oudere firmware schuift de
tegels aan tot de eerste vrije plek, de editor meldt dat onder het voorbeeld.
Het voorbeeld toont de indeling, geen live sensorwaarden.
Klik **Opslaan & naar scherm** om je wijzigingen door te sturen.
+22
View File
@@ -177,6 +177,28 @@ staan iconen in de browser uit het midden); draai daarna `generate_packages.py`.
De substituties `MDI_GLYPH_*` zijn vervallen: `TILEn_ICON` in handmatige
profielen moet uit de set komen. Geen gewijzigde preferences of sleutels.
### Compatibiliteit 0.2.31 / firmware 0.2.26
Opslagversie en tegelprotocol blijven 1. `tiles[].slot` is additief: de absolute plek
van een tegel (pagina × 6 + rij × 2 + kolom, 0–47, `MAX_SLOTS` in `core.py`); een
dubbelbrede tegel staat op een even plek en bedekt ook de plek rechts ervan. Lege plekken
zijn toegestaan. `validate_layout` eist plekken voor alle tegels of voor geen: zonder
plekken (oude editor, oude opslag) krijgt de indeling de plekken van de oude
volgorde-packing (`pack_slots`, hetzelfde als `pack()` in de firmware), zodat er op
het scherm niets verandert; met plekken worden overlap, oneven dubbelbreed en bereik
geweigerd en de tegels op plek gesorteerd. `save()` herhaalt de packing na het
terugzetten van opgeslagen opties (die kunnen een tegel verbreden). `pages` (1–8) is
een optioneel, additief veld: pagina's die de gebruiker leeg wil houden.
Op de draad krijgt het layoutbericht `slots` (één plek per entity, in dezelfde
volgorde) en `pages`. Firmware 0.2.26 (`Model::set_layout` met posities, `place()`
naast het oude `pack()`, `MAX_PAGES` 8) tekent de tegels op die plekken; een wijziging
van alleen plekken (`moved`) herplaatst de pagina's zonder tegelstatussen te wissen.
Oudere firmware negeert beide velden en pakt de entities in volgorde; omdat de
entities op plek gesorteerd zijn is dat de oude weergave zonder gaten, daarom geen
`min_firmware`. De editor meldt het (`has_gaps`/`hasGaps`) bij firmware < 0.2.26. Geen
gewijzigde preferences of sleutels.
### Compatibiliteit 0.2.30 / firmware 0.2.25
Alleen de editor: het statusbolletje in de schermlijst is een eigen `span.dot`
+44
View File
@@ -0,0 +1,44 @@
# Testresultaten app 0.2.31 / firmware 0.2.26 (2026-09-14)
Vrij slepen in de editor met vaste plekken, lege plekken en lege pagina's;
firmware 0.2.26 tekent de tegels op die plekken.
## Wat veranderde
- `core.py`: `SLOTS_PER_PAGE`/`MAX_PAGES`/`MAX_SLOTS`, `pack_slots`, `has_gaps`;
`validate_layout` geeft elke tegel een `slot` (oude volgorde-packing zonder plekken,
anders validatie op bereik, even plek voor dubbelbreed en overlap, gesorteerd) en
accepteert `pages` (1–8). `server.py`: `slots` en `pages` in het layoutbericht;
`save()` pakt opnieuw na het terugzetten van opgeslagen opties en valideert nogmaals.
- `runtime_model.h`: `Model.slots`/`explicit_slots`/`pages`, `set_layout(..., positions,
moved)`, `place()`; `runtime_tiles.h`: parser leest `slots`/`pages`, `page_count()` en
`place_page()` gebruiken `place()`. Versie 0.2.26 in beide profielen en pakketten.
- `app.js`: grid-model (`arrange`/`commit`/`placeTile`, `nearestFree`, `firstFree`),
hover-preview tijdens slepen (`setTarget` rendert het resultaat, `slotAt` kiest de
cel op afstand), pointer capture vanaf pointerdown en op `documentElement` tijdens de
sleep (de mockup wordt herrenderd), lege cellen als knoppen (`insertAt`), pagina's
toevoegen/weghalen, pijltjestoetsen, hint voor firmware < 0.2.26. `style.css`:
`user-select: none` op editor en kiezer, `.preview-cell`, `.placeholder`, `.new-page`.
## Geautomatiseerd
- Python: 91 tests OK (nieuw `tests/test_positions.py`: packing zonder plekken, expliciete
plekken met gaten en sortering, geweigerde overlap/oneven/bereik/gemengd, `pages`,
`slots`/`pages` op de draad, oude opslag en oude editor). `generate_packages.py --check` groen.
- C++ (clang++ -std=c++17 -Wall -Wextra -Werror -I.): alle `tests/*.cpp` PASS;
`test_runtime_model` dekt validatie van posities, `moved` zonder statusverlies,
even plek voor dubbelbreed, lege tussenpagina, `pages`, terugval zonder plekken.
- ESPHome compile (bench-pakketten met lokale componenten): `easy-guition-device.yaml`
SUCCESS (RAM 31,9%, flash 18,3%), `easy-cyd-device.yaml` SUCCESS (RAM 30,9%, flash 76,1%).
- Lokale add-on (devserver met nep-HA, echte pointer-events in de browser): twee enkele
tegels, tweede dubbelbreed → blijft op rij twee, gat op plek 2; entiteit uit de kiezer
in het gat gesleept → plek 2, geen tekstselectie; hover-preview toont de nieuwe pagina
met placeholder; enkele tegel op dubbelbrede gedropt → wissel; pagina toevoegen/weghalen;
pijltjestoetsen 6 → 5 → 6 met focus; lege plek klikken + kiezer → tegel op die plek;
opslaan verstuurt `slots [0,2,3,6]` en state-indexen in plekvolgorde.
## Nog niet geverifieerd
- Op echte hardware: de indeling met gaten en een lege pagina op CYD en Guition na de
firmware-update (Max test via GitHub-release → add-on → firmware bouwen in ESP Screens).
- Touch-slepen (hold 260 ms) op een telefoon in de HA-app.
+1 -1
View File
@@ -1,7 +1,7 @@
# Guition ESP32-S3-4848S040 / 4-inch EU wallbox, 480x480 IPS + GT911.
# Use guition-device.yaml; see docs/GUITION.md. CYD hardware is a separate profile.
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.25"
SCREEN_FIRMWARE_VERSION: "0.2.26"
# A tap survives a finger that drifts up to this far (480 px over 72 mm: 1 cm); further is a swipe.
TOUCH_MOVE_LIMIT_PX: "67"
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
+1 -1
View File
@@ -58,7 +58,7 @@
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.25"
SCREEN_FIRMWARE_VERSION: "0.2.26"
# A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe.
TOUCH_MOVE_LIMIT_PX: "56"
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
+1 -1
View File
@@ -59,7 +59,7 @@
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.25"
SCREEN_FIRMWARE_VERSION: "0.2.26"
# A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe.
TOUCH_MOVE_LIMIT_PX: "56"
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
+1 -1
View File
@@ -2,7 +2,7 @@
# Guition ESP32-S3-4848S040 / 4-inch EU wallbox, 480x480 IPS + GT911.
# Use guition-device.yaml; see docs/GUITION.md. CYD hardware is a separate profile.
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.25"
SCREEN_FIRMWARE_VERSION: "0.2.26"
# A tap survives a finger that drifts up to this far (480 px over 72 mm: 1 cm); further is a swipe.
TOUCH_MOVE_LIMIT_PX: "67"
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
+9
View File
@@ -1,3 +1,12 @@
## 0.2.31 (firmware 0.2.26)
- **Vrij slepen, met lege plekken**: de editor werkt met vaste plekken in plaats van een volgorde die steeds wordt aangeschoven. Elke tegel heeft zijn eigen plek (kolom, rij, pagina) en die verandert alleen als jij hem versleept. Lege plekken zijn gewoon lege plekken: naast een enkele tegel, midden op een pagina, waar je wilt; ze blijven staan zodat je kunt ordenen. Sleep een tegel op een lege plek en hij staat daar. Sleep hem op een andere tegel en die twee wisselen: de ander neemt de plek die vrijkwam, of anders de dichtstbijzijnde vrije plek. Alle overige tegels blijven waar ze staan. Tijdens het slepen laat het voorbeeld al zien waar alles terechtkomt; loslaten bevestigt precies dat, loslaten buiten het voorbeeld verandert niets.
- **Naar een volgende pagina slepen**, ook als de vorige nog niet vol is: onder de laatste pagina staat tijdens het slepen een lege pagina klaar. Met **Pagina toevoegen** maak je zelf een lege pagina, die ook bewaard blijft; een lege pagina heeft **Pagina weghalen** (de pagina's erna schuiven op). Maximaal acht pagina's.
- **Lege plek aanklikken**: klik op een lege plek en de volgende tegel uit de kiezer komt daar ("Volgende tegel komt hier"); zonder keuze vult toevoegen de eerste vrije plek. Een tegel die dubbelbreed wordt houdt zijn rij als de plek ernaast vrij is en gaat anders naar de dichtstbijzijnde vrije rij; de buurtegel blijft staan. Dubbelbreed terug naar normaal laat de rechterplek leeg.
- **Geen tekstselectie meer tijdens het slepen**, en een sleep die snel begint start toch (de muis blijft aan de kaart gekoppeld tot de sleep loopt). Pijltjestoetsen verplaatsen een gefocuste tegel per plek; Enter opent de instellingen.
- Firmware 0.2.26 tekent de tegels op precies die plekken: lege plekken blijven leeg en een lege pagina blijft een pagina. Oudere firmware negeert de nieuwe velden en schuift de tegels aan tot de eerste vrije plek, zoals voorheen; de editor meldt dat onder het voorbeeld zolang zo'n scherm een indeling met gaten heeft. Bestaande indelingen krijgen bij het laden de plekken die ze al hadden, er verandert niets op het scherm.
- Opslag en protocol (voor ontwikkelaars): `tiles[].slot` (0–47, absolute plek: pagina × 6 + rij × 2 + kolom; dubbelbreed altijd op een even plek) en `pages` (1–8) zijn additief in opslagversie 1; het layoutbericht krijgt `slots` en `pages`. Firmware 0.2.26 voor beide borden; de app biedt de update aan.
## 0.2.30 (firmware 0.2.25)
- **Groen bolletje voor online schermen**: in de lijst met schermen is het bolletje voor **Online** nu groen, zodat je in één oogopslag ziet welke schermen bereikbaar zijn. **Offline** blijft een grijs open rondje.
+54 -1
View File
@@ -16,7 +16,7 @@ WEEKDAYS = ['ma', 'di', 'wo', 'do', 'vr', 'za', 'zo']
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
REFS = {'cyd': 'main', 'guition': 'main'}
# Firmware shipped with this app release; screens below it get an update offer.
FIRMWARE_VERSION = '0.2.25'
FIRMWARE_VERSION = '0.2.26'
ATTRS = frozenset('brightness percentage current_position current_temperature temperature current_humidity min_temp max_temp target_temp_step supported_color_modes hvac_modes hvac_action hs_color color_temp_kelvin min_color_temp_kelvin max_color_temp_kelvin fan_speed_list unit_of_measurement battery_level fan_speed volume_level is_volume_muted media_title options min max step temperature_unit supported_features device_class next_rising next_setting finishes_at duration remaining humidity wind_speed wind_speed_unit apparent_temperature fan_modes swing_modes fan_mode swing_mode'.split())
# Attributes whose boolean value the screen needs; every other bool stays behind.
BOOL_ATTRS = frozenset(['is_volume_muted'])
@@ -42,6 +42,36 @@ DISPLAYS = {'weather': ('standard', 'watch', 'forecast'), 'sensor': ('standard',
# Displays that only work on a double-width card.
WIDE_ONLY = ('forecast', 'sunpath')
# Grid positions: two columns, three rows per page, at most eight pages. A tile's
# `slot` is its absolute cell (page * 6 + row * 2 + column); a wide tile starts in
# the left column and also covers the cell to its right. Empty cells are allowed.
SLOTS_PER_PAGE = 6
MAX_PAGES = 8
MAX_SLOTS = MAX_PAGES * SLOTS_PER_PAGE
def is_wide(tile):
return tile.get('options', {}).get('size') == 'wide'
def footprint(slot, wide):
return (slot, slot + 1) if wide else (slot,)
def pack_slots(tiles):
"""In-order packing, the rule before explicit positions and what firmware without
`slots` still does: fill left to right, a wide card starts a new row."""
position, slots = 0, []
for tile in tiles:
wide = is_wide(tile)
if wide and position % 2:
position += 1
slots.append(position)
position += 2 if wide else 1
return slots
def has_gaps(tiles):
"""True when the stored positions differ from the in-order packing, so firmware
before 0.2.26 (which ignores `slots`) would show another arrangement."""
return [t.get('slot') for t in tiles] != pack_slots(tiles)
# Direct controls on the right half of a double-width card (firmware 0.2.19+), like
# Home Assistant's own entity rows. The first choice is what a wide card shows
# when the tile has no explicit choice; 'none' keeps the plain card.
@@ -182,7 +212,30 @@ def validate_layout(data):
raise ValueError('Deze entiteit ondersteunt die directe bediening niet.')
item['options'] = dict(options)
clean.append(item)
# Positions: every tile or none (an older editor sends none and keeps its order).
given = [tile.get('slot') for tile in tiles]
if any(slot is not None for slot in given):
occupied = set()
for item, slot in zip(clean, given):
if type(slot) is not int or not 0 <= slot < MAX_SLOTS:
raise ValueError('Ongeldige tegelpositie; vernieuw de beheerpagina.')
if is_wide(item) and slot % 2:
raise ValueError('Een dubbelbrede tegel begint in de linkerkolom.')
for cell in footprint(slot, is_wide(item)):
if cell in occupied:
raise ValueError('Twee tegels staan op dezelfde plek.')
occupied.add(cell)
item['slot'] = slot
clean.sort(key=lambda item: item['slot'])
else:
for item, slot in zip(clean, pack_slots(clean)):
item['slot'] = slot
result = {'title': title.strip(), 'tiles': clean}
# Pages kept on purpose, empty ones included; the screen shows at least what the tiles need.
if 'pages' in data:
if type(data['pages']) is not int or not 1 <= data['pages'] <= MAX_PAGES:
raise ValueError(f'Een scherm heeft 1 tot {MAX_PAGES} pagina\'s.')
result['pages'] = data['pages']
if 'settings' in data:
result['settings'] = validate_settings(data['settings'])
return result
+13 -3
View File
@@ -14,7 +14,7 @@ import tile_icons
from updates import Updater
from aiohttp import ClientSession, ClientTimeout, WSMsgType, web
from core import BUILTIN, TILE_BACKGROUNDS, controls_catalogue, discover, extras, min_firmware, packets, state_message, validate_layout, validate_settings
from core import BUILTIN, TILE_BACKGROUNDS, controls_catalogue, discover, extras, min_firmware, pack_slots, packets, state_message, validate_layout, validate_settings
from zoneinfo import ZoneInfo
LOG = logging.getLogger('screen_manager')
@@ -269,6 +269,12 @@ class Manager:
tile['options'][key]=old_options[key]
if 'options' not in tile and 'options' in old_tiles.get(tile['entity'],{}):
tile['options'] = old_tiles[tile['entity']]['options'].copy()
# Restored options can widen a tile: an editor without positions packs again with
# the real widths, and explicit positions are checked once more for overlap.
if not any(isinstance(t, dict) and 'slot' in t for t in data.get('tiles', [])):
for tile, slot in zip(layout['tiles'], pack_slots(layout['tiles'])):
tile['slot'] = slot
layout = validate_layout(layout)
known = {e['id'] for e in entities} | set(BUILTIN)
if any(t['entity'] not in known for t in layout['tiles']):
raise ValueError('Een gekozen entiteit bestaat niet meer. Zoek de nieuwe entiteit op.')
@@ -327,8 +333,12 @@ class Manager:
if needed:
self.status[inbox]=f"Indeling bewaard; firmware {needed}+ nodig voor deze tegels"
return
messages = [{'v': 1, 'op': 'layout', 'inbox': inbox, 'title': layout['title'], 'entities': [t['entity'] for t in layout['tiles']],
'keepalive': KEEPALIVE_SECONDS}]
tiles = layout['tiles']
# Grid positions (firmware 0.2.26+; older firmware ignores them and packs the entities in order).
messages = [{'v': 1, 'op': 'layout', 'inbox': inbox, 'title': layout['title'], 'entities': [t['entity'] for t in tiles],
'slots': [t['slot'] for t in tiles], 'keepalive': KEEPALIVE_SECONDS}]
if 'pages' in layout:
messages[0]['pages'] = layout['pages']
if 'settings' in layout:
messages[0]['settings'] = {k:v for k,v in layout['settings'].items() if k not in ('swipe_pages','rotation')}
messages[0]['swipe_pages'] = layout['settings'].get('swipe_pages',False)
+276 -110
View File
@@ -64,6 +64,8 @@ function select(id) {
const screen = inventory.screens.find((s) => s.id === id);
if (!screen) return;
layout = structuredClone(screen.layout);
normalize();
insertAt = -1;
dirty = false;
$("#title").value = layout.title;
$("#screen-name").textContent = screen.name;
@@ -391,13 +393,6 @@ $("#auto-update").onchange = async () => {
toast(e.message);
}
};
function move(from, to) {
if (to < 0 || to >= layout.tiles.length) return;
const [tile] = layout.tiles.splice(from, 1);
layout.tiles.splice(to, 0, tile);
markDirty();
renderTiles();
}
const domains = {
light: ["Licht", "☀", "#ad7600", "#fff3d3"],
climate: ["Klimaat", "❄", "#c86620", "#ffebdc"],
@@ -465,17 +460,111 @@ function defaultOptions(id) {
if (domain === "screen") return { options: { display: "digital", size: "wide" } };
return {};
}
// Same packing as the firmware: wide tiles start in the left column and take a whole row.
function packTiles(tiles) {
// ---- Grid positions ----
// Two columns, three rows per page, at most eight pages. A tile's `slot` is its absolute
// cell (page * 6 + row * 2 + column); a wide tile starts in the left column and also covers
// the cell to its right. Empty cells are allowed and stay exactly where they are.
const SLOTS_PER_PAGE = 6, MAX_PAGES = 8, MAX_SLOTS = MAX_PAGES * SLOTS_PER_PAGE;
const isWide = (tile) => tile.options?.size === "wide";
const cellsOf = (slot, wide) => (wide ? [slot, slot + 1] : [slot]);
const rowStart = (slot) => slot - (slot % 2);
const pageOf = (slot) => Math.floor(slot / SLOTS_PER_PAGE);
const liveEntries = () => layout.tiles.map((tile) => ({ tile, slot: tile.slot }));
// In-order packing: the rule before positions existed, and what firmware below 0.2.26 still draws.
function packSlots(tiles) {
let position = 0;
const placement = tiles.map((tile) => {
const wide = tile.options?.size === "wide";
return tiles.map((tile) => {
const wide = isWide(tile);
if (wide && position % 2 === 1) position++;
const slot = { page: Math.floor(position / 6), slot: position % 6, wide };
const slot = position;
position += wide ? 2 : 1;
return slot;
});
return { placement, pages: Math.max(1, Math.ceil(position / 6)) };
}
function hasGaps(tiles) {
const packed = packSlots(tiles);
return tiles.some((tile, i) => tile.slot !== packed[i]);
}
// Every tile gets a position (older layouts pack in order), the list stays in reading
// order and the open settings sheet keeps following its tile.
function normalize() {
if (layout.tiles.some((t) => !Number.isInteger(t.slot))) { const packed = packSlots(layout.tiles); layout.tiles.forEach((t, i) => (t.slot = packed[i])); }
layout.tiles.sort((a, b) => a.slot - b.slot);
if (sheetIndex >= 0) sheetIndex = layout.tiles.findIndex((t) => t.entity === selectedTile);
}
function occupied(entries) {
const taken = new Set();
for (const { tile, slot } of entries) for (const cell of cellsOf(slot, isWide(tile))) taken.add(cell);
return taken;
}
const fits = (taken, slot, wide) => Number.isInteger(slot) && slot >= 0 && slot + (wide ? 1 : 0) < MAX_SLOTS && !(wide && slot % 2) && cellsOf(slot, wide).every((c) => !taken.has(c));
function firstFree(taken, wide) {
for (let slot = 0; slot < MAX_SLOTS; slot++) if (fits(taken, slot, wide)) return slot;
return -1;
}
// The free position closest to `origin`; on a tie the later one, so a nudged tile moves down, not up.
function nearestFree(taken, wide, origin) {
let best = -1;
for (let slot = 0; slot < MAX_SLOTS; slot++) if (fits(taken, slot, wide) && (best < 0 || Math.abs(slot - origin) <= Math.abs(best - origin))) best = slot;
return best;
}
// The arrangement after putting `moving` (a tile on the grid, or a new one) at `target`:
// it lands exactly there; tiles in its way take the cells it left (a swap) or else the
// nearest free cell; everything else stays put. Null when the target is off the grid.
function arrange(tiles, moving, target) {
const wide = isWide(moving);
if (wide) target = rowStart(target);
if (!fits(new Set(), target, wide)) return null;
const footprint = cellsOf(target, wide);
const vacated = tiles.includes(moving) ? cellsOf(moving.slot, wide) : [];
const result = [{ tile: moving, slot: target }], displaced = [];
for (const tile of tiles) {
if (tile === moving) continue;
if (cellsOf(tile.slot, isWide(tile)).some((c) => footprint.includes(c))) displaced.push(tile);
else result.push({ tile, slot: tile.slot });
}
for (const tile of displaced) {
const w = isWide(tile), taken = occupied(result);
let slot = vacated.map((c) => (w ? rowStart(c) : c)).find((c) => fits(taken, c, w));
if (slot === undefined) slot = nearestFree(taken, w, tile.slot);
if (slot < 0) return null;
result.push({ tile, slot });
}
return result.sort((a, b) => a.slot - b.slot);
}
// Apply an arrangement; a new tile joins the layout. True when anything changed.
function commit(result) {
const before = layout.tiles.map((t) => `${t.entity}@${t.slot}`).join();
for (const { tile, slot } of result) { tile.slot = slot; if (!layout.tiles.includes(tile)) layout.tiles.push(tile); }
normalize();
const changed = layout.tiles.map((t) => `${t.entity}@${t.slot}`).join() !== before;
if (changed) { markDirty(); renderTiles(); renderResults(); }
return changed;
}
function placeTile(tile, target) {
const result = arrange(layout.tiles, tile, target);
return result ? commit(result) : false;
}
// Pages the tiles need, or more when the user keeps empty pages on purpose (`layout.pages`).
function pageCount(entries) {
const last = Math.max(0, ...entries.map(({ tile, slot }) => slot + (isWide(tile) ? 2 : 1)));
return Math.min(MAX_PAGES, Math.max(1, Math.ceil(last / SLOTS_PER_PAGE), layout.pages || 1));
}
function addPage() {
layout.pages = Math.min(MAX_PAGES, pageCount(liveEntries()) + 1);
markDirty();
renderTiles();
document.querySelector("#layout-preview .screen-preview:last-child")?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
// An empty page goes; the pages after it move up.
function removePage(page) {
for (const tile of layout.tiles) if (pageOf(tile.slot) > page) tile.slot -= SLOTS_PER_PAGE;
layout.pages = Math.max(1, pageCount(liveEntries()) - 1);
markDirty();
renderTiles();
}
function focusSlot(slot) {
document.querySelector(`#layout-preview [data-slot="${slot}"]`)?.focus();
}
function supportsFirmware(major, minor, patch) {
const version = inventory.screens.find((s) => s.id === selected)?.firmware || "";
@@ -525,65 +614,114 @@ function tileLimit() {
function entityName(id) {
return inventory.entities.find((e) => e.id === id)?.name || inventory.builtin?.find((e) => e.id === id)?.name || id;
}
function renderPreview() {
let insertAt = -1; // empty cell chosen as the place for the next tile from the picker
function renderPreview(preview) {
const root = $("#layout-preview");
root.replaceChildren();
const { placement, pages } = packTiles(layout.tiles);
for (let page = 0; page < pages; page++) {
// While dragging, the mockup already shows the arrangement after the drop.
const entries = preview?.result || liveEntries();
const moving = preview?.moving || null;
const bySlot = new Map(entries.map((e) => [e.slot, e]));
const covered = new Set(entries.filter((e) => isWide(e.tile)).map((e) => e.slot + 1));
const pages = pageCount(entries), title = $("#title").value || "Thuis";
// While dragging, one more page waits below the last one.
const shown = drag.active && pages < MAX_PAGES ? pages + 1 : pages;
for (let page = 0; page < shown; page++) {
const frame = node("section", undefined, "screen-preview");
frame.append(node("small", `Pagina ${page + 1} · ${$("#title").value || "Thuis"}`, "preview-heading"));
const heading = node("div", undefined, "preview-heading");
heading.append(node("small", `Pagina ${page + 1} · ${title}`));
if (page >= pages) {
frame.classList.add("new-page");
heading.replaceChildren(node("small", `Pagina ${page + 1} · sleep hierheen voor een nieuwe pagina`));
} else if (pages > 1 && !entries.some((e) => pageOf(e.slot) === page)) {
heading.append(node("small", "leeg", "page-note"));
const drop = node("button", "Pagina weghalen", "mini");
drop.type = "button";
drop.title = "De pagina's erna schuiven een plek op";
drop.onclick = () => removePage(page);
heading.append(drop);
}
frame.append(heading);
const grid = node("div", undefined, "preview-grid");
let filled = 0;
layout.tiles.forEach((tile, index) => {
const place = placement[index];
if (place.page !== page) return;
// Keep the grid honest: an empty right column before a wide tile is a real gap on the screen.
for (; filled < place.slot; filled++) grid.append(node("span", "", "preview-gap"));
const card = node("div", undefined, "preview-tile");
card.tabIndex = 0;
card.setAttribute("role", "button");
const name = tile.name || entityName(tile.entity);
const background=inventory.backgrounds?.[tile.options?.background]?.color;
if(background)card.style.backgroundColor=background;
// "Geen": no card on the screen; the mockup keeps a dashed outline as drop target.
if (tile.options?.background === "none") card.classList.add("bare");
if (place.wide) card.classList.add("wide");
card.append(tileBadge(tile), node("strong", name));
card.dataset.index = index;
enableDrag(card, { kind: "tile", index });
card.classList.toggle("chosen", selectedTile === tile.entity);
card.setAttribute("aria-label", `Tegel ${index + 1}: ${name}, instellen`);
card.onclick = () => openTileSheet(index);
card.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openTileSheet(index); } };
const remove = node("button", "✕", "preview-remove");
remove.type = "button";
remove.title = "Tegel verwijderen";
remove.setAttribute("aria-label", `${name} verwijderen`);
remove.onclick = (e) => { e.stopPropagation(); removeTile(index); };
card.append(remove);
if (tile.options?.inline === "slider") card.append(node("span", "", "preview-slider"));
const controls = controlsPreview(tile);
if (controls) card.append(controls);
const display = tile.options?.display;
if (display && display !== "standard") card.append(node("small", displayNames[display] || display));
grid.append(card);
filled += place.wide ? 2 : 1;
});
if (layout.tiles.length < tileLimit())
for (; filled < 6; filled++) {
const card = node("button", undefined, "preview-tile vacant");
card.append(node("span", "+"), node("small", "Tegel toevoegen"));
card.onclick = () => { $("#search").focus(); $("#search").scrollIntoView({behavior:"smooth", block:"center"}); };
grid.append(card);
}
for (let cell = 0; cell < SLOTS_PER_PAGE; cell++) {
const slot = page * SLOTS_PER_PAGE + cell;
if (covered.has(slot)) continue;
const entry = bySlot.get(slot);
grid.append(entry ? tileCard(entry.tile, slot, entry.tile === moving) : emptyCell(slot));
}
frame.append(grid);
root.append(frame);
}
}
// A card on the mockup. A placeholder is the tile being dragged, drawn where it will land.
function tileCard(tile, slot, placeholder) {
const index = layout.tiles.indexOf(tile);
const card = node("div", undefined, "preview-tile");
card.dataset.slot = slot;
const name = tile.name || entityName(tile.entity);
const background = inventory.backgrounds?.[tile.options?.background]?.color;
if (background) card.style.backgroundColor = background;
// "Geen": no card on the screen; the mockup keeps a dashed outline.
if (tile.options?.background === "none") card.classList.add("bare");
if (isWide(tile)) card.classList.add("wide");
card.append(tileBadge(tile), node("strong", name));
if (tile.options?.inline === "slider") card.append(node("span", "", "preview-slider"));
const controls = controlsPreview(tile);
if (controls) card.append(controls);
const display = tile.options?.display;
if (display && display !== "standard") card.append(node("small", displayNames[display] || display));
if (placeholder || index < 0) { card.classList.add("placeholder"); return card; }
card.tabIndex = 0;
card.setAttribute("role", "button");
card.setAttribute("aria-label", `${name}, plek ${(slot % SLOTS_PER_PAGE) + 1} op pagina ${pageOf(slot) + 1}. Enter: instellen, pijltjestoetsen: verplaatsen`);
card.classList.toggle("chosen", selectedTile === tile.entity);
enableDrag(card, { kind: "tile", tile });
card.onclick = () => openTileSheet(index);
card.onkeydown = (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openTileSheet(index); return; }
const step = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -2, ArrowDown: 2 }[e.key];
if (!step) return;
e.preventDefault();
// A wide card owns its row: left and right mean the row above and below.
if (placeTile(tile, tile.slot + (isWide(tile) ? Math.sign(step) * 2 : step))) focusSlot(tile.slot);
};
const remove = node("button", "✕", "preview-remove");
remove.type = "button";
remove.title = "Tegel verwijderen";
remove.setAttribute("aria-label", `${name} verwijderen`);
remove.onclick = (e) => { e.stopPropagation(); removeTile(index); };
card.append(remove);
return card;
}
// An empty cell: a drop target, and a click marks it as the place for the next tile.
function emptyCell(slot) {
const cell = node("button", undefined, "preview-cell");
cell.type = "button";
cell.dataset.slot = slot;
const marked = insertAt === slot;
cell.classList.toggle("insert-here", marked);
cell.append(node("span", "+"), node("small", marked ? "Volgende tegel komt hier" : "Leeg"));
cell.title = "Lege plek. Klik om hier een tegel toe te voegen, of sleep er een naartoe.";
cell.setAttribute("aria-label", `Lege plek ${(slot % SLOTS_PER_PAGE) + 1} op pagina ${pageOf(slot) + 1}: hier de volgende tegel toevoegen`);
cell.onclick = () => {
insertAt = marked ? -1 : slot;
renderPreview();
if (insertAt >= 0) { $("#search").focus(); $("#search").scrollIntoView({ behavior: "smooth", block: "center" }); }
};
return cell;
}
function renderTiles() {
normalize();
// Pages follow the tiles; only a page the user added on purpose can stay empty.
layout.pages = pageCount(liveEntries());
renderPreview();
$("#add-page").disabled = layout.pages >= MAX_PAGES;
$("#count").textContent = `${layout.tiles.length} / ${tileLimit()}${tileLimit()===10?" · update firmware voor 20":""}`;
$("#no-tiles").hidden = layout.tiles.length > 0;
// Firmware below 0.2.26 ignores positions and packs the tiles in order, without gaps.
const hint = $("#positions-hint");
hint.hidden = !hasGaps(layout.tiles) || supportsFirmware(0, 2, 26);
hint.textContent = `Lege plekken en vaste posities werken vanaf firmware 0.2.26. Dit scherm (firmware ${inventory.screens.find((s) => s.id === selected)?.firmware || "onbekend"}) schuift de tegels tot die update aan tot de eerste vrije plek.`;
if (sheetIndex >= 0) renderTileSheet();
}
function removeTile(index) {
@@ -595,7 +733,7 @@ function removeTile(index) {
renderResults();
toast(`${tile.name || entityName(tile.entity)} verwijderd`, {
label: "Ongedaan maken",
run: () => { layout.tiles.splice(Math.min(index, layout.tiles.length), 0, tile); markDirty(); renderTiles(); renderResults(); },
run: () => placeTile(tile, tile.slot),
});
}
// Tile settings open in a sheet above the mockup; every change applies live,
@@ -748,6 +886,7 @@ function renderTileSheet() {
if (domain === "sun") displays.push(["sunpath", "Zonnebaan"]);
const current = (key, fallback) => tile.options?.[key] ?? fallback;
const set = (key, value) => {
const wasWide = isWide(tile);
tile.options = { ...tile.options, [key]: value };
// Direct controls need the standard layout without a mini slider, and vice versa.
if (key === "display" && value === "watch") { tile.options.inline = "none"; if (inventory.controls?.[domain]) tile.options.controls = "none"; }
@@ -755,6 +894,13 @@ function renderTileSheet() {
if (key === "inline" && value === "slider") { tile.options.display = "standard"; if (inventory.controls?.[domain]) tile.options.controls = "none"; }
if (key === "controls" && value !== "none") { tile.options.display = "standard"; tile.options.inline = "none"; }
markDirty();
// A card that becomes double-wide keeps its row when the cell beside it is free, else it
// takes the nearest free row (below first); every other tile stays where it is.
if (isWide(tile) && !wasWide) {
const taken = occupied(liveEntries().filter((e) => e.tile !== tile)), own = rowStart(tile.slot);
const slot = fits(taken, own, true) ? own : nearestFree(taken, true, own);
if (slot >= 0) tile.slot = slot;
}
renderTiles();
};
body.append(field("Weergave", segmented(displays, current("display", domain === "screen" ? "digital" : "standard"), (v) => set("display", v))));
@@ -815,48 +961,48 @@ function renderTileSheet() {
foot.append(done);
sheet.append(head, body, foot);
}
function addTile(id, at) {
// A click in the picker: the marked empty cell, else the first free cell.
function addTile(id) {
if (layout.tiles.some((t) => t.entity === id) || layout.tiles.length >= tileLimit()) return;
layout.tiles.splice(Math.min(at, layout.tiles.length), 0, { entity: id, name: "", ...defaultOptions(id) });
const tile = { entity: id, name: "", ...defaultOptions(id) };
const slot = insertAt >= 0 ? insertAt : firstFree(occupied(liveEntries()), isWide(tile));
insertAt = -1;
selectedTile = id;
markDirty();
renderTiles();
renderResults();
if (slot >= 0) placeTile(tile, slot);
}
// Pointer-based drag & drop: works with mouse and touch, from the picker into
// the mockup and between tiles. Touch starts after a short hold so the page
// still scrolls; a finished drag never doubles as a click.
const drag = { active: false, source: null, element: null, ghost: null, target: null, timer: 0, start: null, offset: null, pointerId: null, suppressUntil: 0, last: null, scroller: 0 };
// Pointer-based drag & drop, mouse and touch, from the picker into the mockup and between
// cells. Touch starts after a short hold so the page still scrolls. While dragging, the
// mockup already shows where everything ends up; the drop confirms exactly that, and a
// drop off the grid changes nothing. A finished drag never doubles as a click.
const drag = { active: false, source: null, element: null, ghost: null, timer: 0, start: null, offset: null, pointerId: null, suppressUntil: 0, last: null, scroller: 0, moving: null, target: null, preview: null };
function enableDrag(element, source) {
element.addEventListener("pointerdown", (e) => {
if (e.button !== 0 || element.disabled || e.target.closest(".preview-remove")) return;
// No text selection while the mouse drags; touch keeps its default so the page can scroll.
if (e.pointerType !== "touch") e.preventDefault();
Object.assign(drag, { source, element, start: { x: e.clientX, y: e.clientY }, pointerId: e.pointerId, active: false });
// A fast flick may leave the card before its first move event: keep the pointer until the drag begins.
try { element.setPointerCapture(e.pointerId); } catch {}
clearTimeout(drag.timer);
if (e.pointerType === "touch") drag.timer = setTimeout(() => beginDrag(e), 260);
});
element.addEventListener("pointermove", (e) => {
if (!drag.start || drag.element !== element) return;
if (drag.active || !drag.start || drag.element !== element) return;
const distance = Math.hypot(e.clientX - drag.start.x, e.clientY - drag.start.y);
if (!drag.active) {
if (e.pointerType === "touch") { if (distance > 10) { clearTimeout(drag.timer); drag.start = null; } return; }
if (distance < 6) return;
beginDrag(e);
}
moveDrag(e);
if (e.pointerType === "touch") { if (distance > 10) { clearTimeout(drag.timer); drag.start = null; } return; }
if (distance >= 6) beginDrag(e);
});
const finish = (e) => {
if (drag.element !== element) return;
clearTimeout(drag.timer);
if (drag.active) endDrag(e.type === "pointerup");
drag.start = null;
};
element.addEventListener("pointerup", finish);
element.addEventListener("pointercancel", finish);
const cancel = () => { if (drag.element === element && !drag.active) { clearTimeout(drag.timer); drag.start = null; } };
element.addEventListener("pointerup", cancel);
element.addEventListener("pointercancel", cancel);
}
function beginDrag(e) {
if (drag.active || !drag.start) return;
drag.active = true;
try { drag.element.setPointerCapture(drag.pointerId); } catch {}
drag.moving = drag.source.kind === "tile" ? drag.source.tile : { entity: drag.source.id, name: "", ...defaultOptions(drag.source.id) };
drag.target = null;
drag.preview = null;
getSelection()?.removeAllRanges();
const rect = drag.element.getBoundingClientRect();
const ghost = drag.element.cloneNode(true);
ghost.classList.add("drag-ghost");
@@ -865,45 +1011,64 @@ function beginDrag(e) {
document.body.append(ghost);
drag.ghost = ghost;
document.body.classList.add("dragging");
// The mockup re-renders while hovering, so the pointer is followed on the document, not the card.
document.addEventListener("pointermove", moveDrag);
document.addEventListener("pointerup", finishDrag);
document.addEventListener("pointercancel", finishDrag);
try { drag.element.releasePointerCapture(drag.pointerId); } catch {}
try { document.documentElement.setPointerCapture(drag.pointerId); } catch {}
document.addEventListener("touchmove", blockScroll, { passive: false });
// Near the viewport edges the page scrolls along, so the mockup can be reached on small screens.
drag.scroller = setInterval(() => {
if (!drag.last) return;
const step = drag.last.y < 70 ? -12 : drag.last.y > innerHeight - 70 ? 12 : 0;
if (step) { window.scrollBy(0, step); setTarget(document.elementFromPoint(drag.last.x, drag.last.y)?.closest(".preview-tile, .preview-gap, .preview-grid") || null); }
if (step) { window.scrollBy(0, step); setTarget(slotAt(drag.last.x, drag.last.y)); }
}, 16);
setTarget(-1);
moveDrag(e);
}
function blockScroll(e) { if (drag.active) e.preventDefault(); }
function finishDrag(e) { if (e.pointerId === drag.pointerId) endDrag(e.type === "pointerup"); }
function moveDrag(e) {
if (!drag.ghost) return;
if (!drag.ghost || e.pointerId !== drag.pointerId) return;
drag.last = { x: e.clientX, y: e.clientY };
drag.ghost.style.transform = `translate(${e.clientX - drag.offset.x}px, ${e.clientY - drag.offset.y}px)`;
const under = document.elementFromPoint(e.clientX, e.clientY);
setTarget(under?.closest(".preview-tile, .preview-gap, .preview-grid") || null);
setTarget(slotAt(e.clientX, e.clientY));
}
function setTarget(target) {
if (drag.target === target) return;
drag.target?.classList.remove("drop-target");
drag.target = target;
drag.target?.classList.add("drop-target");
// The cell under the pointer: the nearest card or empty cell (the gaps between them count
// too); on a wide card the left or right half decides. -1 away from the mockup.
function slotAt(x, y) {
let best = null, nearest = Infinity;
for (const cell of document.querySelectorAll("#layout-preview [data-slot]")) {
const r = cell.getBoundingClientRect();
const distance = Math.hypot(Math.max(r.left - x, 0, x - r.right), Math.max(r.top - y, 0, y - r.bottom));
if (distance < nearest) { nearest = distance; best = { cell, r }; }
}
if (!best || nearest > 16) return -1;
let slot = Number(best.cell.dataset.slot);
if (best.cell.classList.contains("wide") && x > (best.r.left + best.r.right) / 2) slot += 1;
return slot;
}
function setTarget(slot) {
if (drag.target === slot) return;
drag.target = slot;
const result = slot >= 0 ? arrange(layout.tiles, drag.moving, slot) : null;
drag.preview = result;
// Off the grid: a tile from the grid shows where it came from; a new one shows nowhere yet.
renderPreview({ result: result || liveEntries(), moving: drag.moving });
}
function endDrag(drop) {
const { target, source } = drag;
setTarget(null);
drag.ghost?.remove();
drag.ghost = null;
drag.active = false;
drag.suppressUntil = Date.now() + 400;
clearInterval(drag.scroller);
drag.last = null;
document.body.classList.remove("dragging");
const { preview } = drag;
document.removeEventListener("pointermove", moveDrag);
document.removeEventListener("pointerup", finishDrag);
document.removeEventListener("pointercancel", finishDrag);
document.removeEventListener("touchmove", blockScroll);
try { drag.element.releasePointerCapture(drag.pointerId); } catch {}
if (!drop || !target) return;
const to = target.dataset.index !== undefined ? Number(target.dataset.index) : layout.tiles.length;
if (source.kind === "tile") { if (to !== source.index) move(source.index, to); }
else addTile(source.id, to);
try { document.documentElement.releasePointerCapture(drag.pointerId); } catch {}
drag.ghost?.remove();
Object.assign(drag, { ghost: null, active: false, suppressUntil: Date.now() + 400, last: null, start: null, moving: null, target: null, preview: null });
clearInterval(drag.scroller);
document.body.classList.remove("dragging");
if (!(drop && preview && commit(preview))) renderPreview();
}
window.addEventListener("click", (e) => { if (Date.now() < drag.suppressUntil) { e.stopPropagation(); e.preventDefault(); } }, true);
function renderResults() {
@@ -934,7 +1099,7 @@ function renderResults() {
node("span", chosen.has(entity.id) ? "✓" : "+", "plus"),
);
b.disabled = chosen.has(entity.id) || layout.tiles.length >= tileLimit();
b.onclick = () => addTile(entity.id, layout.tiles.length);
b.onclick = () => addTile(entity.id);
if (!b.disabled) enableDrag(b, { kind: "entity", id: entity.id });
$("#results").append(b);
}
@@ -995,6 +1160,7 @@ $("#save").onclick = async () => {
}
};
$("#title").oninput = () => { markDirty(); renderPreview(); };
$("#add-page").onclick = addPage;
$("#search").oninput = renderResults;
$("#refresh").onclick = refresh;
for (const [value, label] of [
+4
View File
@@ -98,9 +98,13 @@
<span id="count">0 / 10</span>
</div>
<div id="layout-preview" aria-label="Schermindeling"></div>
<button id="add-page" class="quiet add-page" type="button">
+ Pagina toevoegen
</button>
<p id="no-tiles" class="hint">
Voeg je eerste lamp, scène of apparaat toe via de kiezer.
</p>
<p id="positions-hint" class="hint" hidden></p>
</div>
<div class="picker">
<label for="search">Voeg een entiteit toe</label
+23 -8
View File
@@ -331,14 +331,16 @@ aside h2 {
background: white;
margin: 0;
}
.drop-target {
outline: 2px solid var(--accent);
outline-offset: 2px;
background: var(--soft) !important;
}
body.dragging {
cursor: grabbing;
user-select: none;
-webkit-user-select: none;
}
/* Dragging a card must never start a text selection; the search box stays editable. */
.tile-editor,
.picker #results {
user-select: none;
-webkit-user-select: none;
}
#editor,
.empty {
@@ -839,15 +841,28 @@ pre {
.filters button { display: inline-flex; align-items: center; gap: 5px; }
.filters .domain-icon { width: 20px; height: 20px; flex-basis: 20px; font-size: 15px; }
.screen-preview { background: #e7e7e7; border: 1px solid #d9dde2; border-radius: 22px; padding: 14px; margin-top: 14px; }
.preview-heading { display: block; color: #49515a; margin: 0 2px 12px; }
.preview-heading { display: flex; align-items: center; gap: 8px; color: #49515a; margin: 0 2px 12px; }
.preview-heading .page-note { color: var(--muted); }
.preview-heading .mini { margin-left: auto; padding: 3px 10px; border-radius: 8px; font-size: 12px; font-weight: 600; background: white; color: var(--ink); border: 1px solid var(--line); }
.preview-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; }
.preview-tile { background: white; color: #25282c; border: 1px solid #ddd; border-radius: 15px; padding: 12px; min-height: 90px; text-align: left; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.preview-tile strong { flex: 1; min-width: 0; font-size: 13px; overflow-wrap: anywhere; }
.preview-tile .domain-icon { width: 30px; height: 30px; flex-basis: 30px; font-size: 20px; }
.preview-tile.chosen { outline: 2px solid var(--accent); outline-offset: 1px; }
.preview-tile.wide { grid-column: span 2; }
.preview-gap { min-height: 90px; border: 1px dashed #d0d4d9; border-radius: 15px; }
.preview-tile.vacant { background: #f4f4f4; border-style: dashed; color: #727982; justify-content: center; }
/* Empty cells are real places on the screen: a drop target, and a click marks where the next tile goes. */
.preview-cell { min-height: 90px; padding: 8px; border: 1px dashed #cfd4da; border-radius: 15px; background: transparent; color: #a3abb5; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; font-weight: 500; cursor: pointer; }
.preview-cell span { font-size: 20px; line-height: 1; }
.preview-cell small { font-size: 11px; }
.preview-cell:hover, .preview-cell:focus-visible { border-color: #9eb6cf; color: #4b5563; background: #fbfcfd; }
.preview-cell.insert-here { border: 2px dashed var(--accent); color: var(--accent); background: var(--soft); }
/* The dragged tile, drawn where it will land; the drop confirms exactly this. */
.preview-tile.placeholder { opacity: 0.6; border: 2px dashed var(--accent); background: var(--soft); box-shadow: none; cursor: default; }
.preview-tile.placeholder .preview-remove { display: none; }
.screen-preview.new-page { background: transparent; border: 2px dashed #cfd4da; }
.add-page { margin-top: 12px; width: 100%; border: 1px dashed #cfd4da; background: transparent; color: var(--muted); }
.add-page:hover:not(:disabled) { border-color: #9eb6cf; color: var(--ink); background: #fbfcfd; }
.screen-preview.new-page .preview-cell { border-color: #e3e7ec; }
.preview-slider { height: 13px; flex-basis: 100%; border-radius: 10px; background: linear-gradient(to right, #ffbf38 60%, #fff1d3 60%); }
/* Miniature direct controls on a wide mockup card, in the shapes the screen draws. */
.preview-controls { display: flex; align-items: center; gap: 4px; flex: none; margin-right: 18px; color: #1b1b1b; }
+1 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager
version: "0.2.30"
version: "0.2.31"
slug: esp_screen_manager
description: Kies tegels voor je CYD en Guition touchscreen, zonder opnieuw flashen.
url: https://github.com/MaxGramser/homeassistant_espscreen
+7 -4
View File
@@ -90,7 +90,9 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
layout={'title':'Mijn huis','tiles':[{'entity':'light.a','name':'Eigen naam'}]}
m.save('text.screen',layout)
fresh=self.setup_manager(path)
self.assertEqual(fresh.layouts['text.screen'],layout)
# Stored layouts always carry a grid position; a save without one packs in order.
self.assertEqual(fresh.layouts['text.screen'],{**layout,'tiles':[{**layout['tiles'][0],'slot':0}]})
layout=fresh.layouts['text.screen']
await fresh.sync_one('text.screen',layout)
self.assertEqual(len(fresh.ha.messages),2)
await fresh.sync_one('text.screen',layout)
@@ -109,7 +111,8 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
legacy={'title':'Thuis','tiles':[{'entity':'light.a','name':'Mijn lamp'}]}
path.write_text(json.dumps({'version':1,'screens':{'text.screen':legacy}}))
m=self.setup_manager(path)
self.assertEqual(m.layouts['text.screen'],legacy)
stored={**legacy,'tiles':[{**legacy['tiles'][0],'slot':0}]}
self.assertEqual(m.layouts['text.screen'],stored)
settings=validate_settings({'brightness':55,'standby_seconds':1200})
m.save('text.screen',{**legacy,'settings':settings})
fresh=self.setup_manager(path)
@@ -117,7 +120,7 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
fresh.save('text.screen',{**legacy,'title':'Andere titel'})
saved=fresh.layouts['text.screen']
self.assertEqual(saved['settings'],settings)
self.assertEqual(saved['tiles'],legacy['tiles'])
self.assertEqual(saved['tiles'],stored['tiles'])
before=path.read_bytes()
with self.assertRaises(ValueError): fresh.save('text.screen',{**saved,'settings':{'brightness':0}})
self.assertEqual(path.read_bytes(),before)
@@ -137,7 +140,7 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
await original(inbox,message)
m.save(inbox,{'title':'Leeg','tiles':[]})
m.ha.send=interrupt
await m.sync_one('text.screen',layout)
await m.sync_one('text.screen',m.layouts['text.screen'])
self.assertEqual(len(m.ha.messages),1)
self.assertNotIn('text.screen',m.sent)
+98
View File
@@ -0,0 +1,98 @@
"""Grid positions (app 0.2.31 / firmware 0.2.26): explicit slots, empty cells, in-order fallback."""
import json
import tempfile
import unittest
from pathlib import Path
import test_portal
from core import MAX_SLOTS, has_gaps, pack_slots, validate_layout
def tiles(*specs):
out = []
for spec in specs:
entity, slot, wide = (spec + (None, False))[:3]
tile = {'entity': entity}
if slot is not None: tile['slot'] = slot
if wide: tile['options'] = {'size': 'wide'}
out.append(tile)
return out
class Positions(unittest.TestCase):
def test_layout_without_positions_packs_in_order(self):
layout = validate_layout({'title': 'Thuis', 'tiles': tiles(('light.a',), ('weather.w', None, True), ('light.b',), ('light.c',))})
self.assertEqual([t['slot'] for t in layout['tiles']], [0, 2, 4, 5])
self.assertEqual(pack_slots(layout['tiles']), [0, 2, 4, 5])
self.assertFalse(has_gaps(layout['tiles']))
def test_explicit_positions_keep_gaps_and_sort_by_slot(self):
layout = validate_layout({'title': 'Thuis', 'tiles': tiles(('light.b', 7), ('light.a', 1), ('weather.w', 4, True), ('light.c', 46))})
self.assertEqual([(t['entity'], t['slot']) for t in layout['tiles']], [('light.a', 1), ('weather.w', 4), ('light.b', 7), ('light.c', 46)])
self.assertTrue(has_gaps(layout['tiles']))
# A second pass (reload from disk) is stable.
self.assertEqual(validate_layout(layout), layout)
def test_invalid_positions_are_refused(self):
for bad in [tiles(('light.a', 0), ('light.b', 0)), # same cell
tiles(('light.a', 1), ('weather.w', 0, True)), # wide covers 0 and 1
tiles(('weather.w', 3, True)), # wide in the right column
tiles(('light.a', MAX_SLOTS)), # beyond page eight
tiles(('light.a', -1)), tiles(('light.a', '2')), tiles(('light.a', 2.0)),
tiles(('light.a', 0), ('light.b',))]: # some tiles without a position
with self.assertRaises(ValueError, msg=bad): validate_layout({'title': 'Thuis', 'tiles': bad})
# Forecast forces a wide card, so the right column is refused there too.
with self.assertRaises(ValueError):
validate_layout({'title': 'Thuis', 'tiles': [{'entity': 'weather.w', 'slot': 1, 'options': {'display': 'forecast'}}]})
def test_pages_kept_on_purpose(self):
layout = validate_layout({'title': 'Thuis', 'tiles': tiles(('light.a', 0)), 'pages': 3})
self.assertEqual(layout['pages'], 3)
self.assertNotIn('pages', validate_layout({'title': 'Thuis', 'tiles': []}))
for bad in [0, 9, '2', 2.0, True]:
with self.assertRaises(ValueError, msg=bad): validate_layout({'title': 'Thuis', 'tiles': [], 'pages': bad})
def test_last_cell_of_page_eight_and_wide_on_last_row(self):
layout = validate_layout({'title': 'Thuis', 'tiles': tiles(('light.a', MAX_SLOTS - 1), ('weather.w', MAX_SLOTS - 4, True))})
self.assertEqual([t['slot'] for t in layout['tiles']], [MAX_SLOTS - 4, MAX_SLOTS - 1])
class PositionsOnTheWire(unittest.IsolatedAsyncioTestCase):
async def test_layout_message_carries_slots(self):
with tempfile.TemporaryDirectory() as tmp:
m = test_portal.ManagerTests().setup_manager(Path(tmp) / 'screens.json')
m.ha.states['light.b'] = {'state': 'off', 'attributes': {}}
m.save('text.screen', {'title': 'Thuis', 'tiles': tiles(('light.b', 7), ('light.a', 2))})
await m.sync_one('text.screen', m.layouts['text.screen'])
wire = m.ha.messages[0][1]
self.assertEqual(wire['entities'], ['light.a', 'light.b'])
self.assertEqual(wire['slots'], [2, 7])
# State messages index the entity list, in slot order.
self.assertEqual([(msg['i'], msg['entity']) for _, msg in m.ha.messages[1:]], [(0, 'light.a'), (1, 'light.b')])
# Moving a tile changes the layout message, so the whole layout goes out again.
m.ha.messages.clear()
m.save('text.screen', {'title': 'Thuis', 'tiles': tiles(('light.b', 6), ('light.a', 2))})
await m.sync_one('text.screen', m.layouts['text.screen'])
self.assertEqual(m.ha.messages[0][1]['slots'], [2, 6])
self.assertEqual(len(m.ha.messages), 3)
self.assertNotIn('pages', m.ha.messages[0][1])
m.ha.messages.clear()
m.save('text.screen', {'title': 'Thuis', 'tiles': tiles(('light.b', 6), ('light.a', 2)), 'pages': 4})
await m.sync_one('text.screen', m.layouts['text.screen'])
self.assertEqual(m.ha.messages[0][1]['pages'], 4)
async def test_old_storage_gets_positions_and_old_editor_keeps_order(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'screens.json'
legacy = {'title': 'Thuis', 'tiles': [{'entity': 'light.a', 'name': ''}, {'entity': 'light.b', 'name': '', 'options': {'size': 'wide'}}]}
path.write_text(json.dumps({'version': 1, 'screens': {'text.screen': legacy}}))
m = test_portal.ManagerTests().setup_manager(path)
self.assertEqual([t['slot'] for t in m.layouts['text.screen']['tiles']], [0, 2])
m.ha.states['light.b'] = {'state': 'off', 'attributes': {}}
# The editor moves light.b to page two; an older editor that saves without positions packs again.
m.save('text.screen', {'title': 'Thuis', 'tiles': [{'entity': 'light.a', 'slot': 1}, {'entity': 'light.b', 'slot': 6, 'options': {'size': 'wide'}}]})
self.assertEqual([t['slot'] for t in m.layouts['text.screen']['tiles']], [1, 6])
self.assertEqual(json.loads(path.read_text())['screens']['text.screen']['tiles'][1]['slot'], 6)
m.save('text.screen', {'title': 'Thuis', 'tiles': [{'entity': 'light.b'}, {'entity': 'light.a'}]})
saved = m.layouts['text.screen']['tiles']
self.assertEqual([(t['entity'], t['slot']) for t in saved], [('light.b', 0), ('light.a', 2)])
self.assertEqual(saved[0]['options'], {'size': 'wide'}, 'options survive an old editor, as before')
if __name__ == '__main__':
unittest.main()
+44
View File
@@ -57,3 +57,47 @@ static void test_domains_and_packing() {
assert(pack(m.tiles, 4, p) == 2 && p[3].page == 1 && p[3].slot == 0);
}
struct RunExtra { RunExtra() { test_domains_and_packing(); } } run_extra;
// Explicit grid positions (0.2.26+): gaps stay empty, a wide card starts in the left column.
static void test_explicit_slots() {
using namespace runtime_tiles;
Model m; bool changed = false, moved = false;
std::array<Placement, MAX_TILES> p;
// Validation: one slot per entity, inside eight pages, no duplicates.
assert(!m.set_layout({"light.a", "light.b"}, "Thuis", changed, {0}, moved));
assert(!m.set_layout({"light.a", "light.b"}, "Thuis", changed, {0, 48}, moved));
assert(!m.set_layout({"light.a", "light.b"}, "Thuis", changed, {3, 3}, moved));
assert(!m.configured);
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {0, 5, 7}, moved));
assert(changed && !moved && m.explicit_slots);
assert(place(m, p) == 2 && p[0].page == 0 && p[0].slot == 0 && p[1].slot == 5 && p[2].page == 1 && p[2].slot == 1);
// Same tiles elsewhere: states survive, only the pages re-place.
for (size_t i = 0; i < m.count; ++i) { m.tiles[i].received = true; m.tiles[i].state = "on"; }
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {2, 5, 7}, moved));
assert(!changed && moved && m.ready() && m.tiles[0].state == "on");
assert(place(m, p) == 2 && p[0].slot == 2);
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {2, 5, 7}, moved));
assert(!changed && !moved);
// A wide card on an odd slot snaps to its row start; the page count follows its footprint.
m.tiles[2].wide = true;
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {2, 5, 11}, moved));
assert(!changed && moved && place(m, p) == 2 && p[2].page == 1 && p[2].slot == 4);
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {2, 5, 12}, moved));
assert(place(m, p) == 3 && p[2].page == 2 && p[2].slot == 0);
// An empty page between two used pages stays a page; the last slot of page eight is allowed.
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {0, 1, 46}, moved));
assert(place(m, p) == 8 && p[2].page == 7 && p[2].slot == 4);
// Pages kept on purpose extend the count, never shrink it.
m.pages = 3; assert(place(m, p) == 8);
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {0, 1, 2}, moved));
assert(place(m, p) == 3); m.pages = 1; assert(place(m, p) == 1); m.pages = 200; assert(place(m, p) == MAX_PAGES); m.pages = 1;
// Without slots (older manager) the in-order packing returns.
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {}, moved));
assert(!changed && moved && !m.explicit_slots);
assert(place(m, p) == 1 && p[0].slot == 0 && p[1].slot == 1 && p[2].slot == 2);
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {}, moved));
assert(!changed && !moved);
// Changing the tiles resets everything, including the positions.
assert(m.set_layout({"light.a", "light.z"}, "Thuis", changed, {1, 6}, moved));
assert(changed && !moved && !m.ready() && place(m, p) == 2 && p[0].slot == 1 && p[1].page == 1);
}
struct RunSlots { RunSlots() { test_explicit_slots(); } } run_slots;