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;