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
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<meta name="color-scheme" content="light dark" />
|
<meta name="color-scheme" content="light dark" />
|
||||||
<title>ESP Screens</title>
|
<title>ESP Screens</title>
|
||||||
<script type="module" crossorigin src="./assets/index-CcnhFWcS.js"></script>
|
<script type="module" crossorigin src="./assets/index-FSwnYVTm.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-PqOTdpJB.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-C6IelTvW.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -144,6 +144,17 @@ class Editor(unittest.TestCase):
|
|||||||
for marker in ('class="whatsnew"', 'role="progressbar"', "go('#firmware')"):
|
for marker in ('class="whatsnew"', 'role="progressbar"', "go('#firmware')"):
|
||||||
self.assertIn(marker, self.page, marker)
|
self.assertIn(marker, self.page, marker)
|
||||||
|
|
||||||
|
def test_full_page_and_navigation_tiles_are_in_the_editor(self):
|
||||||
|
import editor_sources
|
||||||
|
layout = editor_sources.source('model/layout.ts')
|
||||||
|
for marker in ('export const SIZES: Size[] = ["single", "wide", "full"];', 'export const pageTarget', 'versionAtLeast(firmware, "0.2.62") ? MAX_TILES'):
|
||||||
|
self.assertIn(marker, layout, marker)
|
||||||
|
drawer = editor_sources.component('TileInspector')
|
||||||
|
for marker in ('["full", "Full page"]', 'Goes to page', 'retargetPageTile(tile, Number(v))'):
|
||||||
|
self.assertIn(marker, drawer, marker)
|
||||||
|
self.assertIn(':class="{ wide, full, bare, placeholder: placeholder || !live, chosen }"', editor_sources.component('TileCard'))
|
||||||
|
self.assertIn('["screen", "Screen"]', editor_sources.component('Library'))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ class Editor(unittest.TestCase):
|
|||||||
def test_perform_action_is_a_tap_choice_with_home_assistants_list(self):
|
def test_perform_action_is_a_tap_choice_with_home_assistants_list(self):
|
||||||
self.assertIn('list.push(["action", "Perform action"]);', self.SCRIPT)
|
self.assertIn('list.push(["action", "Perform action"]);', self.SCRIPT)
|
||||||
self.assertIn('getJson(`entity-actions?entity=${encodeURIComponent(entity)}`)', self.SCRIPT)
|
self.assertIn('getJson(`entity-actions?entity=${encodeURIComponent(entity)}`)', self.SCRIPT)
|
||||||
self.assertIn('<ActionPicker v-if="domain !== \'screen\' && tap === \'action\'" :tile="tile" />', self.SCRIPT)
|
self.assertIn('<ActionPicker v-if="domain !== \'screen\' && !goesTo && tap === \'action\'" :tile="tile" />', self.SCRIPT)
|
||||||
# Fields follow Home Assistant's selectors; an empty field is left out of the data.
|
# Fields follow Home Assistant's selectors; an empty field is left out of the data.
|
||||||
for marker in ("kindOf(field) === 'boolean'", "kindOf(field) === 'number' || kindOf(field) === 'color_temp'", 'config.options', 'delete data[key]'):
|
for marker in ("kindOf(field) === 'boolean'", "kindOf(field) === 'number' || kindOf(field) === 'color_temp'", 'config.options', 'delete data[key]'):
|
||||||
self.assertIn(marker, self.PICKER, marker)
|
self.assertIn(marker, self.PICKER, marker)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// One page of the screen as the mockup draws it: the top bar and a 2 × 3 grid of cells.
|
// 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 { 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 { isGuition, openBar, removePage, state } from "../store";
|
||||||
import type { Tile } from "../types";
|
import type { Tile } from "../types";
|
||||||
import TileCard from "./TileCard.vue";
|
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 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 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 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 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 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) {
|
function pickCell(slot: number) {
|
||||||
const marked = state.insertAt === slot;
|
const marked = state.insertAt === slot;
|
||||||
state.insertAt = marked ? -1 : 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.
|
// 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 { onMounted, ref, watch } from "vue";
|
||||||
import { getJson } from "../api";
|
import { getJson } from "../api";
|
||||||
import { controlsLabel, displayNames } from "../model/layout";
|
import { controlsLabel, displayNames, sizeNames } from "../model/layout";
|
||||||
import { closeInspector, state, toast } from "../store";
|
import { closeInspector, state, toast } from "../store";
|
||||||
|
|
||||||
const props = defineProps<{ entity?: string }>();
|
const props = defineProps<{ entity?: string }>();
|
||||||
@@ -27,7 +27,7 @@ async function load() {
|
|||||||
}
|
}
|
||||||
const optionsText = (entity: string) => {
|
const optionsText = (entity: string) => {
|
||||||
const options = state.layout?.tiles.find((t) => t.entity === entity)?.options || {};
|
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);
|
onMounted(load);
|
||||||
watch(() => props.entity, load);
|
watch(() => props.entity, load);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { addTile, automaticIcon, isGuition, state, tileLimit } from "../store";
|
|||||||
const FILTERS: [string, string][] = [
|
const FILTERS: [string, string][] = [
|
||||||
["", "All"], ["light", "Lights"], ["climate", "Climate"], ["switch", "Switches"], ["binary_sensor", "Status"], ["button", "Actions"],
|
["", "All"], ["light", "Lights"], ["climate", "Climate"], ["switch", "Switches"], ["binary_sensor", "Status"], ["button", "Actions"],
|
||||||
["script", "Scripts"], ["fan", "Fans"], ["cover", "Covers"], ["scene", "Scenes"], ["vacuum", "Vacuum"], ["sensor", "Sensors"],
|
["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 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) || []));
|
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-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>
|
<p v-else-if="matches.length > 80" class="hint">{{ matches.length }} results. Keep typing to narrow it down.</p>
|
||||||
</div>
|
</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>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,14 +3,16 @@
|
|||||||
// dragged, drawn where it will land.
|
// dragged, drawn where it will land.
|
||||||
import { computed, nextTick } from "vue";
|
import { computed, nextTick } from "vue";
|
||||||
import { vDrag } from "../drag";
|
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 { glyph } from "../model/topbar";
|
||||||
import { entityName, liveOf, openTile, placeTile, removeTile, state, tileIconCp } from "../store";
|
import { entityName, liveOf, openTile, placeTile, removeTile, state, tileIconCp } from "../store";
|
||||||
import type { Tile } from "../types";
|
import type { Tile } from "../types";
|
||||||
|
|
||||||
const props = defineProps<{ tile: Tile; slot: number; placeholder?: boolean }>();
|
const props = defineProps<{ tile: Tile; slot: number; placeholder?: boolean }>();
|
||||||
const name = computed(() => props.tile.name || entityName(props.tile.entity));
|
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 background = computed(() => state.inventory.backgrounds?.[props.tile.options?.background || ""]?.color);
|
||||||
const bare = computed(() => props.tile.options?.background === "none");
|
const bare = computed(() => props.tile.options?.background === "none");
|
||||||
const display = computed(() => props.tile.options?.display || "standard");
|
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];
|
const step = ({ ArrowLeft: -1, ArrowRight: 1, ArrowUp: -2, ArrowDown: 2 } as Record<string, number>)[e.key];
|
||||||
if (!step) return;
|
if (!step) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// A wide card owns its row: left and right mean the row above and below.
|
// 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 + (wide.value ? Math.sign(step) * 2 : step))) {
|
if (placeTile(props.tile, props.tile.slot + (full.value ? Math.sign(step) * SLOTS_PER_PAGE : wide.value ? Math.sign(step) * 2 : step))) {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
document.querySelector<HTMLElement>(`.pages [data-slot="${props.tile.slot}"]`)?.focus();
|
document.querySelector<HTMLElement>(`.pages [data-slot="${props.tile.slot}"]`)?.focus();
|
||||||
}
|
}
|
||||||
@@ -88,7 +90,7 @@ async function onKey(e: KeyboardEvent) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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"
|
:style="background && !bare ? { backgroundColor: background } : undefined"
|
||||||
:tabindex="live ? 0 : -1" :role="live ? 'button' : undefined" :aria-label="live ? label : 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)">
|
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="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>
|
<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>
|
||||||
|
<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">
|
<template v-else-if="wide">
|
||||||
<span class="lead">
|
<span class="lead">
|
||||||
<span class="ic mdi" :class="{ lit: isOn }">{{ glyph(tileIconCp(tile)) }}</span>
|
<span class="ic mdi" :class="{ lit: isOn }">{{ glyph(tileIconCp(tile)) }}</span>
|
||||||
<span class="tx">
|
<span class="tx">
|
||||||
<span class="nm">{{ name }}</span>
|
<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 v-else-if="status" class="st" :class="{ off: gone }">{{ status }}</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -136,7 +158,8 @@ async function onKey(e: KeyboardEvent) {
|
|||||||
<span class="lead">
|
<span class="lead">
|
||||||
<span v-if="display === 'watch'" class="big">{{ bigValue }}<small v-if="unit && !gone">{{ unit }}</small></span>
|
<span v-if="display === 'watch'" class="big">{{ bigValue }}<small v-if="unit && !gone">{{ unit }}</small></span>
|
||||||
<span class="nm">{{ name }}</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 v-if="tile.options?.inline === 'slider'" class="mini-slider" :style="sliderStyle"></span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// One tile's settings. Every change applies live, so the card on the mockup shows the result while you pick.
|
// 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 { 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 { 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 type { Tile } from "../types";
|
||||||
import ActionPicker from "./ActionPicker.vue";
|
import ActionPicker from "./ActionPicker.vue";
|
||||||
import IconPicker from "./IconPicker.vue";
|
import IconPicker from "./IconPicker.vue";
|
||||||
@@ -12,6 +12,13 @@ import Segmented from "./Segmented.vue";
|
|||||||
const props = defineProps<{ tile: Tile }>();
|
const props = defineProps<{ tile: Tile }>();
|
||||||
const domain = computed(() => props.tile.entity.split(".")[0]);
|
const domain = computed(() => props.tile.entity.split(".")[0]);
|
||||||
const name = computed(() => entityName(props.tile.entity));
|
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 caps = computed(() => state.capabilities[props.tile.entity]);
|
||||||
const current = (key: string, fallback: unknown) => props.tile.options?.[key] ?? fallback;
|
const current = (key: string, fallback: unknown) => props.tile.options?.[key] ?? fallback;
|
||||||
const display = computed(() => current("display", domain.value === "screen" ? "digital" : "standard") as string);
|
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 size = computed(() => current("size", "single") as string);
|
||||||
const catalogue = computed(() => state.inventory.controls?.[domain.value]);
|
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 controlChoices = computed(() => {
|
||||||
const c = caps.value;
|
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]);
|
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;
|
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 };
|
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)
|
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 };
|
: "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);
|
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 history = computed(() => current("history_hours", 24) as number);
|
||||||
const backgrounds = computed(() => Object.entries(state.inventory.backgrounds || {}));
|
const backgrounds = computed(() => Object.entries(state.inventory.backgrounds || {}));
|
||||||
const fromHA = computed(() => Boolean(state.inventory.entities.find((e) => e.id === props.tile.entity)?.icon));
|
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) {
|
function rename(value: string) {
|
||||||
props.tile.name = value;
|
props.tile.name = value;
|
||||||
markDirty();
|
markDirty();
|
||||||
@@ -90,32 +97,38 @@ function inspect() {
|
|||||||
:auto-label="`Automatic (${fromHA ? 'from Home Assistant' : 'default'})`"
|
:auto-label="`Automatic (${fromHA ? 'from Home Assistant' : 'default'})`"
|
||||||
:note="supports(0, 2, 18) ? '' : 'The screen shows a chosen icon from firmware 0.2.18.'"
|
:note="supports(0, 2, 18) ? '' : 'The screen shows a chosen icon from firmware 0.2.18.'"
|
||||||
@pick="(n) => setTileOption(tile, 'icon', n)" />
|
@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>
|
<span class="f-label">Display</span>
|
||||||
<Segmented :choices="displays" :value="display" @pick="(v) => setTileOption(tile, 'display', v)" />
|
<Segmented :choices="displays" :value="display" @pick="(v) => setTileOption(tile, 'display', v)" />
|
||||||
<small v-if="displayHint" class="warn">{{ displayHint }}</small>
|
<small v-if="displayHint" class="warn">{{ displayHint }}</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="f">
|
<div class="f">
|
||||||
<span class="f-label">Width</span>
|
<span class="f-label">Size</span>
|
||||||
<Segmented :choices="[['single', 'Normal'], ['wide', 'Double-width']]" :value="size" @pick="(v) => setTileOption(tile, 'size', v)" />
|
<Segmented :choices="sizes" :value="size" @pick="(v) => setTileOption(tile, 'size', v)" />
|
||||||
|
<small v-if="sizeHint">{{ sizeHint }}</small>
|
||||||
</div>
|
</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>
|
<span class="f-label">Direct control on the tile</span>
|
||||||
<Segmented :choices="controlChoices" :value="controls" @pick="(v) => setTileOption(tile, 'controls', v)" />
|
<Segmented :choices="controlChoices" :value="controls" @pick="(v) => setTileOption(tile, 'controls', v)" />
|
||||||
<small :class="{ warn: controlHint.warn }">{{ controlHint.text }}</small>
|
<small :class="{ warn: controlHint.warn }">{{ controlHint.text }}</small>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="domain !== 'screen'" class="f">
|
<div v-if="domain !== 'screen' && !goesTo" class="f">
|
||||||
<span class="f-label">On tap</span>
|
<span class="f-label">On tap</span>
|
||||||
<Segmented :choices="taps" :value="tap" @pick="(v) => setTileOption(tile, 'tap', v)" />
|
<Segmented :choices="taps" :value="tap" @pick="(v) => setTileOption(tile, 'tap', v)" />
|
||||||
<small v-if="tapHint" :class="{ warn: tapHint.warn }">{{ tapHint.text }}</small>
|
<small v-if="tapHint" :class="{ warn: tapHint.warn }">{{ tapHint.text }}</small>
|
||||||
</div>
|
</div>
|
||||||
<ActionPicker v-if="domain !== 'screen' && tap === 'action'" :tile="tile" />
|
<ActionPicker v-if="domain !== 'screen' && !goesTo && tap === 'action'" :tile="tile" />
|
||||||
<div v-if="showSlider" class="f">
|
<div v-if="showSlider && !goesTo" class="f">
|
||||||
<span class="f-label">Small slider on the tile</span>
|
<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)" />
|
<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>
|
<small v-if="sliderWarn" class="warn">Home Assistant has nothing a slider can change for this entity. Choose No.</small>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="domain === 'sensor'" class="f">
|
<div v-if="domain === 'sensor' && !goesTo" class="f">
|
||||||
<span class="f-label">History</span>
|
<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))" />
|
<Segmented :choices="[[1, '1 hour'], [6, '6 hours'], [24, '24 hours']]" :value="history" @pick="(v) => setTileOption(tile, 'history_hours', Number(v))" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+51
-30
@@ -1,7 +1,8 @@
|
|||||||
// ---- Grid positions ----
|
// ---- Grid positions ----
|
||||||
// Two columns, three rows per page, at most eight pages. A tile's `slot` is its absolute
|
// 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
|
// 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";
|
import type { Inventory, Layout, Tile } from "../types";
|
||||||
|
|
||||||
export const SLOTS_PER_PAGE = 6;
|
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 const MAX_SLOTS = MAX_PAGES * SLOTS_PER_PAGE;
|
||||||
|
|
||||||
export type Entry = { tile: Tile; slot: number };
|
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";
|
// A navigation tile (screen.page_<n>, firmware 0.2.62+) and the page it opens; 0 for any other entity.
|
||||||
export const cellsOf = (slot: number, wide: boolean) => (wide ? [slot, slot + 1] : [slot]);
|
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 rowStart = (slot: number) => slot - (slot % 2);
|
||||||
export const pageOf = (slot: number) => Math.floor(slot / SLOTS_PER_PAGE);
|
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 }));
|
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.
|
// In-order packing: the rule before positions existed, and what firmware below 0.2.26 still draws.
|
||||||
export function packSlots(tiles: Tile[]) {
|
export function packSlots(tiles: Tile[]) {
|
||||||
let position = 0;
|
let position = 0;
|
||||||
return tiles.map((tile) => {
|
return tiles.map((tile) => {
|
||||||
const wide = isWide(tile);
|
const size = sizeOf(tile);
|
||||||
if (wide && position % 2 === 1) position++;
|
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;
|
const slot = position;
|
||||||
position += wide ? 2 : 1;
|
position += spanOf(size);
|
||||||
return slot;
|
return slot;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -41,41 +57,43 @@ export function normalize(layout: Layout) {
|
|||||||
}
|
}
|
||||||
export function occupied(entries: Entry[]) {
|
export function occupied(entries: Entry[]) {
|
||||||
const taken = new Set<number>();
|
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;
|
return taken;
|
||||||
}
|
}
|
||||||
export const fits = (taken: Set<number>, slot: number, wide: boolean) =>
|
export const fits = (taken: Set<number>, slot: number, size: SizeLike) =>
|
||||||
Number.isInteger(slot) && slot >= 0 && slot + (wide ? 1 : 0) < MAX_SLOTS && !(wide && slot % 2) && cellsOf(slot, wide).every((c) => !taken.has(c));
|
Number.isInteger(slot) && slot >= 0 && slot < MAX_SLOTS &&
|
||||||
export function firstFree(taken: Set<number>, wide: boolean) {
|
(asSize(size) === "full" ? slot % SLOTS_PER_PAGE === 0 : asSize(size) === "wide" ? slot + 1 < MAX_SLOTS && !(slot % 2) : true) &&
|
||||||
for (let slot = 0; slot < MAX_SLOTS; slot++) if (fits(taken, slot, wide)) return slot;
|
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;
|
return -1;
|
||||||
}
|
}
|
||||||
// The free position closest to `origin`; on a tie the later one, so a nudged tile moves down, not up.
|
// 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;
|
let best = -1;
|
||||||
for (let slot = 0; slot < MAX_SLOTS; slot++)
|
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;
|
return best;
|
||||||
}
|
}
|
||||||
// The arrangement after putting `moving` (a tile on the grid, or a new one) at `target`:
|
// 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
|
// 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.
|
// 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 {
|
export function arrange(tiles: Tile[], moving: Tile, target: number): Entry[] | null {
|
||||||
const wide = isWide(moving);
|
const size = sizeOf(moving);
|
||||||
if (wide) target = rowStart(target);
|
target = startOf(target, size);
|
||||||
if (!fits(new Set(), target, wide)) return null;
|
if (!fits(new Set(), target, size)) return null;
|
||||||
const footprint = cellsOf(target, wide);
|
const footprint = cellsOf(target, size);
|
||||||
const vacated = tiles.includes(moving) ? cellsOf(moving.slot, wide) : [];
|
const vacated = tiles.includes(moving) ? cellsOf(moving.slot, size) : [];
|
||||||
const result: Entry[] = [{ tile: moving, slot: target }];
|
const result: Entry[] = [{ tile: moving, slot: target }];
|
||||||
const displaced: Tile[] = [];
|
const displaced: Tile[] = [];
|
||||||
for (const tile of tiles) {
|
for (const tile of tiles) {
|
||||||
if (tile === moving) continue;
|
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 });
|
else result.push({ tile, slot: tile.slot });
|
||||||
}
|
}
|
||||||
for (const tile of displaced) {
|
for (const tile of displaced) {
|
||||||
const w = isWide(tile), taken = occupied(result);
|
const w = sizeOf(tile), taken = occupied(result);
|
||||||
let slot = vacated.map((c) => (w ? rowStart(c) : c)).find((c) => fits(taken, c, w));
|
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 === undefined) slot = nearestFree(taken, w, tile.slot);
|
||||||
if (slot < 0) return null;
|
if (slot < 0) return null;
|
||||||
result.push({ tile, slot });
|
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`).
|
// Pages the tiles need, or more when the user keeps empty pages on purpose (`layout.pages`).
|
||||||
export function pageCount(entries: Entry[], wanted = 1) {
|
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));
|
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.
|
// 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];
|
const domain = id.split(".")[0];
|
||||||
if (domain === "sun") return { options: { display: "sunpath", size: "wide" } };
|
if (domain === "sun") return { options: { display: "sunpath", size: "wide" } };
|
||||||
if (domain === "weather") return { options: { display: "forecast", size: "wide" } };
|
if (domain === "weather") return { options: { display: "forecast", size: "wide" } };
|
||||||
|
if (pageTarget(id)) return {};
|
||||||
if (domain === "screen") return { options: { display: "digital", size: "wide" } };
|
if (domain === "screen") return { options: { display: "digital", size: "wide" } };
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
export const newTile = (id: string): Tile => ({ entity: id, name: "", slot: -1, ...defaultOptions(id) } as Tile);
|
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;
|
// 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.
|
// 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 {
|
export function effectiveControls(tile: Tile, inventory: Inventory): string | null {
|
||||||
const domain = tile.entity.split(".")[0], catalogue = inventory.controls?.[domain], o = tile.options || {};
|
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;
|
if (!catalogue || !["wide", "full"].includes(o.size as string) || (o.display || "standard") !== "standard" || o.inline === "slider") return null;
|
||||||
const choice = o.controls ?? catalogue.default;
|
const choice = o.controls ?? (o.size === "full" ? "none" : catalogue.default);
|
||||||
return choice === "none" ? null : choice;
|
return choice === "none" ? null : choice;
|
||||||
}
|
}
|
||||||
export function controlsLabel(tile: Tile, inventory: Inventory) {
|
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) =>
|
export const supportsFirmware = (firmware: string | undefined, major: number, minor: number, patch: number) =>
|
||||||
versionAtLeast(firmware, `${major}.${minor}.${patch}`);
|
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) {
|
export function tileLimit(firmware: string | undefined) {
|
||||||
const match = parseVersion(firmware);
|
if (parseVersion(firmware).length !== 3) return 10;
|
||||||
if (match.length !== 3) return 10;
|
return versionAtLeast(firmware, "0.2.62") ? MAX_TILES : versionAtLeast(firmware, "0.2.7") ? 20 : 10;
|
||||||
return match[0] > 0 || match[1] > 2 || (match[1] === 2 && match[2] >= 7) ? 20 : 10;
|
|
||||||
}
|
}
|
||||||
export const displayNames: Record<string, string> = {
|
export const displayNames: Record<string, string> = {
|
||||||
standard: "standard", watch: "large value", forecast: "weather forecast", graph: "graph",
|
standard: "standard", watch: "large value", forecast: "weather forecast", graph: "graph",
|
||||||
digital: "digital clock", analog: "analog clock", sunpath: "sun path",
|
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 TOGGLE_BEFORE = ["light", "switch", "input_boolean", "fan", "media_player", "climate"];
|
||||||
export const SLIDER_DOMAINS = ["light", "fan", "cover", "number", "input_number", "media_player"];
|
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"],
|
input_select: ["Select", "≡", "#5862af", "#eaecfa"],
|
||||||
button: ["Action", "↗", "#5862af", "#eaecfa"],
|
button: ["Action", "↗", "#5862af", "#eaecfa"],
|
||||||
input_button: ["Action", "↗", "#5862af", "#eaecfa"],
|
input_button: ["Action", "↗", "#5862af", "#eaecfa"],
|
||||||
screen: ["Clock", "◷", "#25282c", "#e9ecf1"],
|
screen: ["Screen", "◷", "#25282c", "#e9ecf1"],
|
||||||
sun: ["Sun", "☼", "#c86620", "#ffebdc"],
|
sun: ["Sun", "☼", "#c86620", "#ffebdc"],
|
||||||
timer: ["Timer", "⏱", "#008577", "#def3ed"],
|
timer: ["Timer", "⏱", "#008577", "#def3ed"],
|
||||||
person: ["Person", "☺", "#2f7d32", "#e1f2e2"],
|
person: ["Person", "☺", "#2f7d32", "#e1f2e2"],
|
||||||
|
|||||||
+39
-9
@@ -3,8 +3,8 @@
|
|||||||
import { computed, reactive } from "vue";
|
import { computed, reactive } from "vue";
|
||||||
import { api, getJson, send, setCsrf } from "./api";
|
import { api, getJson, send, setCsrf } from "./api";
|
||||||
import {
|
import {
|
||||||
arrange, entriesOf, firstFree, fits, isWide, MAX_PAGES, nearestFree, newTile, normalize, occupied, pageCount, pageOf,
|
arrange, cellsOf, entriesOf, firstFree, fits, isFull, isWide, MAX_PAGES, nearestFree, newTile, normalize, occupied, pageCount, pageOf,
|
||||||
rowStart, SLOTS_PER_PAGE, supportsFirmware as supportsVersion, tileLimit as limitFor,
|
pageTarget, rowStart, sizeOf, SLOTS_PER_PAGE, supportsFirmware as supportsVersion, tileLimit as limitFor,
|
||||||
} from "./model/layout";
|
} from "./model/layout";
|
||||||
import { agoText, BAR_METRICS, clockText, dateText, itemKey, type ItemView, whenBarFontsLoad } from "./model/topbar";
|
import { agoText, BAR_METRICS, clockText, dateText, itemKey, type ItemView, whenBarFontsLoad } from "./model/topbar";
|
||||||
import { versionAtLeast } from "./model/layout";
|
import { versionAtLeast } from "./model/layout";
|
||||||
@@ -246,7 +246,7 @@ export function addTile(id: string) {
|
|||||||
const layout = state.layout;
|
const layout = state.layout;
|
||||||
if (!layout || layout.tiles.some((t) => t.entity === id) || layout.tiles.length >= tileLimit.value) return;
|
if (!layout || layout.tiles.some((t) => t.entity === id) || layout.tiles.length >= tileLimit.value) return;
|
||||||
const tile = newTile(id);
|
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;
|
state.insertAt = -1;
|
||||||
if (slot >= 0 && placeTile(tile, slot)) openTile(id);
|
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
|
// 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.
|
// 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) {
|
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 };
|
tile.options = { ...tile.options, [key]: value };
|
||||||
// Direct controls need the standard layout without a mini slider, and vice versa.
|
// 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"; }
|
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))
|
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";
|
tile.options.controls = catalogue.choices.find((c) => c.key !== "none" && caps.controls.includes(c.key))?.key || "none";
|
||||||
markDirty();
|
markDirty();
|
||||||
if (state.layout && isWide(tile) && !wasWide) {
|
const layout = state.layout;
|
||||||
const taken = occupied(entriesOf(state.layout).filter((e) => e.tile !== tile)), own = rowStart(tile.slot);
|
if (!layout) return;
|
||||||
const slot = fits(taken, own, true) ? own : nearestFree(taken, true, own);
|
// 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;
|
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) ----
|
// ---- Inspector (the drawer) ----
|
||||||
|
|||||||
@@ -474,3 +474,14 @@ td code { white-space: nowrap; }
|
|||||||
/* Try it (alerts) */
|
/* Try it (alerts) */
|
||||||
.try-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; }
|
.try-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; }
|
||||||
.try-grid .field.wide { grid-column: 1 / -1; }
|
.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; }
|
||||||
|
|||||||
@@ -118,3 +118,16 @@ describe("CommandPalette", () => {
|
|||||||
expect(state.palette).toBe(false);
|
expect(state.palette).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("full-page and navigation tiles on the mockup", () => {
|
||||||
|
it("draws a full tile as one big card and a navigation tile with its page", () => {
|
||||||
|
state.liveStates["light.a"] = { state: "on", word: "On", a: {} };
|
||||||
|
const full = placed({ entity: "light.a", name: "", slot: 0, options: { size: "full" } });
|
||||||
|
expect(full.classes()).toContain("full");
|
||||||
|
expect(full.classes()).not.toContain("wide");
|
||||||
|
expect(full.find(".ic").classes()).toContain("lit");
|
||||||
|
expect(full.find(".tog").exists()).toBe(false);
|
||||||
|
const nav = placed({ entity: "screen.page_3", name: "Go to page 3", slot: 6 });
|
||||||
|
expect(nav.find(".goto").text()).toBe("Page 3 ›");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// The grid rules: the same ones the add-on applies (core.pack_slots, validate_layout) and the firmware draws.
|
// The grid rules: the same ones the add-on applies (core.pack_slots, validate_layout) and the firmware draws.
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
arrange, controlsLabel, defaultOptions, effectiveControls, firstFree, fits, hasGaps, MAX_PAGES, MAX_SLOTS, nearestFree,
|
arrange, cellsOf, controlsLabel, defaultOptions, effectiveControls, firstFree, fits, hasGaps, MAX_PAGES, MAX_SLOTS, nearestFree,
|
||||||
newTile, normalize, occupied, packSlots, pageCount, rowStart, SLOTS_PER_PAGE, tileLimit, versionAtLeast,
|
newTile, normalize, occupied, packSlots, pageCount, pageStart, pageTarget, rowStart, sizeOf, SLOTS_PER_PAGE, spanOf, tileLimit, versionAtLeast,
|
||||||
} from "../src/model/layout";
|
} from "../src/model/layout";
|
||||||
import type { Inventory, Layout, Tile } from "../src/types";
|
import type { Inventory, Layout, Tile } from "../src/types";
|
||||||
|
|
||||||
@@ -109,3 +109,48 @@ describe("defaults, controls and versions", () => {
|
|||||||
expect(tileLimit(undefined)).toBe(10);
|
expect(tileLimit(undefined)).toBe(10);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("full-page tiles and navigation tiles (firmware 0.2.62+)", () => {
|
||||||
|
it("knows the three sizes and what they cover", () => {
|
||||||
|
expect(sizeOf(tile("a", 0))).toBe("single");
|
||||||
|
expect(sizeOf(tile("a", 0, { size: "wide" }))).toBe("wide");
|
||||||
|
expect(sizeOf(tile("a", 0, { size: "full" }))).toBe("full");
|
||||||
|
expect(sizeOf(tile("a", 0, { size: "huge" }))).toBe("single");
|
||||||
|
expect(spanOf("full")).toBe(6);
|
||||||
|
expect(cellsOf(8, "full")).toEqual([6, 7, 8, 9, 10, 11]);
|
||||||
|
expect(cellsOf(3, true)).toEqual([3, 4]);
|
||||||
|
expect(pageStart(11)).toBe(6);
|
||||||
|
});
|
||||||
|
it("packs a full tile at the start of a page and lets only a page start fit it", () => {
|
||||||
|
expect(packSlots([tile("a", -1), tile("f", -1, { size: "full" }), tile("b", -1)])).toEqual([0, 6, 12]);
|
||||||
|
const taken = occupied(entries([tile("a", 0)]));
|
||||||
|
expect(fits(taken, 0, "full")).toBe(false);
|
||||||
|
expect(fits(taken, 6, "full")).toBe(true);
|
||||||
|
expect(fits(taken, 8, "full")).toBe(false);
|
||||||
|
expect(firstFree(taken, "full")).toBe(6);
|
||||||
|
expect(firstFree(taken, "single", 6)).toBe(6);
|
||||||
|
expect(pageCount(entries([tile("f", 6, { size: "full" })]))).toBe(2);
|
||||||
|
});
|
||||||
|
it("drops a full tile on its page and moves what was there to the next free cells", () => {
|
||||||
|
const a = tile("a", 0), b = tile("b", 3), f = tile("f", 6, { size: "full" });
|
||||||
|
const result = arrange([a, b, f], f, 2)!;
|
||||||
|
const by = Object.fromEntries(result.map((e) => [e.tile.entity, e.slot]));
|
||||||
|
expect(by.f).toBe(0);
|
||||||
|
// The displaced tiles take the cells the full tile left, in order.
|
||||||
|
expect(by.a).toBe(6);
|
||||||
|
expect(by.b).toBe(7);
|
||||||
|
});
|
||||||
|
it("knows a navigation tile and gives it no default card", () => {
|
||||||
|
expect(pageTarget("screen.page_3")).toBe(3);
|
||||||
|
expect(pageTarget("screen.page_9")).toBe(0);
|
||||||
|
expect(pageTarget("screen.clock")).toBe(0);
|
||||||
|
expect(defaultOptions("screen.page_2")).toEqual({});
|
||||||
|
});
|
||||||
|
it("holds 48 tiles from firmware 0.2.62 and shows no control on a full card without a choice", () => {
|
||||||
|
expect(tileLimit("0.2.62")).toBe(48);
|
||||||
|
expect(tileLimit("0.2.61")).toBe(20);
|
||||||
|
const inventory = { screens: [], entities: [], controls: { light: { default: "toggle", choices: [{ key: "toggle", label: "On/off" }, { key: "none", label: "None" }] } } } as unknown as Inventory;
|
||||||
|
expect(effectiveControls(tile("light.a", 0, { size: "full" }), inventory)).toBeNull();
|
||||||
|
expect(effectiveControls(tile("light.a", 0, { size: "full", controls: "toggle" }), inventory)).toBe("toggle");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+33
-1
@@ -1,7 +1,7 @@
|
|||||||
// The store: selecting a screen, editing its layout, what's new, progress, copy and import.
|
// The store: selecting a screen, editing its layout, what's new, progress, copy and import.
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
addTile, canAlert, copyLayoutFrom, importLayout, layoutJson, liveOf, PHASES, removeTile, select, setTileOption, state, tileLimit,
|
addTile, canAlert, copyLayoutFrom, importLayout, layoutJson, liveOf, PHASES, removeTile, retargetPageTile, select, setTileOption, state, tileLimit,
|
||||||
topbarItems, topbarView, updateProgress, whatsNew,
|
topbarItems, topbarView, updateProgress, whatsNew,
|
||||||
} from "../src/store";
|
} from "../src/store";
|
||||||
import type { Inventory, Screen } from "../src/types";
|
import type { Inventory, Screen } from "../src/types";
|
||||||
@@ -156,3 +156,35 @@ describe("updates with content", () => {
|
|||||||
expect(canAlert(undefined)).toBe(false);
|
expect(canAlert(undefined)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("full-page and navigation tiles", () => {
|
||||||
|
it("moves the other tiles of the page behind a tile that grows to the whole page", () => {
|
||||||
|
select("living");
|
||||||
|
const [lamp, sensor] = state.layout!.tiles;
|
||||||
|
setTileOption(lamp, "size", "full");
|
||||||
|
expect(lamp.slot).toBe(0);
|
||||||
|
expect(sensor.slot).toBe(6);
|
||||||
|
expect(state.layout!.pages).toBe(2);
|
||||||
|
expect(state.toast).toBeNull();
|
||||||
|
});
|
||||||
|
it("takes the first empty page when the others cannot move, and gives up with a toast when none is free", () => {
|
||||||
|
select("living");
|
||||||
|
state.layout!.tiles = Array.from({ length: 48 }, (_, i) => ({ entity: `light.l${i}`, name: "", slot: i }));
|
||||||
|
const first = state.layout!.tiles[0];
|
||||||
|
setTileOption(first, "size", "full");
|
||||||
|
expect(first.options?.size).toBe("single");
|
||||||
|
expect(state.toast?.message).toMatch(/No page is free/);
|
||||||
|
});
|
||||||
|
it("lets a navigation tile point at another page, once per page", () => {
|
||||||
|
select("living");
|
||||||
|
addTile("screen.page_2");
|
||||||
|
const nav = state.layout!.tiles.find((t) => t.entity === "screen.page_2")!;
|
||||||
|
expect(nav.options).toBeUndefined();
|
||||||
|
expect(retargetPageTile(nav, 3)).toBe(true);
|
||||||
|
expect(nav.entity).toBe("screen.page_3");
|
||||||
|
addTile("screen.page_4");
|
||||||
|
expect(retargetPageTile(nav, 4)).toBe(false);
|
||||||
|
expect(state.toast?.message).toMatch(/already has a tile that goes to page 4/);
|
||||||
|
expect(retargetPageTile(nav, 9)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user