Let the page buttons make way: a Page buttons setting that gives their room to the tiles, chevrons and dots instead of words, and a warning in the editor for pages nothing leads to (0.2.82)
GitHub issue #9 asked to drop the Previous/Next bar when Go to page tiles do the navigation and to give the tiles the whole screen. Page buttons is a setting the screen owns (on by default): a row in the Screen group of the settings page, switch.<screen>_page_buttons in Home Assistant, and a row in the editor's Screen settings. Without the bar, and on a screen with one page, place_page moves and sizes the three rows so they reach the bottom margin (Guition 108 -> 122 px, CYD 52 -> 61 px); a card keeps its circle, name and state in its middle and the single analog clock keeps its dial. With page buttons and swiping both off, the editor says where the Go to page tiles lead and which pages that leaves out of reach. The bar itself: each half of the band under the tiles is one key with an MDI chevron and lights up under a finger, and a dot per page sits between them; the screen's settings page uses the same pager. App 0.2.82, firmware 0.2.69. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
// The pages side by side, like swiping on the screen, and the library on the right.
|
||||
import { computed } from "vue";
|
||||
import { entriesOf, hasGaps, MAX_PAGES, pageCount } from "../model/layout";
|
||||
import { addPage, closeInspector, currentScreen, pagesShown, state, supports, tileLimit } from "../store";
|
||||
import { addPage, closeInspector, currentScreen, pageReachWarning, pagesShown, state, supports, tileLimit } from "../store";
|
||||
import DevicePage from "./DevicePage.vue";
|
||||
import Library from "./Library.vue";
|
||||
|
||||
@@ -14,6 +14,8 @@ const canAdd = computed(() => pages.value < MAX_PAGES);
|
||||
const positionsHint = computed(() => hasGaps(layout.value.tiles) && !supports(0, 2, 26)
|
||||
? `Empty slots and fixed positions work from firmware 0.2.26. This screen (firmware ${currentScreen.value?.firmware || "unknown"}) shifts the tiles up to the first free slot until that update.`
|
||||
: "");
|
||||
// Page buttons and swiping off: a page no Go to page tile reaches, or one without a way back.
|
||||
const reachHint = computed(() => pageReachWarning(entries.value, pages.value));
|
||||
function onCanvasClick(e: MouseEvent) {
|
||||
// A click beside the pages closes the drawer; the cards and the bar handle their own clicks.
|
||||
if ((e.target as HTMLElement).closest(".device, .page-label, .canvas-head")) return;
|
||||
@@ -28,6 +30,7 @@ function onCanvasClick(e: MouseEvent) {
|
||||
<span v-if="!layout.tiles.length" id="no-tiles">Add your first light, scene, or device from the library.</span>
|
||||
<span v-else>Tap the top bar or a tile to change it. Drag to move; drop on the next page for a new one.</span>
|
||||
<span v-if="positionsHint" id="positions-hint" class="warn">{{ positionsHint }}</span>
|
||||
<span v-if="reachHint" id="page-reach-hint" class="warn">{{ reachHint }}</span>
|
||||
</div>
|
||||
<div class="pages" id="layout-preview" aria-label="Screen layout">
|
||||
<DevicePage v-for="page in shown" :key="page" :page="page - 1" :entries="entries" :pages="pages" :moving="state.drag.moving" />
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
// once, like on the screen; no Save needed.
|
||||
import { computed } from "vue";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { currentScreen, SETTING_GROUPS, setSetting, settingText, settingValues, settingsView, state, steppedSetting, type SettingRow } from "../store";
|
||||
import { currentScreen, pageReachWarning, SETTING_GROUPS, setSetting, settingText, settingValues, settingsView, state, steppedSetting, type SettingRow } from "../store";
|
||||
|
||||
const view = computed(() => settingsView());
|
||||
const values = computed(() => settingValues());
|
||||
const offline = computed(() => view.value?.owner === "screen" && !currentScreen.value?.online);
|
||||
const groups = computed(() => SETTING_GROUPS.map((group) => ({ ...group, rows: (group.rows as readonly SettingRow[]).filter((row) => view.value?.keys.includes(row.key)) })).filter((g) => g.rows.length));
|
||||
// Page buttons and swiping off: the pages that only Go to page tiles could reach, and don't.
|
||||
const reachWarning = computed(() => pageReachWarning());
|
||||
const unavailable = (row: SettingRow) => offline.value || Boolean(view.value?.unavailable.includes(row.key));
|
||||
const needs = (row: SettingRow) => (row.needs ? Boolean(values.value[row.needs]) : true);
|
||||
const status = computed(() => offline.value
|
||||
@@ -78,6 +80,7 @@ function click(e: MouseEvent, row: SettingRow, direction: number) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="reachWarning && group.rows.some((row) => row.key === 'page_buttons')" class="hint warn" id="settings-page-reach">{{ reachWarning }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -105,6 +105,34 @@ export function pageCount(entries: Entry[], wanted = 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));
|
||||
}
|
||||
// With the page buttons and swiping both off (firmware 0.2.69+) only Go to page tiles change the page. Pages count
|
||||
// from 1: `tiles` is how many Go to page tiles lead to a page the screen has, `targets` the pages they lead to,
|
||||
// `unreachable` the pages no chain of them reaches from page 1, `noWayBack` the reachable ones they never lead back from.
|
||||
export function strandedPages(entries: Entry[], pages: number) {
|
||||
const links = Array.from({ length: pages }, () => new Set<number>());
|
||||
let tiles = 0;
|
||||
for (const { tile, slot } of entries) {
|
||||
const to = pageTarget(tile.entity) - 1, from = pageOf(slot);
|
||||
if (to < 0 || to >= pages || from >= pages) continue;
|
||||
tiles++;
|
||||
links[from].add(to);
|
||||
}
|
||||
const reach = (next: (page: number) => number[]) => {
|
||||
const seen = new Set([0]), queue = [0];
|
||||
while (queue.length) for (const page of next(queue.shift()!)) if (!seen.has(page)) { seen.add(page); queue.push(page); }
|
||||
return seen;
|
||||
};
|
||||
const forward = reach((page) => [...links[page]]);
|
||||
const back = reach((page) => links.flatMap((to, from) => (to.has(page) ? [from] : [])));
|
||||
const all = Array.from({ length: pages }, (_, page) => page);
|
||||
const numbers = (list: number[]) => list.map((page) => page + 1);
|
||||
return {
|
||||
tiles,
|
||||
targets: numbers(all.filter((page) => links.some((to) => to.has(page)))),
|
||||
unreachable: numbers(all.filter((page) => !forward.has(page))),
|
||||
noWayBack: numbers(all.filter((page) => forward.has(page) && !back.has(page))),
|
||||
};
|
||||
}
|
||||
// New tiles start with the card that shows the entity best.
|
||||
export function defaultOptions(id: string): Partial<Tile> {
|
||||
const domain = id.split(".")[0];
|
||||
|
||||
+20
-1
@@ -4,7 +4,7 @@ import { computed, reactive, toRaw } from "vue";
|
||||
import { api, getJson, send, setCsrf } from "./api";
|
||||
import {
|
||||
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,
|
||||
pageTarget, rowStart, sizeOf, SLOTS_PER_PAGE, strandedPages, 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";
|
||||
@@ -636,10 +636,29 @@ export const SETTING_GROUPS = [
|
||||
{ key: "auto_home_seconds", label: "After", kind: "duration", min: 30, max: 3600, needs: "auto_home" },
|
||||
{ key: "home_on_standby", label: "Also on standby", kind: "toggle" },
|
||||
{ key: "swipe_pages", label: "Swipe between pages", kind: "toggle" },
|
||||
{ key: "page_buttons", label: "Page buttons", kind: "toggle" },
|
||||
{ key: "rotation", label: "Rotation", kind: "choice", options: [[0, "0°"], [90, "90°"], [180, "180°"], [270, "270°"]] },
|
||||
] },
|
||||
] as const;
|
||||
export type SettingRow = (typeof SETTING_GROUPS)[number]["rows"][number] & { min?: number; max?: number; step?: number; unit?: string; needs?: string; cap?: string; options?: readonly (readonly [unknown, string])[] };
|
||||
// Page buttons and swiping both off (firmware 0.2.69+): only Go to page tiles change the page, so the editor says which
|
||||
// pages the Go to page tiles lead to and which page that leaves out, or has no way back to page 1. Empty when either
|
||||
// is on or still unknown, or every page can be reached and left.
|
||||
export function pageReachWarning(entries = liveEntries(), pages = state.layout ? pageCount(entries, state.layout.pages) : 1) {
|
||||
const values = settingValues();
|
||||
if (pages < 2 || values.page_buttons !== false || values.swipe_pages !== false) return "";
|
||||
const { tiles, targets, unreachable, noWayBack } = strandedPages(entries, pages);
|
||||
if (!unreachable.length && !noWayBack.length) return "";
|
||||
const named = (list: number[]) => (list.length === 1 ? `page ${list[0]}` : `pages ${list.slice(0, -1).join(", ")} and ${list[list.length - 1]}`);
|
||||
// A page that tiles lead to but only from pages that can't be reached themselves.
|
||||
const missing = unreachable.filter((page) => !targets.includes(page)), cutOff = unreachable.filter((page) => targets.includes(page));
|
||||
let text = "Page buttons and swiping are off, so only Go to page tiles change the page. ";
|
||||
text += tiles ? `You have ${tiles} Go to page ${tiles === 1 ? "tile" : "tiles"}, to ${named(targets)}` : "You have no Go to page tiles";
|
||||
text += missing.length ? `${tiles ? ", but none" : ""} to ${named(missing)}, so you can't reach ${missing.length === 1 ? "it" : "them"}.` : ".";
|
||||
if (cutOff.length) text += ` The tiles to ${named(cutOff)} are only on pages you can't reach.`;
|
||||
if (noWayBack.length) text += ` From ${named(noWayBack)} no tile leads back to page 1.`;
|
||||
return text;
|
||||
}
|
||||
// Changes made here that the screen has not reported back yet win over what Home Assistant still shows for a
|
||||
// few seconds, so a value never flicks back while it travels.
|
||||
const SETTING_EDIT_MS = 4000;
|
||||
|
||||
@@ -300,6 +300,7 @@ a:hover { text-decoration: underline; }
|
||||
.set-card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 6px 14px 8px; }
|
||||
.set-card h4 { display: flex; align-items: center; gap: 8px; padding: 10px 0 8px; }
|
||||
.set-card h4 .mdi { color: var(--muted); font-size: 16px; }
|
||||
.set-card > .hint { margin: 6px 0 4px; }
|
||||
.srow { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 0; border-top: 1px solid var(--line); }
|
||||
.srow > .s-label { color: var(--ink-2); }
|
||||
.srow.inactive .s-label, .srow.inactive .s-control { opacity: 0.45; }
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
arrange, cellsOf, controlsLabel, defaultOptions, effectiveControls, firstFree, fits, hasGaps, MAX_PAGES, MAX_SLOTS, nearestFree,
|
||||
newTile, normalize, occupied, packSlots, pageCount, pageStart, pageTarget, rowStart, sizeOf, SLOTS_PER_PAGE, spanOf, tileLimit, versionAtLeast,
|
||||
newTile, normalize, occupied, packSlots, pageCount, pageStart, pageTarget, rowStart, sizeOf, SLOTS_PER_PAGE, spanOf, strandedPages, tileLimit,
|
||||
versionAtLeast,
|
||||
} from "../src/model/layout";
|
||||
import type { Inventory, Layout, Tile } from "../src/types";
|
||||
|
||||
@@ -157,3 +158,19 @@ describe("full-page tiles and navigation tiles (firmware 0.2.62+)", () => {
|
||||
expect(effectiveControls(tile("light.a", 0, { size: "full", controls: "toggle" }), inventory)).toBe("toggle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pages that only Go to page tiles reach (firmware 0.2.69)", () => {
|
||||
const nav = (page: number, slot: number) => tile(`screen.page_${page}`, slot);
|
||||
it("finds a page no tile leads to, and one without a way back", () => {
|
||||
// Page 1 goes to 2 and 3; page 2 goes back to 1; page 3 leads nowhere; nothing goes to page 4.
|
||||
const layout = entries([nav(2, 0), nav(3, 1), tile("light.a", 2), nav(1, 6), tile("light.b", 12), tile("light.c", 18)]);
|
||||
expect(strandedPages(layout, 4)).toEqual({ tiles: 3, targets: [1, 2, 3], unreachable: [4], noWayBack: [3] });
|
||||
});
|
||||
it("follows a chain of pages, and ignores a tile to a page the screen lacks or to its own page", () => {
|
||||
const chain = entries([nav(2, 0), nav(3, 6), nav(1, 12), nav(8, 13), nav(3, 14)]);
|
||||
expect(strandedPages(chain, 3)).toEqual({ tiles: 4, targets: [1, 2, 3], unreachable: [], noWayBack: [] });
|
||||
// A tile to page 3 that sits on a page nobody reaches leaves page 3 out too.
|
||||
expect(strandedPages(entries([nav(3, 6)]), 3)).toEqual({ tiles: 1, targets: [3], unreachable: [2, 3], noWayBack: [] });
|
||||
expect(strandedPages(entries([tile("light.a", 0)]), 1)).toEqual({ tiles: 0, targets: [], unreachable: [], noWayBack: [] });
|
||||
});
|
||||
});
|
||||
|
||||
+32
-1
@@ -1,7 +1,7 @@
|
||||
// The store: selecting a screen, editing its layout, what's new, progress, copy and import.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
addTile, canAlert, copyLayoutFrom, fullPage, importLayout, layoutJson, liveOf, moveTileToPage, pageTilesRepeat, PHASES, removeTile,
|
||||
addTile, canAlert, copyLayoutFrom, fullPage, importLayout, layoutJson, liveOf, moveTileToPage, pageReachWarning, pageTilesRepeat, PHASES, removeTile,
|
||||
retargetPageTile, save, select, setTileOption, state, supports, tileLimit, topbarItems, topbarView, updateProgress, whatsNew,
|
||||
} from "../src/store";
|
||||
import type { Inventory, Screen } from "../src/types";
|
||||
@@ -408,3 +408,34 @@ describe("several tiles that go to the same page (firmware 0.2.65)", () => {
|
||||
expect(state.dirty).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("page buttons and swiping both off (firmware 0.2.69)", () => {
|
||||
const settings = (values: Record<string, unknown>) =>
|
||||
Object.assign(state.inventory.screens[0], { settings: { owner: "screen", values, keys: Object.keys(values), unavailable: [] } });
|
||||
const tiles = (list: [string, number][]) => { state.layout!.tiles = list.map(([entity, slot]) => ({ entity, name: "", slot, options: {} })); state.layout!.pages = 1; };
|
||||
it("says which pages the Go to page tiles lead to, and which one they leave out", () => {
|
||||
settings({ page_buttons: false, swipe_pages: false });
|
||||
select("living");
|
||||
tiles([["screen.page_2", 0], ["screen.page_3", 1], ["screen.page_1", 6], ["screen.page_1", 12], ["light.a", 18]]);
|
||||
expect(pageReachWarning()).toBe("Page buttons and swiping are off, so only Go to page tiles change the page. "
|
||||
+ "You have 4 Go to page tiles, to pages 1, 2 and 3, but none to page 4, so you can't reach it.");
|
||||
tiles([["light.a", 0], ["light.b", 6], ["switch.c", 12]]);
|
||||
expect(pageReachWarning()).toBe("Page buttons and swiping are off, so only Go to page tiles change the page. "
|
||||
+ "You have no Go to page tiles to pages 2 and 3, so you can't reach them.");
|
||||
tiles([["screen.page_2", 0], ["light.a", 6]]);
|
||||
expect(pageReachWarning()).toMatch(/You have 1 Go to page tile, to page 2\. From page 2 no tile leads back to page 1\.$/);
|
||||
});
|
||||
it("stays quiet while either way of paging is on, the values are unknown, or every page can be reached and left", () => {
|
||||
select("living");
|
||||
tiles([["light.a", 0], ["light.b", 6]]);
|
||||
for (const values of [{ page_buttons: true, swipe_pages: false }, { page_buttons: false, swipe_pages: true }, { page_buttons: null, swipe_pages: false }, {}]) {
|
||||
settings(values);
|
||||
expect(pageReachWarning()).toBe("");
|
||||
}
|
||||
settings({ page_buttons: false, swipe_pages: false });
|
||||
tiles([["screen.page_2", 0], ["screen.page_1", 6]]);
|
||||
expect(pageReachWarning()).toBe("");
|
||||
tiles([["light.a", 0]]);
|
||||
expect(pageReachWarning()).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user