From 95cdfa30cf24b9f72ee3d45b752b14b95be4c225 Mon Sep 17 00:00:00 2001 From: Max Gramser Date: Mon, 14 Sep 2026 13:00:39 +0200 Subject: [PATCH] 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 --- components/smart_display/runtime_model.h | 46 +++ components/smart_display/runtime_tiles.h | 20 +- docs/EASY_SETUP.md | 20 +- docs/RELEASING.md | 22 ++ docs/TEST_RESULTS_0231.md | 44 +++ guition-4848s040.yaml | 2 +- home-like-2432s028.yaml | 2 +- packages/cyd.yaml | 2 +- packages/guition.yaml | 2 +- screen_manager/CHANGELOG.md | 9 + screen_manager/app/core.py | 55 +++- screen_manager/app/server.py | 16 +- screen_manager/app/static/app.js | 386 ++++++++++++++++------- screen_manager/app/static/index.html | 4 + screen_manager/app/static/style.css | 31 +- screen_manager/config.yaml | 2 +- tests/test_portal.py | 11 +- tests/test_positions.py | 98 ++++++ tests/test_runtime_model.cpp | 44 +++ 19 files changed, 676 insertions(+), 140 deletions(-) create mode 100644 docs/TEST_RESULTS_0231.md create mode 100644 tests/test_positions.py diff --git a/components/smart_display/runtime_model.h b/components/smart_display/runtime_model.h index 0733fe3..7e31929 100644 --- a/components/smart_display/runtime_model.h +++ b/components/smart_display/runtime_model.h @@ -1,4 +1,5 @@ #pragma once +#include #include #include #include @@ -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 &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 &out); struct Model { std::array 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 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 &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 &entities, const std::string &name, bool &changed, + const std::vector &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 &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(slot / SLOTS_PER_PAGE), static_cast(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(m.pages, MAX_PAGES)}); +} } // namespace runtime_tiles diff --git a/components/smart_display/runtime_tiles.h b/components/smart_display/runtime_tiles.h index 6f0d9d6..23e2f25 100644 --- a/components/smart_display/runtime_tiles.h +++ b/components/smart_display/runtime_tiles.h @@ -160,9 +160,20 @@ inline std::string receive(const std::string &payload) { root["rotation"].as()>270 || root["rotation"].as()%90!=0))return false; if(!root["keepalive"].isNull() && (!root["keepalive"].is() || root["keepalive"].as()<5 || root["keepalive"].as()>3600))return false; + // Explicit grid positions (0.2.26+), one absolute slot per entity; absent on older managers. + std::vector positions; + if (!root["slots"].isNull()) { + if (!root["slots"].is()) return false; + for (JsonVariant slot : root["slots"].as()) { + if (!slot.is() || slot.as() >= MAX_SLOTS || positions.size() == MAX_TILES) return false; + positions.push_back(static_cast(slot.as())); + } + } 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() ? static_cast(std::clamp(root["pages"].as(), 1, MAX_PAGES)) : 1; if(root["swipe_pages"].is() && swipe_pages!=root["swipe_pages"].as()){ swipe_pages=root["swipe_pages"].as();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()) keepalive_seconds = root["keepalive"].as(); 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; - 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; - 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 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 [ diff --git a/screen_manager/app/static/index.html b/screen_manager/app/static/index.html index 7b9fe8b..6da4e00 100644 --- a/screen_manager/app/static/index.html +++ b/screen_manager/app/static/index.html @@ -98,9 +98,13 @@ 0 / 10
+

Voeg je eerste lamp, scène of apparaat toe via de kiezer.

+
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;