// Placement math for the monitor widget: which section sits in which grid cell, // in what order, and how tall its row is. Pure functions over a `Layout` — no // React, no DOM — so the floorplan board, the in-widget menu and the URL parser // all move sections the same way, and the tricky part (normalizing an inherited // layout against the visibility flags) is testable on its own. See // ./placement.test.ts. // // THE MODEL IS ROWS OF CELLS, each cell a vertical stack of sections. It // replaces the old `SectionId[][]` columns, which had no rows, no spans and no // height control — so a section could never span the full width, four one-line // totals could never share a rail, and nothing could be given the leftover // height to scroll inside. Rendered as CSS Grid: `cols` tracks wide, one grid // row per Row, each cell placed with a col-span. // // URL shape. `l=` is still an INPUT format and is still what gets emitted // whenever a layout is expressible as plain columns, so every widget link copied // before rows existed keeps rendering identically: // // /widget?l=ctl.disk.wk.jobs-cln.act (one auto row, N cells, span 1) // // Anything richer serializes to `g=`: // // rows separated by _ // cells separated by - // sections separated by . // modifiers after * digits = span, r = flow row, f = row fills // // /widget?g=ctl*2_lsync.disk.cln.bf*2r_sched*f-act.clnl // // `_ - . *` are the only separators URLSearchParams leaves literal (`~` and `!` // come back percent-encoded), which is the same reason the original picked `-` // and `.`. Row height rides on a cell modifier rather than a second param: a row // is `fill` if ANY of its cells carries `f`, and serialization writes `f` on the // row's first cell. import { DEFAULT_ORDER, SECTION_BY_ID, enabledSections, sectionByCode, type SectionFlags, type SectionId, } from "./sections"; export const ROW_SEP = "_"; export const CELL_SEP = "-"; export const SECTION_SEP = "."; export const MOD_SEP = "*"; // The old name for CELL_SEP, from when a cell was a whole column. export const COLUMN_SEP = CELL_SEP; // Six is where Tailwind's static `grid-cols-N` / `col-span-N` literals stop // being worth generating, and well past the point where a 320px widget has // anything useful to show. A hand-written layout with more is merged down rather // than rejected. export const MAX_COLUMNS = 6; export const MAX_ROWS = 6; // A row is `auto` (its content's height, the pre-rows behaviour) or `fill` (it // shares the leftover height of the widget's frame). One fill row anywhere is // what turns the widget from content-height into frame-height. export type RowSize = "auto" | "fill"; // How a cell arranges the sections inside it: a vertical stack, or one dense // horizontal rail of totals. export type CellFlow = "col" | "row"; export type Cell = { sections: SectionId[]; span: number; flow: CellFlow }; export type Row = { size: RowSize; cells: Cell[] }; export type Layout = { cols: number; rows: Row[] }; export type CellPos = { row: number; cell: number }; export type SectionPos = { row: number; cell: number; index: number }; function clampInt(n: number, lo: number, hi: number): number { const v = Math.round(Number.isFinite(n) ? n : lo); return Math.max(lo, Math.min(hi, v)); } function cloneCell(c: Cell): Cell { return { sections: c.sections.slice(), span: c.span, flow: c.flow }; } function cloneLayout(l: Layout): Layout { return { cols: l.cols, rows: l.rows.map((r) => ({ size: r.size, cells: r.cells.map(cloneCell) })), }; } export function emptyCell(span = 1): Cell { return { sections: [], span, flow: "col" }; } // INVARIANT: every row's cell spans sum to exactly `cols`, and no row has more // cells than there are columns. Keeping it exact is what makes `cols` derivable // from a `g=` string (no separate param) and what keeps the grid gapless. // // `keep` is the one cell whose span the caller just set on purpose; everything // else absorbs the difference, shrinking from the tail first so the cell you // dragged is the one that wins. function fitRow(cells: Cell[], cols: number, keep = -1): Cell[] { const next = cells.map(cloneCell); if (next.length === 0) return [emptyCell(cols)]; // Too many cells for the track count: merge the tail rather than drop it. while (next.length > cols) { const last = next.pop() as Cell; next[next.length - 1].sections.push(...last.sections); } for (const c of next) c.span = clampInt(c.span, 1, cols); const n = next.length; if (keep >= 0 && keep < n) { // Leave every other cell at least one track. next[keep].span = clampInt(next[keep].span, 1, cols - (n - 1)); } let sum = next.reduce((s, c) => s + c.span, 0); for (let i = n - 1; sum > cols && i >= 0; i--) { if (i === keep) continue; const take = Math.min(next[i].span - 1, sum - cols); next[i].span -= take; sum -= take; } // Only reachable for a malformed `keep`; clamp rather than emit a bad grid. if (sum > cols && keep >= 0 && keep < n) { next[keep].span -= sum - cols; sum = cols; } if (sum < cols) { let idx = n - 1; if (idx === keep && n > 1) idx = n - 2; next[idx].span += cols - sum; } return next; } // Bring a whole layout back to the invariants: 1..MAX_COLUMNS tracks, // 1..MAX_ROWS rows (extras merged into the last, never dropped), spans summing // to `cols` per row. export function normalizeLayout(layout: Layout): Layout { const cols = clampInt(layout.cols, 1, MAX_COLUMNS); let rows = layout.rows.map((r) => ({ size: r.size === "fill" ? ("fill" as const) : ("auto" as const), cells: r.cells.map(cloneCell), })); if (rows.length === 0) rows = [{ size: "auto", cells: [emptyCell(cols)] }]; if (rows.length > MAX_ROWS) { const keep = rows.slice(0, MAX_ROWS); const last = keep[MAX_ROWS - 1]; for (const extra of rows.slice(MAX_ROWS)) { for (const cell of extra.cells) { last.cells[last.cells.length - 1].sections.push(...cell.sections); } } rows = keep; } return { cols, rows: rows.map((r) => ({ ...r, cells: fitRow(r.cells, cols) })) }; } // One column holding every enabled section in the widget's original render // order — the layout a link with no `l=`/`g=` gets, and the baseline // serializeLayout() compares against so an all-default config stays queryless. export function defaultLayout(config: SectionFlags): Layout { return { cols: 1, rows: [ { size: "auto", cells: [{ sections: enabledSections(config), span: 1, flow: "col" }], }, ], }; } export function sameLayout(a: Layout, b: Layout): boolean { if (a.cols !== b.cols || a.rows.length !== b.rows.length) return false; return a.rows.every((row, r) => { const other = b.rows[r]; if (row.size !== other.size || row.cells.length !== other.cells.length) { return false; } return row.cells.every((cell, c) => { const oc = other.cells[c]; return ( cell.span === oc.span && cell.flow === oc.flow && cell.sections.length === oc.sections.length && cell.sections.every((id, i) => id === oc.sections[i]) ); }); }); } // Bring a layout back in line with the visibility flags: drop sections that are // now off, and append ones that are on but unplaced to the LAST CELL OF THE LAST // ROW, in DEFAULT_ORDER. The append rule is what keeps a baked link working when // a new section is enabled from the in-widget menu (or added to the registry in // a later release) — it shows up rather than silently vanishing. export function reconcileLayout(layout: Layout, config: SectionFlags): Layout { const on = new Set(enabledSections(config)); const seen = new Set(); const next = normalizeLayout(layout); for (const row of next.rows) { for (const cell of row.cells) { cell.sections = cell.sections.filter((id) => { if (!on.has(id) || seen.has(id)) return false; seen.add(id); return true; }); } } const missing = DEFAULT_ORDER.filter((id) => on.has(id) && !seen.has(id)); if (missing.length > 0) { const lastRow = next.rows[next.rows.length - 1]; lastRow.cells[lastRow.cells.length - 1].sections.push(...missing); } return next; } // --------------------------------------------------------------------------- // Decode // --------------------------------------------------------------------------- type ParsedCell = { cell: Cell; fill: boolean }; // "lsync.disk.cln.bf*2r" → the cell it describes plus whether it marked its row // as filling. Unknown modifier letters and out-of-range spans are dropped or // clamped, the same tolerance parseLayoutParam has always had for a hand-edited // link. function parseCell(raw: string, seen: Set): ParsedCell { const star = raw.indexOf(MOD_SEP); const body = star === -1 ? raw : raw.slice(0, star); const mods = star === -1 ? "" : raw.slice(star + 1); const sections: SectionId[] = []; for (const code of body.split(SECTION_SEP)) { const def = sectionByCode(code.trim()); if (!def || seen.has(def.id)) continue; seen.add(def.id); sections.push(def.id); } const digits = mods.replace(/[^0-9]/g, ""); const span = digits ? clampInt(Number(digits), 1, MAX_COLUMNS) : 1; return { cell: { sections, span, flow: mods.includes("r") ? "row" : "col" }, fill: mods.includes("f"), }; } function parseGrid(raw: string): Layout { const seen = new Set(); const rows: Row[] = []; for (const rowRaw of raw.split(ROW_SEP)) { const parsed = rowRaw.split(CELL_SEP).map((c) => parseCell(c, seen)); rows.push({ size: parsed.some((p) => p.fill) ? "fill" : "auto", cells: parsed.map((p) => p.cell), }); } // `cols` is derived, not carried: with spans summing to the track count per // row (the invariant fitRow enforces), the widest row IS the column count. const cols = rows.reduce( (m, r) => Math.max(m, r.cells.reduce((s, c) => s + c.span, 0)), 1, ); return normalizeLayout({ cols, rows }); } function parseColumns(raw: string): Layout { const seen = new Set(); const cells = raw .split(CELL_SEP) .map((c) => parseCell(c.split(MOD_SEP)[0], seen).cell); return normalizeLayout({ cols: cells.length, rows: [{ size: "auto", cells }] }); } function isEmptyLayout(layout: Layout): boolean { return layout.rows.every((r) => r.cells.every((c) => c.sections.length === 0)); } // Decode + normalize the layout params. `g` wins when present; `l` decodes into // a single auto row of plain stacked cells, which is exactly what it always // meant. Unknown codes and repeats are dropped (first placement wins), disabled // sections are dropped, and anything enabled but unlisted is appended by // reconcileLayout. A value that survives none of that falls back to the default // single column rather than rendering nothing. export function parseLayoutParam( l: string | undefined, g: string | undefined, config: SectionFlags, ): Layout { const raw = g || l; if (!raw) return defaultLayout(config); const layout = g ? parseGrid(g) : parseColumns(l as string); if (isEmptyLayout(layout)) return defaultLayout(config); return reconcileLayout(layout, config); } // --------------------------------------------------------------------------- // Encode // --------------------------------------------------------------------------- // True when the layout says nothing a plain `l=` can't: one auto row of // single-track stacked cells. This is what keeps every pre-rows link // round-tripping to the identical string. export function isColumnsLayout(layout: Layout): boolean { return ( layout.rows.length === 1 && layout.rows[0].size === "auto" && layout.rows[0].cells.every((c) => c.span === 1 && c.flow === "col") ); } function cellCode(cell: Cell, fill: boolean): string { const body = cell.sections.map((id) => SECTION_BY_ID[id].code).join(SECTION_SEP); let mods = ""; if (cell.span > 1) mods += String(cell.span); if (cell.flow === "row") mods += "r"; if (fill) mods += "f"; return mods ? `${body}${MOD_SEP}${mods}` : body; } // `{}` when the layout is what a link with no params would already produce, so // an all-default config still serializes to a bare /widget. Returns `l` OR `g`, // never both — see buildWidgetQuery. export function serializeLayout( layout: Layout, config: SectionFlags, ): { l?: string; g?: string } { if (sameLayout(layout, defaultLayout(config))) return {}; if (isColumnsLayout(layout)) { return { l: layout.rows[0].cells .map((c) => c.sections.map((id) => SECTION_BY_ID[id].code).join(SECTION_SEP)) .join(CELL_SEP), }; } return { g: layout.rows .map((row) => row.cells .map((cell, i) => cellCode(cell, row.size === "fill" && i === 0)) .join(CELL_SEP), ) .join(ROW_SEP), }; } // --------------------------------------------------------------------------- // Movement // --------------------------------------------------------------------------- function cellAt(layout: Layout, row: number, cell: number): Cell | null { return layout.rows[row]?.cells[cell] ?? null; } export function findSection(layout: Layout, id: SectionId): SectionPos | null { for (let row = 0; row < layout.rows.length; row++) { const cells = layout.rows[row].cells; for (let cell = 0; cell < cells.length; cell++) { const index = cells[cell].sections.indexOf(id); if (index !== -1) return { row, cell, index }; } } return null; } // Swap with the neighbour above/below inside one cell. Same idiom as the // reorder in SocialLinksField. export function moveWithin( layout: Layout, row: number, cell: number, idx: number, dir: -1 | 1, ): Layout { const next = cloneLayout(layout); const target = cellAt(next, row, cell); if (!target) return layout; const j = idx + dir; if (j < 0 || j >= target.sections.length) return layout; const s = target.sections; [s[idx], s[j]] = [s[j], s[idx]]; return next; } // Move a section to the adjacent cell in the same row, landing at the same // height where that cell is deep enough and at the end where it isn't. export function moveToCell( layout: Layout, row: number, cell: number, idx: number, dir: -1 | 1, ): Layout { const to = cell + dir; const from = cellAt(layout, row, cell); if (!from || to < 0 || to >= layout.rows[row].cells.length) return layout; const next = cloneLayout(layout); const [id] = next.rows[row].cells[cell].sections.splice(idx, 1); if (id === undefined) return layout; const dest = next.rows[row].cells[to].sections; dest.splice(Math.min(idx, dest.length), 0, id); return next; } // Move a section to the adjacent row, into the cell at the same horizontal // position (or the last one, where that row is narrower). export function moveToRow( layout: Layout, row: number, cell: number, idx: number, dir: -1 | 1, ): Layout { const to = row + dir; if (to < 0 || to >= layout.rows.length) return layout; const next = cloneLayout(layout); const [id] = next.rows[row].cells[cell]?.sections.splice(idx, 1) ?? []; if (id === undefined) return layout; const destCells = next.rows[to].cells; const destCell = destCells[Math.min(cell, destCells.length - 1)]; destCell.sections.push(id); return next; } // Place a section at an explicit slot, pulling it out of wherever it currently // sits first. `index` is read against the cell as the user sees it, so a move // down within one cell has to account for the removal shifting things up. export function insertAt( layout: Layout, id: SectionId, row: number, cell: number, index: number, ): Layout { const next = cloneLayout(layout); const target = cellAt(next, row, cell); if (!target) return layout; let at = index; const found = findSection(next, id); if (found) { next.rows[found.row].cells[found.cell].sections.splice(found.index, 1); if (found.row === row && found.cell === cell && found.index < index) at -= 1; } target.sections.splice(Math.max(0, Math.min(at, target.sections.length)), 0, id); return next; } export function removeSection(layout: Layout, id: SectionId): Layout { const next = cloneLayout(layout); for (const row of next.rows) { for (const cell of row.cells) { cell.sections = cell.sections.filter((x) => x !== id); } } return next; } // --------------------------------------------------------------------------- // Structure // --------------------------------------------------------------------------- // Grow by appending an empty cell per row; shrink by merging every trailing cell // into the last one that survives, so nothing is ever silently dropped. export function setColumnCount(layout: Layout, n: number): Layout { const cols = clampInt(n, 1, MAX_COLUMNS); if (cols === layout.cols) return layout; const next = cloneLayout(layout); next.cols = cols; if (cols > layout.cols) { for (const row of next.rows) { for (let i = layout.cols; i < cols; i++) row.cells.push(emptyCell(1)); } } return normalizeLayout(next); } // Grow by appending a row of empty single-track cells — one per column, so a new // row is a place to drop things rather than one undivided band; shrink by // merging every trailing row's sections into the last surviving row's last cell. export function setRowCount(layout: Layout, n: number): Layout { const count = clampInt(n, 1, MAX_ROWS); if (count === layout.rows.length) return layout; const next = cloneLayout(layout); if (count > next.rows.length) { while (next.rows.length < count) { next.rows.push({ size: "auto", cells: Array.from({ length: next.cols }, () => emptyCell(1)), }); } return normalizeLayout(next); } const keep = next.rows.slice(0, count); const last = keep[count - 1]; for (const row of next.rows.slice(count)) { for (const cell of row.cells) { last.cells[last.cells.length - 1].sections.push(...cell.sections); } } next.rows = keep; return normalizeLayout(next); } export function setRowSize(layout: Layout, row: number, size: RowSize): Layout { if (!layout.rows[row] || layout.rows[row].size === size) return layout; const next = cloneLayout(layout); next.rows[row].size = size; return next; } // Widen/narrow one cell. The row keeps summing to the track count, so the // neighbours give up (or take back) what this one gains — the cell you dragged // is the one that wins. // // A neighbour squeezed to nothing is REMOVED and its sections merged into the // cell that took its tracks, rather than the widen being refused: "make this // span the whole row" is the commonest thing anyone asks a grid for, and // refusing it because some other cell still holds a track would be the wrong // answer. Narrowing the last cell in a row is the exact inverse — the freed // tracks become a new empty cell you can drop into. function applySpan(cells: Cell[], cols: number, keep: number, want: number): Cell[] { const next = cells.map(cloneCell); next[keep].span = clampInt(want, 1, cols); let sum = next.reduce((s, c) => s + c.span, 0); // Nearest first: the cell you are growing into is the one beside you. const order: number[] = []; for (let i = keep + 1; i < next.length; i++) order.push(i); for (let i = keep - 1; i >= 0; i--) order.push(i); for (const i of order) { if (sum <= cols) break; const take = Math.min(next[i].span, sum - cols); next[i].span -= take; sum -= take; } const merged: Cell[] = []; for (let i = 0; i < next.length; i++) { if (i !== keep && next[i].span <= 0) { next[keep].sections.push(...next[i].sections); continue; } merged.push(next[i]); } sum = merged.reduce((s, c) => s + c.span, 0); if (sum < cols) { const k = merged.indexOf(next[keep]); let idx = merged.length - 1; if (idx === k) idx = merged.length - 2; if (idx < 0) merged.push(emptyCell(cols - sum)); else merged[idx].span += cols - sum; } return merged; } export function setCellSpan( layout: Layout, row: number, cell: number, span: number, ): Layout { const target = cellAt(layout, row, cell); if (!target) return layout; const next = cloneLayout(layout); next.rows[row].cells = applySpan(next.rows[row].cells, next.cols, cell, span); return sameLayout(next, layout) ? layout : next; } export function setCellFlow( layout: Layout, row: number, cell: number, flow: CellFlow, ): Layout { const target = cellAt(layout, row, cell); if (!target || target.flow === flow) return layout; const next = cloneLayout(layout); next.rows[row].cells[cell].flow = flow; return next; } export function addCell(layout: Layout, row: number): Layout { const target = layout.rows[row]; if (!target || target.cells.length >= layout.cols) return layout; const next = cloneLayout(layout); next.rows[row].cells.push(emptyCell(1)); next.rows[row].cells = fitRow(next.rows[row].cells, next.cols); return next; } // Remove a cell, merging its sections into its neighbour rather than dropping // them. The last cell in a row can't be removed — a row always has one. export function removeCell(layout: Layout, row: number, cell: number): Layout { const target = layout.rows[row]; if (!target || target.cells.length <= 1 || !target.cells[cell]) return layout; const next = cloneLayout(layout); const [gone] = next.rows[row].cells.splice(cell, 1); const into = next.rows[row].cells[Math.max(0, cell - 1)]; into.sections.push(...gone.sections); next.rows[row].cells = fitRow(next.rows[row].cells, next.cols); return next; } // Whether any row asks for the leftover height — the one thing that changes the // widget from content-height to frame-height. export function hasFillRow(layout: Layout): boolean { return layout.rows.some((r) => r.size === "fill"); }