107 lines
4.7 KiB
JavaScript
107 lines
4.7 KiB
JavaScript
// Pure decisions behind js/shared/widget_card.mjs: which rows a card shows,
|
||
// how tall it is, and how a number scrubs. No DOM, no app import, so
|
||
// tests/widget_card.test.mjs runs it under node:test.
|
||
export const ROW_HEIGHT = 26;
|
||
export const ROW_GAP = 5;
|
||
export const SECTION_HEIGHT = 17;
|
||
export const GROUP_HEIGHT = 22;
|
||
export const CARD_PADDING = 7;
|
||
// Every scrub field in a card reserves this much for its unit ("px", "MP",
|
||
// "×") whether it has one or not, so the numbers share one centre line.
|
||
export const UNIT_SLOT_WIDTH = 22;
|
||
// A widget input socket is drawn at the widget's y plus half a slot.
|
||
export const SLOT_OFFSET = 10;
|
||
// A segmented pill only reads when every label fits its slot.
|
||
const SEGMENT_MAX_OPTIONS = 4;
|
||
const SEGMENT_MAX_CHARS = 26;
|
||
|
||
// Which rows the current values leave visible. `groups` maps a group name to
|
||
// its open state; a row inside a closed group is out, a row whose `when`
|
||
// says no is out, a group header stays as long as any row under it exists.
|
||
export function visibleRows(rows, values, groups = {}) {
|
||
return rows.filter((row) => {
|
||
if (row.group !== undefined && row.widget === undefined && row.pair === undefined && row.section === undefined) {
|
||
return true; // the disclosure header itself
|
||
}
|
||
if (row.when && row.when(values) === false) return false;
|
||
if (row.inGroup && !groups[row.inGroup]) return false;
|
||
return true;
|
||
});
|
||
}
|
||
|
||
// One row's height: a caption, a group header, a control row, or the
|
||
// height a textarea row asks for.
|
||
export function rowHeight(row) {
|
||
if (row.section !== undefined) return SECTION_HEIGHT;
|
||
if (row.widget === undefined && row.pair === undefined) return GROUP_HEIGHT;
|
||
return Number(row.height) > 0 ? Number(row.height) : ROW_HEIGHT;
|
||
}
|
||
|
||
// Card height for a list of visible rows: rows, section captions and group
|
||
// headers each add their own height plus the gap between them.
|
||
export function cardHeight(rows) {
|
||
if (!rows.length) return CARD_PADDING * 2;
|
||
return CARD_PADDING * 2 + rows.reduce((sum, row) => sum + rowHeight(row), 0) + ROW_GAP * (rows.length - 1);
|
||
}
|
||
|
||
// Where each visible row starts, measured from the card's top edge.
|
||
export function rowTops(rows) {
|
||
let y = CARD_PADDING;
|
||
return rows.map((row) => {
|
||
const top = y;
|
||
y += rowHeight(row) + ROW_GAP;
|
||
return top;
|
||
});
|
||
}
|
||
|
||
// The y a hidden widget reports so its input socket sits level with the
|
||
// card row standing in for it: the socket is drawn SLOT_OFFSET below the
|
||
// widget's y, and it belongs on the row's centre line - or, for a tall
|
||
// textarea row, just under its top edge, where the frontend puts it too.
|
||
export function socketWidgetY(cardY, insetTop, rowTop, height) {
|
||
return cardY + insetTop + rowTop + Math.min(height / 2, 13) - SLOT_OFFSET;
|
||
}
|
||
|
||
// A combo is a segmented pill when the labels are few and short enough to
|
||
// sit side by side; otherwise it is a select.
|
||
export function segmentFits(labels) {
|
||
if (!Array.isArray(labels) || labels.length < 2 || labels.length > SEGMENT_MAX_OPTIONS) return false;
|
||
return labels.reduce((sum, label) => sum + String(label).length, 0) <= SEGMENT_MAX_CHARS;
|
||
}
|
||
|
||
// Scrub steps from a widget's options: the fine step is the widget's own
|
||
// increment; the coarse step covers ground - a 0..1 float moves by 0.05, a
|
||
// wide float by ten increments, a wide integer by 8.
|
||
export function scrubSteps(options = {}, isFloat = true) {
|
||
const fine = Number(options.step2 ?? (options.step ? options.step / 10 : null) ?? (isFloat ? 0.01 : 1)) || (isFloat ? 0.01 : 1);
|
||
const range = Number(options.max ?? Infinity) - Number(options.min ?? -Infinity);
|
||
let step;
|
||
if (!isFloat) step = Number.isFinite(range) && range > 2048 ? 8 : Math.max(1, fine);
|
||
else if (Number.isFinite(range) && range <= 2) step = Math.max(fine, 0.05);
|
||
else step = fine * 10;
|
||
const decimals = isFloat ? Number(options.precision ?? Math.max(0, -Math.floor(Math.log10(fine)))) : 0;
|
||
return { step, fineStep: fine, decimals };
|
||
}
|
||
|
||
// Row kind from the widget the row mirrors, unless the config names one.
|
||
export function rowKind(row, widget) {
|
||
if (row.kind) return row.kind;
|
||
const type = String(widget?.type ?? "");
|
||
if (type === "number" || type === "slider") return "scrub";
|
||
if (type === "combo") return segmentFits(comboLabels(row, widget)) ? "segment" : "select";
|
||
if (type === "toggle") return "switch";
|
||
if (type === "customtext") return "skip";
|
||
if (/color/i.test(String(row.widget ?? "")) && /^#[0-9a-f]{3,8}$/i.test(String(widget?.value ?? ""))) return "color";
|
||
return "text";
|
||
}
|
||
|
||
export function comboValues(widget) {
|
||
let values = widget?.options?.values;
|
||
if (typeof values === "function") values = values(widget);
|
||
return Array.isArray(values) ? values : [];
|
||
}
|
||
|
||
function comboLabels(row, widget) {
|
||
return comboValues(widget).map((value) => row.labels?.[value] ?? String(value));
|
||
}
|