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:
@@ -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,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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+51
-30
@@ -1,7 +1,8 @@
|
||||
// ---- 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.
|
||||
// the cell to its right; a full tile (firmware 0.2.62+) starts a page and covers all six cells.
|
||||
// Empty cells are allowed and stay exactly where they are.
|
||||
import type { Inventory, Layout, Tile } from "../types";
|
||||
|
||||
export const SLOTS_PER_PAGE = 6;
|
||||
@@ -9,21 +10,36 @@ export const MAX_PAGES = 8;
|
||||
export const MAX_SLOTS = MAX_PAGES * SLOTS_PER_PAGE;
|
||||
|
||||
export type Entry = { tile: Tile; slot: number };
|
||||
// A tile is single, wide (a row) or full (the whole page); `true` still means wide.
|
||||
export type Size = "single" | "wide" | "full";
|
||||
export const SIZES: Size[] = ["single", "wide", "full"];
|
||||
type SizeLike = Size | boolean;
|
||||
const asSize = (size: SizeLike): Size => (size === true ? "wide" : size === false ? "single" : size);
|
||||
|
||||
export const isWide = (tile: Tile) => tile.options?.size === "wide";
|
||||
export const cellsOf = (slot: number, wide: boolean) => (wide ? [slot, slot + 1] : [slot]);
|
||||
// A navigation tile (screen.page_<n>, firmware 0.2.62+) and the page it opens; 0 for any other entity.
|
||||
export const pageTarget = (id: string) => (/^screen\.page_[1-8]$/.test(id) ? Number(id.slice(-1)) : 0);
|
||||
export const sizeOf = (tile: Tile): Size => (SIZES.includes(tile.options?.size as Size) ? (tile.options!.size as Size) : "single");
|
||||
export const isWide = (tile: Tile) => sizeOf(tile) !== "single";
|
||||
export const isFull = (tile: Tile) => sizeOf(tile) === "full";
|
||||
export const pageStart = (slot: number) => slot - (slot % SLOTS_PER_PAGE);
|
||||
export const rowStart = (slot: number) => slot - (slot % 2);
|
||||
export const pageOf = (slot: number) => Math.floor(slot / SLOTS_PER_PAGE);
|
||||
export const spanOf = (size: SizeLike) => (asSize(size) === "full" ? SLOTS_PER_PAGE : asSize(size) === "wide" ? 2 : 1);
|
||||
export const cellsOf = (slot: number, size: SizeLike) =>
|
||||
asSize(size) === "full" ? Array.from({ length: SLOTS_PER_PAGE }, (_, i) => pageStart(slot) + i) : asSize(size) === "wide" ? [slot, slot + 1] : [slot];
|
||||
// The cell a tile of `size` starts at when dropped on `slot`.
|
||||
export const startOf = (slot: number, size: SizeLike) => (asSize(size) === "full" ? pageStart(slot) : asSize(size) === "wide" ? rowStart(slot) : slot);
|
||||
export const entriesOf = (layout: Layout): Entry[] => 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.
|
||||
export function packSlots(tiles: Tile[]) {
|
||||
let position = 0;
|
||||
return tiles.map((tile) => {
|
||||
const wide = isWide(tile);
|
||||
if (wide && position % 2 === 1) position++;
|
||||
const size = sizeOf(tile);
|
||||
if (size === "full" && position % SLOTS_PER_PAGE) position += SLOTS_PER_PAGE - (position % SLOTS_PER_PAGE);
|
||||
else if (size === "wide" && position % 2 === 1) position++;
|
||||
const slot = position;
|
||||
position += wide ? 2 : 1;
|
||||
position += spanOf(size);
|
||||
return slot;
|
||||
});
|
||||
}
|
||||
@@ -41,41 +57,43 @@ export function normalize(layout: Layout) {
|
||||
}
|
||||
export function occupied(entries: Entry[]) {
|
||||
const taken = new Set<number>();
|
||||
for (const { tile, slot } of entries) for (const cell of cellsOf(slot, isWide(tile))) taken.add(cell);
|
||||
for (const { tile, slot } of entries) for (const cell of cellsOf(slot, sizeOf(tile))) taken.add(cell);
|
||||
return taken;
|
||||
}
|
||||
export const fits = (taken: Set<number>, slot: number, wide: boolean) =>
|
||||
Number.isInteger(slot) && slot >= 0 && slot + (wide ? 1 : 0) < MAX_SLOTS && !(wide && slot % 2) && cellsOf(slot, wide).every((c) => !taken.has(c));
|
||||
export function firstFree(taken: Set<number>, wide: boolean) {
|
||||
for (let slot = 0; slot < MAX_SLOTS; slot++) if (fits(taken, slot, wide)) return slot;
|
||||
export const fits = (taken: Set<number>, slot: number, size: SizeLike) =>
|
||||
Number.isInteger(slot) && slot >= 0 && slot < MAX_SLOTS &&
|
||||
(asSize(size) === "full" ? slot % SLOTS_PER_PAGE === 0 : asSize(size) === "wide" ? slot + 1 < MAX_SLOTS && !(slot % 2) : true) &&
|
||||
cellsOf(slot, size).every((c) => !taken.has(c));
|
||||
export function firstFree(taken: Set<number>, size: SizeLike, from = 0) {
|
||||
for (let slot = from; slot < MAX_SLOTS; slot++) if (fits(taken, slot, size)) return slot;
|
||||
return -1;
|
||||
}
|
||||
// The free position closest to `origin`; on a tie the later one, so a nudged tile moves down, not up.
|
||||
export function nearestFree(taken: Set<number>, wide: boolean, origin: number) {
|
||||
export function nearestFree(taken: Set<number>, size: SizeLike, origin: number) {
|
||||
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;
|
||||
if (fits(taken, slot, size) && (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.
|
||||
export function arrange(tiles: Tile[], moving: Tile, target: number): Entry[] | null {
|
||||
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 size = sizeOf(moving);
|
||||
target = startOf(target, size);
|
||||
if (!fits(new Set(), target, size)) return null;
|
||||
const footprint = cellsOf(target, size);
|
||||
const vacated = tiles.includes(moving) ? cellsOf(moving.slot, size) : [];
|
||||
const result: Entry[] = [{ tile: moving, slot: target }];
|
||||
const displaced: Tile[] = [];
|
||||
for (const tile of tiles) {
|
||||
if (tile === moving) continue;
|
||||
if (cellsOf(tile.slot, isWide(tile)).some((c) => footprint.includes(c))) displaced.push(tile);
|
||||
if (cellsOf(tile.slot, sizeOf(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));
|
||||
const w = sizeOf(tile), taken = occupied(result);
|
||||
let slot = vacated.map((c) => startOf(c, w)).find((c) => fits(taken, c, w));
|
||||
if (slot === undefined) slot = nearestFree(taken, w, tile.slot);
|
||||
if (slot < 0) return null;
|
||||
result.push({ tile, slot });
|
||||
@@ -84,7 +102,7 @@ export function arrange(tiles: Tile[], moving: Tile, target: number): Entry[] |
|
||||
}
|
||||
// Pages the tiles need, or more when the user keeps empty pages on purpose (`layout.pages`).
|
||||
export function pageCount(entries: Entry[], wanted = 1) {
|
||||
const last = Math.max(0, ...entries.map(({ tile, slot }) => slot + (isWide(tile) ? 2 : 1)));
|
||||
const last = Math.max(0, ...entries.map(({ tile, slot }) => slot + spanOf(sizeOf(tile))));
|
||||
return Math.min(MAX_PAGES, Math.max(1, Math.ceil(last / SLOTS_PER_PAGE), wanted || 1));
|
||||
}
|
||||
// New tiles start with the card that shows the entity best.
|
||||
@@ -92,17 +110,18 @@ export function defaultOptions(id: string): Partial<Tile> {
|
||||
const domain = id.split(".")[0];
|
||||
if (domain === "sun") return { options: { display: "sunpath", size: "wide" } };
|
||||
if (domain === "weather") return { options: { display: "forecast", size: "wide" } };
|
||||
if (pageTarget(id)) return {};
|
||||
if (domain === "screen") return { options: { display: "digital", size: "wide" } };
|
||||
return {};
|
||||
}
|
||||
export const newTile = (id: string): Tile => ({ entity: id, name: "", slot: -1, ...defaultOptions(id) } as Tile);
|
||||
|
||||
// Same rule as the add-on: only a wide card in the standard layout shows direct controls;
|
||||
// without a choice the domain's first control set applies.
|
||||
// Same rule as the add-on: only a wide or full card in the standard layout shows direct controls;
|
||||
// without a choice the domain's first control set applies to a wide card, none to a full one.
|
||||
export function effectiveControls(tile: Tile, inventory: Inventory): string | null {
|
||||
const domain = tile.entity.split(".")[0], catalogue = inventory.controls?.[domain], o = tile.options || {};
|
||||
if (!catalogue || o.size !== "wide" || (o.display || "standard") !== "standard" || o.inline === "slider") return null;
|
||||
const choice = o.controls ?? catalogue.default;
|
||||
if (!catalogue || !["wide", "full"].includes(o.size as string) || (o.display || "standard") !== "standard" || o.inline === "slider") return null;
|
||||
const choice = o.controls ?? (o.size === "full" ? "none" : catalogue.default);
|
||||
return choice === "none" ? null : choice;
|
||||
}
|
||||
export function controlsLabel(tile: Tile, inventory: Inventory) {
|
||||
@@ -120,15 +139,17 @@ export function versionAtLeast(version: string | undefined, minimum: string) {
|
||||
}
|
||||
export const supportsFirmware = (firmware: string | undefined, major: number, minor: number, patch: number) =>
|
||||
versionAtLeast(firmware, `${major}.${minor}.${patch}`);
|
||||
// Firmware 0.2.62 holds one tile per slot (48); 0.2.7 twenty; older firmware ten.
|
||||
export const MAX_TILES = MAX_SLOTS;
|
||||
export function tileLimit(firmware: string | undefined) {
|
||||
const match = parseVersion(firmware);
|
||||
if (match.length !== 3) return 10;
|
||||
return match[0] > 0 || match[1] > 2 || (match[1] === 2 && match[2] >= 7) ? 20 : 10;
|
||||
if (parseVersion(firmware).length !== 3) return 10;
|
||||
return versionAtLeast(firmware, "0.2.62") ? MAX_TILES : versionAtLeast(firmware, "0.2.7") ? 20 : 10;
|
||||
}
|
||||
export const displayNames: Record<string, string> = {
|
||||
standard: "standard", watch: "large value", forecast: "weather forecast", graph: "graph",
|
||||
digital: "digital clock", analog: "analog clock", sunpath: "sun path",
|
||||
};
|
||||
export const sizeNames: Record<string, string> = { single: "normal", wide: "double", full: "full page" };
|
||||
export const TOGGLE_BEFORE = ["light", "switch", "input_boolean", "fan", "media_player", "climate"];
|
||||
export const SLIDER_DOMAINS = ["light", "fan", "cover", "number", "input_number", "media_player"];
|
||||
|
||||
@@ -152,7 +173,7 @@ export const domains: Record<string, [string, string, string, string]> = {
|
||||
input_select: ["Select", "≡", "#5862af", "#eaecfa"],
|
||||
button: ["Action", "↗", "#5862af", "#eaecfa"],
|
||||
input_button: ["Action", "↗", "#5862af", "#eaecfa"],
|
||||
screen: ["Clock", "◷", "#25282c", "#e9ecf1"],
|
||||
screen: ["Screen", "◷", "#25282c", "#e9ecf1"],
|
||||
sun: ["Sun", "☼", "#c86620", "#ffebdc"],
|
||||
timer: ["Timer", "⏱", "#008577", "#def3ed"],
|
||||
person: ["Person", "☺", "#2f7d32", "#e1f2e2"],
|
||||
|
||||
+39
-9
@@ -3,8 +3,8 @@
|
||||
import { computed, reactive } from "vue";
|
||||
import { api, getJson, send, setCsrf } from "./api";
|
||||
import {
|
||||
arrange, entriesOf, firstFree, fits, isWide, MAX_PAGES, nearestFree, newTile, normalize, occupied, pageCount, pageOf,
|
||||
rowStart, SLOTS_PER_PAGE, supportsFirmware as supportsVersion, tileLimit as limitFor,
|
||||
arrange, cellsOf, entriesOf, firstFree, fits, isFull, isWide, MAX_PAGES, nearestFree, newTile, normalize, occupied, pageCount, pageOf,
|
||||
pageTarget, rowStart, sizeOf, SLOTS_PER_PAGE, supportsFirmware as supportsVersion, tileLimit as limitFor,
|
||||
} from "./model/layout";
|
||||
import { agoText, BAR_METRICS, clockText, dateText, itemKey, type ItemView, whenBarFontsLoad } from "./model/topbar";
|
||||
import { versionAtLeast } from "./model/layout";
|
||||
@@ -246,7 +246,7 @@ export function addTile(id: string) {
|
||||
const layout = state.layout;
|
||||
if (!layout || layout.tiles.some((t) => t.entity === id) || layout.tiles.length >= tileLimit.value) return;
|
||||
const tile = newTile(id);
|
||||
const slot = state.insertAt >= 0 ? state.insertAt : firstFree(occupied(entriesOf(layout)), isWide(tile));
|
||||
const slot = state.insertAt >= 0 ? state.insertAt : firstFree(occupied(entriesOf(layout)), sizeOf(tile));
|
||||
state.insertAt = -1;
|
||||
if (slot >= 0 && placeTile(tile, slot)) openTile(id);
|
||||
}
|
||||
@@ -286,7 +286,7 @@ export function pagesShown() {
|
||||
// The tile's options change live; 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.
|
||||
export function setTileOption(tile: Tile, key: string, value: unknown) {
|
||||
const domain = tile.entity.split(".")[0], caps = state.capabilities[tile.entity], wasWide = isWide(tile);
|
||||
const domain = tile.entity.split(".")[0], caps = state.capabilities[tile.entity], wasWide = isWide(tile), wasSize = sizeOf(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 (state.inventory.controls?.[domain]) tile.options.controls = "none"; }
|
||||
@@ -298,13 +298,43 @@ export function setTileOption(tile: Tile, key: string, value: unknown) {
|
||||
if (key === "size" && value === "wide" && caps && catalogue && !("controls" in tile.options) && !caps.controls.includes(catalogue.default))
|
||||
tile.options.controls = catalogue.choices.find((c) => c.key !== "none" && caps.controls.includes(c.key))?.key || "none";
|
||||
markDirty();
|
||||
if (state.layout && isWide(tile) && !wasWide) {
|
||||
const taken = occupied(entriesOf(state.layout).filter((e) => e.tile !== tile)), own = rowStart(tile.slot);
|
||||
const slot = fits(taken, own, true) ? own : nearestFree(taken, true, own);
|
||||
const layout = state.layout;
|
||||
if (!layout) return;
|
||||
// A card that grows to the whole page keeps its page: the other tiles there move to the first free
|
||||
// cells after it. With no room for them it takes the first empty page, or stays as it was.
|
||||
if (isFull(tile) && wasSize !== "full") {
|
||||
const page = pageOf(tile.slot), others = layout.tiles.filter((t) => t !== tile && pageOf(t.slot) === page);
|
||||
const taken = occupied(entriesOf(layout).filter((e) => e.tile !== tile && !others.includes(e.tile)));
|
||||
const moved: [Tile, number][] = [];
|
||||
for (const other of others) {
|
||||
const slot = firstFree(taken, sizeOf(other), (page + 1) * SLOTS_PER_PAGE);
|
||||
if (slot < 0) { moved.length = 0; break; }
|
||||
moved.push([other, slot]);
|
||||
for (const c of cellsOf(slot, sizeOf(other))) taken.add(c);
|
||||
}
|
||||
if (moved.length === others.length) { for (const [other, slot] of moved) other.slot = slot; tile.slot = page * SLOTS_PER_PAGE; }
|
||||
else {
|
||||
const slot = firstFree(occupied(entriesOf(layout).filter((e) => e.tile !== tile)), "full");
|
||||
if (slot >= 0) tile.slot = slot;
|
||||
else { tile.options.size = wasSize; toast("No page is free for a full-page tile. Free a page first."); }
|
||||
}
|
||||
} else if (isWide(tile) && !wasWide) {
|
||||
const taken = occupied(entriesOf(layout).filter((e) => e.tile !== tile)), own = rowStart(tile.slot);
|
||||
const slot = fits(taken, own, "wide") ? own : nearestFree(taken, "wide", own);
|
||||
if (slot >= 0) tile.slot = slot;
|
||||
normalize(state.layout);
|
||||
state.layout.pages = pageCount(entriesOf(state.layout), state.layout.pages);
|
||||
}
|
||||
normalize(layout);
|
||||
layout.pages = pageCount(entriesOf(layout), layout.pages);
|
||||
}
|
||||
// A navigation tile goes to another page: its entity changes (screen.page_<n>), one tile per page it goes to.
|
||||
export function retargetPageTile(tile: Tile, page: number) {
|
||||
const entity = `screen.page_${page}`;
|
||||
if (!state.layout || entity === tile.entity || !pageTarget(entity)) return false;
|
||||
if (state.layout.tiles.some((t) => t.entity === entity)) { toast(`This screen already has a tile that goes to page ${page}.`); return false; }
|
||||
tile.entity = entity;
|
||||
if (state.selectedTile) state.selectedTile = entity;
|
||||
markDirty();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- Inspector (the drawer) ----
|
||||
|
||||
@@ -474,3 +474,14 @@ td code { white-space: nowrap; }
|
||||
/* Try it (alerts) */
|
||||
.try-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; }
|
||||
.try-grid .field.wide { grid-column: 1 / -1; }
|
||||
|
||||
/* A full-page tile (firmware 0.2.62+): both columns and all three rows, one big button. */
|
||||
.tile.full { grid-column: span 2; grid-row: span 3; flex-direction: column; align-items: center; justify-content: center; text-align: center; gap: 6px; }
|
||||
.tile.full .ic { font-size: 34px; }
|
||||
.tile.full .lead { flex: 0 0 auto; align-content: center; justify-items: center; }
|
||||
.tile.full .nm { font-size: 14px; }
|
||||
.tile.full .st { font-size: 11px; }
|
||||
.tile.full .ctl { margin-top: 6px; }
|
||||
.tile.full .mini-slider { width: 60%; }
|
||||
.tile .goto { font-size: 10.5px; color: #5a5f66; }
|
||||
.device.cyd .tile.full .ic { font-size: 26px; }
|
||||
|
||||
Reference in New Issue
Block a user