Port the full-page and navigation tiles into the Vue editor (for 0.2.74)

Sizes single, wide and full in web/src/model/layout.ts (a full tile covers the six cells of a page and starts
it; packing, fitting, arranging and page counting know it), the full card on the mockup, Size with Full page
and its firmware hint in the tile drawer, controls defaulting to none on a full tile, navigation tiles
(screen.page_1..8: Goes to page, size normal or double, icon and colour only, "Page n ›" on the card), the
tile limit of 48 from firmware 0.2.62, the "Screen" domain label. Vitest and the Python source tests cover it.
The add-on side of this round lives on the peer branch fullpage (4be6153).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-18 08:39:28 +02:00
parent 1ed42dc425
commit 00c9a4e4f3
17 changed files with 300 additions and 101 deletions
+3 -3
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// One page of the screen as the mockup draws it: the top bar and a 2 × 3 grid of cells.
import { computed } from "vue";
import { isWide, pageOf, SLOTS_PER_PAGE } from "../model/layout";
import { cellsOf, pageOf, sizeOf, SLOTS_PER_PAGE, spanOf } from "../model/layout";
import { isGuition, openBar, removePage, state } from "../store";
import type { Tile } from "../types";
import TileCard from "./TileCard.vue";
@@ -9,11 +9,11 @@ import TopbarSvg from "./TopbarSvg.vue";
const props = defineProps<{ page: number; entries: { tile: Tile; slot: number }[]; pages: number; moving: Tile | null }>();
const bySlot = computed(() => new Map(props.entries.map((e) => [e.slot, e])));
const covered = computed(() => new Set(props.entries.filter((e) => isWide(e.tile)).map((e) => e.slot + 1)));
const covered = computed(() => new Set(props.entries.flatMap((e) => cellsOf(e.slot, sizeOf(e.tile)).slice(1))));
const cells = computed(() => Array.from({ length: SLOTS_PER_PAGE }, (_, cell) => props.page * SLOTS_PER_PAGE + cell).filter((slot) => !covered.value.has(slot)));
const empty = computed(() => props.pages > 1 && !props.entries.some((e) => pageOf(e.slot) === props.page));
const barSelected = computed(() => state.inspector?.kind === "bar" || state.inspector?.kind === "bar-add");
const filled = computed(() => props.entries.filter((e) => pageOf(e.slot) === props.page).reduce((n, e) => n + (isWide(e.tile) ? 2 : 1), 0));
const filled = computed(() => props.entries.filter((e) => pageOf(e.slot) === props.page).reduce((n, e) => n + spanOf(sizeOf(e.tile)), 0));
function pickCell(slot: number) {
const marked = state.insertAt === slot;
state.insertAt = marked ? -1 : slot;
+2 -2
View File
@@ -2,7 +2,7 @@
// Read current data: what Home Assistant reports for the screen's tiles right now, and how each tile is set.
import { onMounted, ref, watch } from "vue";
import { getJson } from "../api";
import { controlsLabel, displayNames } from "../model/layout";
import { controlsLabel, displayNames, sizeNames } from "../model/layout";
import { closeInspector, state, toast } from "../store";
const props = defineProps<{ entity?: string }>();
@@ -27,7 +27,7 @@ async function load() {
}
const optionsText = (entity: string) => {
const options = state.layout?.tiles.find((t) => t.entity === entity)?.options || {};
return `Small slider: ${options.inline === "slider" ? "yes" : "no"} · Display: ${displayNames[options.display || "standard"] || options.display} · Width: ${options.size === "wide" ? "double" : "normal"} · Control: ${controlsLabel({ entity, name: "", slot: 0, options }, state.inventory)} · Background: ${state.inventory.backgrounds?.[options.background || "auto"]?.label || "Default"}`;
return `Small slider: ${options.inline === "slider" ? "yes" : "no"} · Display: ${displayNames[options.display || "standard"] || options.display} · Size: ${sizeNames[options.size as string] || "normal"} · Control: ${controlsLabel({ entity, name: "", slot: 0, options }, state.inventory)} · Background: ${state.inventory.backgrounds?.[options.background || "auto"]?.label || "Default"}`;
};
onMounted(load);
watch(() => props.entity, load);
+2 -2
View File
@@ -11,7 +11,7 @@ import { addTile, automaticIcon, isGuition, state, tileLimit } from "../store";
const FILTERS: [string, string][] = [
["", "All"], ["light", "Lights"], ["climate", "Climate"], ["switch", "Switches"], ["binary_sensor", "Status"], ["button", "Actions"],
["script", "Scripts"], ["fan", "Fans"], ["cover", "Covers"], ["scene", "Scenes"], ["vacuum", "Vacuum"], ["sensor", "Sensors"],
["media_player", "Media"], ["weather", "Weather"], ["number", "Values"], ["select", "Selects"], ["person", "People"], ["timer", "Timers"], ["screen", "Clock"],
["media_player", "Media"], ["weather", "Weather"], ["number", "Values"], ["select", "Selects"], ["person", "People"], ["timer", "Timers"], ["screen", "Screen"],
];
const ALIAS: Record<string, string> = { switch: "input_boolean", number: "input_number", select: "input_select", weather: "sun", button: "input_button" };
const chosen = computed(() => new Set(state.layout?.tiles.map((t) => t.entity) || []));
@@ -69,6 +69,6 @@ const tone = (e: { id: string; state?: string }) => {
<p v-if="!matches.length" class="hint">{{ state.hidePlaced && !state.search && !state.filter && !state.room ? "Everything here is already on this screen." : "No entities found. Try a different name, room or filter." }}</p>
<p v-else-if="matches.length > 80" class="hint">{{ matches.length }} results. Keep typing to narrow it down.</p>
</div>
<div class="lib-foot">{{ full ? `This screen holds ${tileLimit} tiles${tileLimit === 10 ? "; update its firmware for 20" : ""}.` : "Drag onto a page, or tap + to add to the first free slot. ⌘K searches everything." }}</div>
<div class="lib-foot">{{ full ? `This screen holds ${tileLimit} tiles${tileLimit < 48 ? "; update its firmware for 48" : ""}.` : "Drag onto a page, or tap + to add to the first free slot. ⌘K searches everything." }}</div>
</aside>
</template>
+30 -7
View File
@@ -3,14 +3,16 @@
// dragged, drawn where it will land.
import { computed, nextTick } from "vue";
import { vDrag } from "../drag";
import { displayNames, effectiveControls, isWide, pageOf, SLOTS_PER_PAGE } from "../model/layout";
import { displayNames, effectiveControls, isFull, isWide, pageOf, pageTarget, SLOTS_PER_PAGE } from "../model/layout";
import { glyph } from "../model/topbar";
import { entityName, liveOf, openTile, placeTile, removeTile, state, tileIconCp } from "../store";
import type { Tile } from "../types";
const props = defineProps<{ tile: Tile; slot: number; placeholder?: boolean }>();
const name = computed(() => props.tile.name || entityName(props.tile.entity));
const wide = computed(() => isWide(props.tile));
const full = computed(() => isFull(props.tile));
const wide = computed(() => isWide(props.tile) && !full.value);
const goesTo = computed(() => pageTarget(props.tile.entity));
const background = computed(() => state.inventory.backgrounds?.[props.tile.options?.background || ""]?.color);
const bare = computed(() => props.tile.options?.background === "none");
const display = computed(() => props.tile.options?.display || "standard");
@@ -79,8 +81,8 @@ async function onKey(e: KeyboardEvent) {
const step = ({ ArrowLeft: -1, ArrowRight: 1, ArrowUp: -2, ArrowDown: 2 } as Record<string, number>)[e.key];
if (!step) return;
e.preventDefault();
// A wide card owns its row: left and right mean the row above and below.
if (placeTile(props.tile, props.tile.slot + (wide.value ? Math.sign(step) * 2 : step))) {
// A wide card owns its row: left and right mean the row above and below. A full card moves by the page.
if (placeTile(props.tile, props.tile.slot + (full.value ? Math.sign(step) * SLOTS_PER_PAGE : wide.value ? Math.sign(step) * 2 : step))) {
await nextTick();
document.querySelector<HTMLElement>(`.pages [data-slot="${props.tile.slot}"]`)?.focus();
}
@@ -88,7 +90,7 @@ async function onKey(e: KeyboardEvent) {
</script>
<template>
<div class="tile" :class="{ wide, bare, placeholder: placeholder || !live, chosen }" :data-slot="slot"
<div class="tile" :class="{ wide, full, bare, placeholder: placeholder || !live, chosen }" :data-slot="slot"
:style="background && !bare ? { backgroundColor: background } : undefined"
:tabindex="live ? 0 : -1" :role="live ? 'button' : undefined" :aria-label="live ? label : undefined"
v-drag="{ kind: 'tile', tile }" @click="live && openTile(tile.entity)" @keydown="live && onKey($event)">
@@ -106,12 +108,32 @@ async function onKey(e: KeyboardEvent) {
<span class="ic mdi">{{ glyph(tileIconCp(tile)) }}</span>
<span class="lead"><span class="big">{{ String(now.getHours()).padStart(2, "0") }}:{{ String(now.getMinutes()).padStart(2, "0") }}</span><span class="nm">{{ name }}</span></span>
</template>
<template v-else-if="full">
<span class="ic mdi" :class="{ lit: isOn }">{{ glyph(tileIconCp(tile)) }}</span>
<span class="lead">
<span class="nm">{{ name }}</span>
<span v-if="goesTo" class="goto">Page {{ goesTo }} ›</span>
<span v-else-if="display === 'watch'" class="big">{{ bigValue }}<small v-if="unit && !gone">{{ unit }}</small></span>
<span v-else-if="status" class="st" :class="{ off: gone }">{{ status }}</span>
</span>
<span v-if="tile.options?.inline === 'slider'" class="mini-slider" :style="sliderStyle"></span>
<span v-if="controls" class="ctl">
<span v-if="controls === 'toggle'" class="tog" :class="{ off: !on }"></span>
<span v-else-if="controls === 'setpoint'" class="stp"><span class="mdi">{{ key("minus") || "−" }}</span><b>{{ setpoint }}</b><span class="mdi">{{ key("plus") || "+" }}</span></span>
<template v-else-if="controls === 'volume'"><span class="range" :style="volumeStyle"></span><span class="key mdi">{{ key("volume-high") }}</span></template>
<template v-else-if="controls === 'playback'"><span class="key mdi">{{ key("skip-previous") }}</span><span class="key mdi">{{ key(on ? "pause" : "play") || key("play") }}</span><span class="key mdi">{{ key("skip-next") }}</span></template>
<template v-else-if="controls === 'buttons' && domain === 'cover'"><span class="key mdi">{{ key("arrow-expand-horizontal") }}</span><span class="key mdi">{{ key("stop") }}</span><span class="key mdi">{{ key("arrow-collapse-horizontal") }}</span></template>
<span v-else-if="controls === 'run'" class="run">{{ ({ scene: "Activate", script: "Run" } as Record<string, string>)[domain] || "Press" }}</span>
<span v-else class="range" :style="{ background: `linear-gradient(to right, #2196f3 ${fill}%, #d3e8fb ${fill}%)` }"></span>
</span>
</template>
<template v-else-if="wide">
<span class="lead">
<span class="ic mdi" :class="{ lit: isOn }">{{ glyph(tileIconCp(tile)) }}</span>
<span class="tx">
<span class="nm">{{ name }}</span>
<span v-if="display === 'watch'" class="big">{{ bigValue }}<small v-if="unit && !gone">{{ unit }}</small></span>
<span v-if="goesTo" class="goto">Page {{ goesTo }} ›</span>
<span v-else-if="display === 'watch'" class="big">{{ bigValue }}<small v-if="unit && !gone">{{ unit }}</small></span>
<span v-else-if="status" class="st" :class="{ off: gone }">{{ status }}</span>
</span>
</span>
@@ -136,7 +158,8 @@ async function onKey(e: KeyboardEvent) {
<span class="lead">
<span v-if="display === 'watch'" class="big">{{ bigValue }}<small v-if="unit && !gone">{{ unit }}</small></span>
<span class="nm">{{ name }}</span>
<span v-if="display !== 'watch' && status" class="st" :class="{ off: gone }">{{ status }}</span>
<span v-if="goesTo" class="goto">Page {{ goesTo }} ›</span>
<span v-else-if="display !== 'watch' && status" class="st" :class="{ off: gone }">{{ status }}</span>
<span v-if="tile.options?.inline === 'slider'" class="mini-slider" :style="sliderStyle"></span>
</span>
</template>
+26 -13
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
// One tile's settings. Every change applies live, so the card on the mockup shows the result while you pick.
import { computed } from "vue";
import { domainInfo, SLIDER_DOMAINS, TOGGLE_BEFORE } from "../model/layout";
import { domainInfo, MAX_PAGES, pageTarget, SLIDER_DOMAINS, TOGGLE_BEFORE } from "../model/layout";
import { glyph } from "../model/topbar";
import { automaticIcon, closeInspector, entityName, markDirty, removeTile, setTileOption, state, supports, tileIconCp } from "../store";
import { automaticIcon, closeInspector, entityName, markDirty, removeTile, retargetPageTile, setTileOption, state, supports, tileIconCp } from "../store";
import type { Tile } from "../types";
import ActionPicker from "./ActionPicker.vue";
import IconPicker from "./IconPicker.vue";
@@ -12,6 +12,13 @@ import Segmented from "./Segmented.vue";
const props = defineProps<{ tile: Tile }>();
const domain = computed(() => props.tile.entity.split(".")[0]);
const name = computed(() => entityName(props.tile.entity));
// A navigation tile (screen.page_<n>): the page it opens, its size, icon and colour; nothing else applies.
const goesTo = computed(() => pageTarget(props.tile.entity));
const pages = Array.from({ length: MAX_PAGES }, (_, i) => [i + 1, String(i + 1)] as [number, string]);
const sizes = computed<[string, string][]>(() => goesTo.value ? [["single", "Normal"], ["wide", "Double-width"]] : [["single", "Normal"], ["wide", "Double-width"], ["full", "Full page"]]);
const sizeHint = computed(() => goesTo.value ? "" : supports(0, 2, 62)
? "Full page: one big button that lights up while on; a slider, controls or a graph sit at the bottom of it."
: "A full-page tile needs firmware 0.2.62: press Update on the screen first.");
const caps = computed(() => state.capabilities[props.tile.entity]);
const current = (key: string, fallback: unknown) => props.tile.options?.[key] ?? fallback;
const display = computed(() => current("display", domain.value === "screen" ? "digital" : "standard") as string);
@@ -33,7 +40,7 @@ const displayHint = computed(() => {
});
const size = computed(() => current("size", "single") as string);
const catalogue = computed(() => state.inventory.controls?.[domain.value]);
const controls = computed(() => current("controls", catalogue.value?.default) as string);
const controls = computed(() => current("controls", size.value === "full" ? "none" : catalogue.value?.default) as string);
const controlChoices = computed(() => {
const c = caps.value;
return (catalogue.value?.choices || []).filter((ch) => !c || ch.key === "none" || ch.key === controls.value || c.controls.includes(ch.key)).map((ch) => [ch.key, ch.label] as [string, string]);
@@ -42,7 +49,7 @@ const controlHint = computed(() => {
const c = caps.value;
if (c && controls.value !== "none" && !c.controls.includes(controls.value)) return { text: "Home Assistant doesn't offer this control for this entity, so it stays empty on the screen. Choose another one.", warn: true };
return { text: supports(0, 2, 19)
? "On the right of the double-width tile, like the rows in Home Assistant. Tapping the name works as set below."
? (size.value === "full" ? "At the bottom of the full-page tile; a tap anywhere else works as set below." : "On the right of the double-width tile, like the rows in Home Assistant. Tapping the name works as set below.")
: "The screen shows direct control from firmware 0.2.19; until then the tile stays as it was.", warn: false };
});
const tap = computed(() => current("tap", "auto") as string);
@@ -65,7 +72,7 @@ const sliderWarn = computed(() => inline.value === "slider" && caps.value && !ca
const history = computed(() => current("history_hours", 24) as number);
const backgrounds = computed(() => Object.entries(state.inventory.backgrounds || {}));
const fromHA = computed(() => Boolean(state.inventory.entities.find((e) => e.id === props.tile.entity)?.icon));
const showIcon = computed(() => Boolean(state.inventory.icons) && domain.value !== "screen" && !["forecast", "sunpath"].includes(display.value));
const showIcon = computed(() => Boolean(state.inventory.icons) && (domain.value !== "screen" || goesTo.value > 0) && !["forecast", "sunpath"].includes(display.value));
function rename(value: string) {
props.tile.name = value;
markDirty();
@@ -90,32 +97,38 @@ function inspect() {
:auto-label="`Automatic (${fromHA ? 'from Home Assistant' : 'default'})`"
:note="supports(0, 2, 18) ? '' : 'The screen shows a chosen icon from firmware 0.2.18.'"
@pick="(n) => setTileOption(tile, 'icon', n)" />
<div class="f">
<div v-if="goesTo" class="f">
<span class="f-label">Goes to page</span>
<Segmented :choices="pages" :value="goesTo" @pick="(v) => retargetPageTile(tile, Number(v))" />
<small>{{ supports(0, 2, 62) ? "A tap on the tile opens that page." : "Navigation tiles need firmware 0.2.62: press Update on the screen first." }}</small>
</div>
<div v-else class="f">
<span class="f-label">Display</span>
<Segmented :choices="displays" :value="display" @pick="(v) => setTileOption(tile, 'display', v)" />
<small v-if="displayHint" class="warn">{{ displayHint }}</small>
</div>
<div class="f">
<span class="f-label">Width</span>
<Segmented :choices="[['single', 'Normal'], ['wide', 'Double-width']]" :value="size" @pick="(v) => setTileOption(tile, 'size', v)" />
<span class="f-label">Size</span>
<Segmented :choices="sizes" :value="size" @pick="(v) => setTileOption(tile, 'size', v)" />
<small v-if="sizeHint">{{ sizeHint }}</small>
</div>
<div v-if="catalogue && size === 'wide'" class="f">
<div v-if="catalogue && size !== 'single' && !goesTo" class="f">
<span class="f-label">Direct control on the tile</span>
<Segmented :choices="controlChoices" :value="controls" @pick="(v) => setTileOption(tile, 'controls', v)" />
<small :class="{ warn: controlHint.warn }">{{ controlHint.text }}</small>
</div>
<div v-if="domain !== 'screen'" class="f">
<div v-if="domain !== 'screen' && !goesTo" class="f">
<span class="f-label">On tap</span>
<Segmented :choices="taps" :value="tap" @pick="(v) => setTileOption(tile, 'tap', v)" />
<small v-if="tapHint" :class="{ warn: tapHint.warn }">{{ tapHint.text }}</small>
</div>
<ActionPicker v-if="domain !== 'screen' && tap === 'action'" :tile="tile" />
<div v-if="showSlider" class="f">
<ActionPicker v-if="domain !== 'screen' && !goesTo && tap === 'action'" :tile="tile" />
<div v-if="showSlider && !goesTo" class="f">
<span class="f-label">Small slider on the tile</span>
<Segmented :choices="[['none', 'No'], ['slider', 'Yes, control directly']]" :value="inline" @pick="(v) => setTileOption(tile, 'inline', v)" />
<small v-if="sliderWarn" class="warn">Home Assistant has nothing a slider can change for this entity. Choose No.</small>
</div>
<div v-if="domain === 'sensor'" class="f">
<div v-if="domain === 'sensor' && !goesTo" class="f">
<span class="f-label">History</span>
<Segmented :choices="[[1, '1 hour'], [6, '6 hours'], [24, '24 hours']]" :value="history" @pick="(v) => setTileOption(tile, 'history_hours', Number(v))" />
</div>