Rebuild the editor as a Vue app: sidebar, pages side by side, tile settings in a drawer (0.2.74)

The page is now web/ (Vue 3 + Vite + TypeScript), built into screen_manager/app/static
with hashed file names; server.py serves index.html as built and /assets/. The Python
API, the data and the firmware are unchanged. Your screens sit in a sidebar, the pages
of a screen stand next to each other at the screen's own shape, the library sits on the
right, and a tile's settings slide in as a drawer while the mockup stays in view. New
screen, Firmware & USB, Alerts, Override YAML and Settings are pages of their own.
Fonts moved to web/src/assets; the tests read the source through tests/editor_sources.py.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-18 07:48:45 +02:00
parent 9f5694b9eb
commit 510ebdf885
72 changed files with 5486 additions and 5409 deletions
+162
View File
@@ -0,0 +1,162 @@
// ---- 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.
import type { Inventory, Layout, Tile } from "../types";
export const SLOTS_PER_PAGE = 6;
export const MAX_PAGES = 8;
export const MAX_SLOTS = MAX_PAGES * SLOTS_PER_PAGE;
export type Entry = { tile: Tile; slot: number };
export const isWide = (tile: Tile) => tile.options?.size === "wide";
export const cellsOf = (slot: number, wide: boolean) => (wide ? [slot, slot + 1] : [slot]);
export const rowStart = (slot: number) => slot - (slot % 2);
export const pageOf = (slot: number) => Math.floor(slot / SLOTS_PER_PAGE);
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 slot = position;
position += wide ? 2 : 1;
return slot;
});
}
export function hasGaps(tiles: Tile[]) {
const packed = packSlots(tiles);
return tiles.some((tile, i) => tile.slot !== packed[i]);
}
// Every tile gets a position (older layouts pack in order) and the list stays in reading order.
export function normalize(layout: Layout) {
if (layout.tiles.some((t) => !Number.isInteger(t.slot))) {
const packed = packSlots(layout.tiles);
layout.tiles.forEach((t, i) => (t.slot = packed[i]));
}
layout.tiles.sort((a, b) => a.slot - b.slot);
}
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);
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;
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) {
let best = -1;
for (let slot = 0; slot < MAX_SLOTS; slot++)
if (fits(taken, slot, wide) && (best < 0 || Math.abs(slot - origin) <= Math.abs(best - origin))) best = slot;
return best;
}
// The arrangement after putting `moving` (a tile on the grid, or a new one) at `target`:
// it lands exactly there; tiles in its way take the cells it left (a swap) or else the
// nearest free cell; everything else stays put. Null when the target is off the grid.
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 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);
else result.push({ tile, slot: tile.slot });
}
for (const tile of displaced) {
const w = isWide(tile), taken = occupied(result);
let slot = vacated.map((c) => (w ? rowStart(c) : c)).find((c) => fits(taken, c, w));
if (slot === undefined) slot = nearestFree(taken, w, tile.slot);
if (slot < 0) return null;
result.push({ tile, slot });
}
return result.sort((a, b) => a.slot - b.slot);
}
// 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)));
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.
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 (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.
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;
return choice === "none" ? null : choice;
}
export function controlsLabel(tile: Tile, inventory: Inventory) {
const key = effectiveControls(tile, inventory);
if (!key) return "none";
return inventory.controls?.[tile.entity.split(".")[0]]?.choices.find((c) => c.key === key)?.label.toLocaleLowerCase() || key;
}
export const parseVersion = (v: string | undefined) => (/^(\d+)\.(\d+)\.(\d+)$/.exec(v || "") || []).slice(1).map(Number);
export function versionAtLeast(version: string | undefined, minimum: string) {
const [a, b] = [parseVersion(version), parseVersion(minimum)];
if (a.length !== 3 || b.length !== 3) return false;
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
return true;
}
export const supportsFirmware = (firmware: string | undefined, major: number, minor: number, patch: number) =>
versionAtLeast(firmware, `${major}.${minor}.${patch}`);
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;
}
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 TOGGLE_BEFORE = ["light", "switch", "input_boolean", "fan", "media_player", "climate"];
export const SLIDER_DOMAINS = ["light", "fan", "cover", "number", "input_number", "media_player"];
export const domains: Record<string, [string, string, string, string]> = {
light: ["Light", "☀", "#ad7600", "#fff3d3"],
climate: ["Climate", "❄", "#c86620", "#ffebdc"],
vacuum: ["Vacuum", "◉", "#008577", "#def3ed"],
fan: ["Fan", "✣", "#008aab", "#def5fa"],
cover: ["Cover", "▤", "#8053af", "#eee5f8"],
media_player: ["Media", "▶", "#007cad", "#def2fc"],
sensor: ["Sensor", "⌁", "#3476b1", "#e5effa"],
binary_sensor: ["Status", "◈", "#ad7600", "#fff3d3"],
switch: ["Switch", "⏻", "#ad7600", "#fff3d3"],
input_boolean: ["Switch", "⏻", "#ad7600", "#fff3d3"],
scene: ["Scene", "✦", "#8053af", "#eee5f8"],
script: ["Script", "▷", "#8053af", "#eee5f8"],
weather: ["Weather", "☁", "#007cad", "#def2fc"],
number: ["Value", "±", "#008577", "#def3ed"],
input_number: ["Value", "±", "#008577", "#def3ed"],
select: ["Select", "≡", "#5862af", "#eaecfa"],
input_select: ["Select", "≡", "#5862af", "#eaecfa"],
button: ["Action", "↗", "#5862af", "#eaecfa"],
input_button: ["Action", "↗", "#5862af", "#eaecfa"],
screen: ["Clock", "◷", "#25282c", "#e9ecf1"],
sun: ["Sun", "☼", "#c86620", "#ffebdc"],
timer: ["Timer", "⏱", "#008577", "#def3ed"],
person: ["Person", "☺", "#2f7d32", "#e1f2e2"],
camera: ["Camera", "◧", "#3d4a57", "#e6ebf0"],
image: ["Image", "◧", "#3d4a57", "#e6ebf0"],
};
export const domainInfo = (id: string) => domains[id.split(".")[0]] || (["Entity", "◇", "#637184", "#edf0f4"] as [string, string, string, string]);
+117
View File
@@ -0,0 +1,117 @@
// ---- The top bar as the screen draws it ----
// One rule set with header_bar.h in the firmware, in screen pixels of the board: every value (the
// time too) in the same 400 Roboto on the name's baseline; icons and the dial centred on the
// height of the digits; the gaps measured between what you see (glyph ink), not between boxes, so
// an icon with side bearings sits exactly as close to its value as one without.
import type { HeaderItem } from "../types";
export const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
export const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
export const BUILTIN_ICONS: Record<string, string> = { clock: "clock-outline", analog: "clock-outline", date: "calendar" };
export const itemKey = (item: HeaderItem) => JSON.stringify(item);
export const glyph = (cp: string) => String.fromCodePoint(parseInt(cp, 16));
export function clockText(clock24: boolean, now = new Date()) {
let hours = now.getHours();
// The screen formats with %I:%M when the 24-hour clock is off.
if (!clock24) hours = hours % 12 || 12;
return `${String(hours).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
}
export const dateText = (now = new Date()) => `${WEEKDAYS[now.getDay()]} ${now.getDate()} ${MONTHS[now.getMonth()]}`;
// Same wording and thresholds as header_bar::ago_text() in the firmware.
export function agoText(then: number, now = Math.floor(Date.now() / 1000)) {
const seconds = now - then, span = Math.abs(seconds), per = (unit: number) => Math.floor(span / unit);
if (seconds < 0) {
if (span < 3600) return `In ${Math.max(1, per(60))} min`;
if (span < 86400) return per(3600) === 1 ? "In 1 hour" : `In ${per(3600)} hours`;
if (span < 172800) return "Tomorrow";
return `In ${per(86400)} days`;
}
if (span < 60) return "Just now";
if (span < 3600) return `${per(60)} min ago`;
if (span < 86400) return per(3600) === 1 ? "1 hour ago" : `${per(3600)} hours ago`;
if (span < 172800) return "Yesterday";
if (span < 604800) return `${per(86400)} days ago`;
if (span < 2592000) return per(604800) === 1 ? "1 week ago" : `${per(604800)} weeks ago`;
if (span < 31536000) return per(2592000) === 1 ? "1 month ago" : `${per(2592000)} months ago`;
return per(31536000) === 1 ? "1 year ago" : `${per(31536000)} years ago`;
}
export type BarMetrics = { width: number; top: number; name: number; text: number; icon: number };
export const BAR_METRICS: Record<string, BarMetrics> = {
guition: { width: 448, top: 36, name: 27, text: 21, icon: 26 },
cyd: { width: 298, top: 24, name: 18, text: 14, icon: 18 },
};
export type ItemView = { icon?: string | null; text?: string; color?: string | null; shown: boolean; analog?: boolean; loading?: boolean };
type Ink = { left: number; right: number; top: number; bottom: number; advance: number };
const measure = document.createElement("canvas").getContext("2d")!;
const inkCache = new Map<string, Ink>();
export const clearInkCache = () => inkCache.clear();
// Ink box of a string relative to its origin on the baseline: left/right, and top (negative, up)/bottom.
export function inkOf(text: string, font: string): Ink {
const key = `${font}|${text}`;
let ink = inkCache.get(key);
if (!ink) {
measure.font = font;
const m = measure.measureText(text);
ink = { left: -m.actualBoundingBoxLeft, right: m.actualBoundingBoxRight, top: -m.actualBoundingBoxAscent, bottom: m.actualBoundingBoxDescent, advance: m.width };
inkCache.set(key, ink);
}
return ink;
}
export const barFonts = (m: BarMetrics) => ({ name: `500 ${m.name}px "Bar Roboto"`, text: `400 ${m.text}px "Bar Roboto"`, icon: `${m.icon}px "Tile Icons"` });
// Same integer arithmetic as header_bar::gaps() in the firmware, from the digit height in pixels.
export function barGaps(cap: number) {
return { icon: Math.max(2, Math.floor((cap * 4 + 5) / 10)), item: Math.max(6, Math.floor((cap * 125 + 50) / 100)), name: Math.max(8, Math.floor((cap * 16 + 5) / 10)) };
}
export type BarPart = {
index: number; item: HeaderItem; view: ItemView; shown: boolean; width: number; x?: number;
dial?: number; icon?: { glyph: string; ink: Ink }; text?: { value: string; ink: Ink };
};
export type BarLayout = ReturnType<typeof barLayout>;
// The parts per item with their ink widths, the placement, and which items fall off.
export function barLayout(items: HeaderItem[], metrics: BarMetrics, nameText: string, viewOf: (item: HeaderItem) => ItemView) {
const fonts = barFonts(metrics);
// The firmware reads the digit height as a whole number of pixels (the glyph box of "0").
const zero = inkOf("0", fonts.text), cap = Math.round(zero.bottom - zero.top), gaps = barGaps(cap);
const dialInk = inkOf(String.fromCodePoint(0xf0150), fonts.icon), dial = Math.round(dialInk.bottom - dialInk.top);
const parts: BarPart[] = items.map((item, index) => {
const view = viewOf(item);
const part: BarPart = { index, item, view, shown: view.shown, width: 0 };
if (view.analog) { part.dial = dial; part.width = dial; return part; }
if (view.icon) { part.icon = { glyph: glyph(view.icon), ink: inkOf(glyph(view.icon), fonts.icon) }; part.width += part.icon.ink.right - part.icon.ink.left; }
if (view.text) {
part.text = { value: view.text, ink: inkOf(view.text, fonts.text) };
part.width += (part.icon ? gaps.icon : 0) + part.text.ink.right - part.text.ink.left;
}
return part;
});
const shown = parts.filter((p) => p.shown);
const natural = inkOf(nameText, fonts.name).advance;
const minName = Math.min(natural, Math.floor((metrics.width * 35) / 100));
const total = (list: BarPart[]) => list.reduce((sum, p) => sum + p.width, 0) + Math.max(0, list.length - 1) * gaps.item;
let first = 0;
while (first < shown.length && total(shown.slice(first)) + gaps.name + minName > metrics.width) first++;
const placed = shown.slice(first), dropped = new Set(shown.slice(0, first).map((p) => p.index));
let x = metrics.width - total(placed);
for (const p of placed) { p.x = x; x += p.width + gaps.item; }
const nameRoom = placed.length ? placed[0].x! - gaps.name : metrics.width;
return { metrics, fonts, cap, zero, gaps, parts, placed, dropped, nameText, natural, nameRoom };
}
// LVGL's LV_LABEL_LONG_DOT: the longest start that fits with "..." after it.
export function dotted(text: string, font: string, room: number) {
if (inkOf(text, font).advance <= room) return text;
const chars = [...text];
while (chars.length && inkOf(chars.join("") + "...", font).advance > room) chars.pop();
return chars.join("") + "...";
}
// The fonts load on first use; measurements before that are wrong, so draw again once they are in.
export function whenBarFontsLoad(then: () => void) {
if (!("fonts" in document)) return;
Promise.all([
document.fonts.load('500 27px "Bar Roboto"', "Studio 0"),
document.fonts.load('400 21px "Bar Roboto"', "Away 0"),
document.fonts.load('26px "Tile Icons"', String.fromCodePoint(0xf0150)),
]).then(() => { clearInkCache(); then(); }).catch(() => {});
}