// SPDX-License-Identifier: AGPL-3.0-or-later /** * HomeModel : the homescreen's arrangement. * * Placement is flow order, not coordinates. Each section owns an ordered list * and the grid packs it in order, so a move is an index change. * */ /** Sizes an item may take, as [columns, rows] against a 4-column grid. */ export const SIZES = [ [1, 1], [2, 1], [2, 2], [4, 1], [4, 2], ]; /** * A live item is one big enough to be worth rendering as a page. 1x1 is an * icon; everything else is the page itself. This is the single rule the whole * presentation model rests on, so it lives in one function. */ export function isLive(item) { return !(item.w === 1 && item.h === 1); } /** * The widget name in a `builtin:` URL, or null when this is an ordinary URL. */ export function builtinName(item) { return item.url?.startsWith("builtin:") ? item.url.slice(8) : null; } /** * The URL an item opens, which is not always the URL it shows. */ export function openUrl(item) { return item?.open || item?.url; } /** * Whether this item can be opened as a view. */ export function isOpenable(item) { return builtinName({ url: openUrl(item) }) === null; } /** * Whether a builtin name can be resolved to a module and an element. */ export function isBuiltinName(name) { return typeof name === "string" && /^[a-z][a-z0-9-]*$/.test(name); } function clampSize(w, h) { const match = SIZES.find(([sw, sh]) => sw === w && sh === h); return match ?? SIZES[0]; } let idCounter = 0; /* Unique within a run, which is all an id needs to be: it keys DOM nodes and addresses items in mutations. The timestamp prefix keeps ids from colliding with a restored snapshot's, since the counter restarts at zero on load. */ function makeId(prefix) { idCounter += 1; return `${prefix}${Date.now().toString(36)}${idCounter.toString(36)}`; } export function makeItem({ url, title = "", w = 1, h = 1, icon = null, open = null, }) { const [cw, ch] = clampSize(w, h); return { id: makeId("i"), url, title, w: cw, h: ch, icon, open }; } export function makeSection({ name = "", collapsed = false, items = [] } = {}) { return { id: makeId("s"), name, collapsed, items }; } /* A snapshot is only usable if the parts we index into are the right shape. Anything malformed is dropped rather than repaired: a homescreen that silently loses one bad item is better than one that throws on render, and the caller falls back to the default layout when nothing survives. */ function sanitizeItem(raw) { if (!raw || typeof raw.url !== "string" || !raw.url) { return null; } const [w, h] = clampSize(raw.w, raw.h); return { id: typeof raw.id === "string" && raw.id ? raw.id : makeId("i"), url: raw.url, title: typeof raw.title === "string" ? raw.title : "", w, h, icon: typeof raw.icon === "string" ? raw.icon : null, // Optional: see openUrl(). Absent means "opens what it shows". open: typeof raw.open === "string" ? raw.open : null, }; } function sanitizeSection(raw) { if (!raw || !Array.isArray(raw.items)) { return null; } return { id: typeof raw.id === "string" && raw.id ? raw.id : makeId("s"), name: typeof raw.name === "string" ? raw.name : "", collapsed: raw.collapsed === true, items: raw.items.map(sanitizeItem).filter(Boolean), }; } export class HomeModel { /** * @param {object} [snapshot] Shape as produced by {@link toSnapshot}. Missing * or malformed parts fall back to empty rather than throwing. */ constructor(snapshot = {}) { this.sections = Array.isArray(snapshot.sections) ? snapshot.sections.map(sanitizeSection).filter(Boolean) : []; this.prefs = { ...(snapshot.prefs ?? {}) }; } /** True when there is nothing to render, so the caller can seed a default. */ get isEmpty() { return this.sections.every((section) => section.items.length === 0); } toSnapshot() { return { sections: this.sections, prefs: this.prefs }; } section(sectionId) { return this.sections.find((s) => s.id === sectionId) ?? null; } /** The ordered list behind a section id, or null if there is no such list. */ list(sectionId) { return this.section(sectionId)?.items ?? null; } /** Locate an item without the caller having to know which section holds it. */ find(itemId) { for (const section of this.sections) { const at = section.items.findIndex((i) => i.id === itemId); if (at !== -1) { return { item: section.items[at], containerId: section.id, index: at }; } } return null; } addItem(sectionId, item, index = -1) { const list = this.list(sectionId); if (!list) { return null; } if (index < 0 || index >= list.length) { list.push(item); } else { list.splice(index, 0, item); } return item; } removeItem(itemId) { const found = this.find(itemId); if (!found) { return null; } this.list(found.containerId).splice(found.index, 1); return found.item; } /** * Move an item into `containerId` at `index`, across containers or within * one. Returns false when the item or target does not exist. * * `index` is an insertion gap in the list as the caller currently sees it, * not the item's final position: in [a, b, c], gap 2 is "between b and c", * so moving a there gives [b, a, c]. That is what a drop between two tiles * means, and it is why a same-list forward move compensates for its own * removal here rather than in every caller. Gap `list.length` (or -1) is the * end. */ moveItem(itemId, sectionId, index = -1) { const found = this.find(itemId); const target = this.list(sectionId); if (!found || !target) { return false; } this.list(found.containerId).splice(found.index, 1); let at = index; if (found.containerId === sectionId && at > found.index) { at -= 1; } this.addItem(sectionId, found.item, at); return true; } /** Resize to the next size up, wrapping back to 1x1. */ cycleSize(itemId, direction = 1) { const found = this.find(itemId); if (!found) { return null; } const { item } = found; const current = SIZES.findIndex(([w, h]) => w === item.w && h === item.h); const next = (current + direction + SIZES.length) % SIZES.length; [item.w, item.h] = SIZES[next]; return item; } setSize(itemId, w, h) { const found = this.find(itemId); if (!found) { return null; } [found.item.w, found.item.h] = clampSize(w, h); return found.item; } addSection(section, index = -1) { if (index < 0 || index >= this.sections.length) { this.sections.push(section); } else { this.sections.splice(index, 0, section); } return section; } /** Removing a section takes its items with it; the caller confirms first. */ removeSection(sectionId) { const index = this.sections.findIndex((s) => s.id === sectionId); if (index === -1) { return null; } return this.sections.splice(index, 1)[0]; } renameSection(sectionId, name) { const section = this.section(sectionId); if (section) { section.name = name; } return section; } toggleCollapsed(sectionId, collapsed) { const section = this.section(sectionId); if (!section) { return null; } section.collapsed = collapsed ?? !section.collapsed; return section; } /** Reorder a section. `index` is an insertion gap, as in moveItem. */ moveSection(sectionId, index) { const from = this.sections.findIndex((s) => s.id === sectionId); if (from === -1) { return false; } const [section] = this.sections.splice(from, 1); let at = index; if (at > from) { at -= 1; } if (at < 0 || at >= this.sections.length) { this.sections.push(section); } else { this.sections.splice(at, 0, section); } return true; } }