221 lines
10 KiB
JavaScript
221 lines
10 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;
|
||
});
|
||
}
|
||
|
||
// Does the current value set mute this row? A muted row keeps its place,
|
||
// height and socket, but dims and stops taking input (a setting the current
|
||
// mode does not read).
|
||
export function rowMuted(row, values) {
|
||
return typeof row?.mute === "function" && row.mute(values) === true;
|
||
}
|
||
|
||
// What a row says in the card's current look. A card can have more than one
|
||
// look, chosen per node (the Text node's big look), and a row can carry
|
||
// `looks: { big: { height, placeholder } }`: in that look those settings
|
||
// stand in for the row's own. Any other look is the row as written.
|
||
export function rowInLook(row, look = null) {
|
||
const own = look ? row?.looks?.[look] : null;
|
||
return own && typeof own === "object" ? { ...row, ...own } : row;
|
||
}
|
||
|
||
// Whether a change of the card's height is a restore: the node's size
|
||
// already fits the card as it now is, so it is kept (lifted to the floor)
|
||
// rather than fitted to the change (nodeHeightAfterCardChange in
|
||
// panel_layout.mjs). A workflow that is loading is one, with the height it
|
||
// was saved at. So is a look that changes with no hand on the card: it came
|
||
// with the node's own data (a load, a paste, an undo), and ComfyUI redraws
|
||
// the card while it fills the node in, before onConfigure, with the saved
|
||
// size already on the node. Only a look picked on the card itself fits the
|
||
// node to the new look.
|
||
export function cardChangeRestore({ restoring = false, savedHeight, lookChanged = false, byHand = false, current } = {}) {
|
||
if (restoring) return { restoring: true, saved: savedHeight };
|
||
if (lookChanged && !byHand) return { restoring: true, saved: current };
|
||
return { restoring: false, saved: undefined };
|
||
}
|
||
|
||
// One row's height: a caption, a group header, a control row, or the
|
||
// height a textarea row asks for in the card's look.
|
||
export function rowHeight(row, look = null) {
|
||
if (row.section !== undefined) return SECTION_HEIGHT;
|
||
if (row.panel) return Number(row.height) || ROW_HEIGHT;
|
||
if (row.widget === undefined && row.pair === undefined) return GROUP_HEIGHT;
|
||
const height = Number(rowInLook(row, look).height);
|
||
return height > 0 ? 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, look = null) {
|
||
if (!rows.length) return CARD_PADDING * 2;
|
||
return CARD_PADDING * 2 + rows.reduce((sum, row) => sum + rowHeight(row, look), 0) + ROW_GAP * (rows.length - 1);
|
||
}
|
||
|
||
// Where each visible row starts, measured from the card's top edge.
|
||
export function rowTops(rows, look = null) {
|
||
let y = CARD_PADDING;
|
||
return rows.map((row) => {
|
||
const top = y;
|
||
y += rowHeight(row, look) + 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 whole number moves by its own increment:
|
||
// steps, counts and frame numbers go 25, 24, 23. Only a wide pixel size
|
||
// (`pixels`) moves by 8.
|
||
export function scrubSteps(options = {}, isFloat = true, pixels = false) {
|
||
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 = pixels && 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";
|
||
}
|
||
|
||
// Write a value the way LiteGraph's own widgets do: set it, run the widget's
|
||
// callback, then tell the node. The frontend re-scans a node for missing
|
||
// media, and clears the errors it flagged, from node.onWidgetChanged.
|
||
export function commitWidgetValue(node, widget, value, canvas) {
|
||
const previous = widget.value;
|
||
widget.value = value;
|
||
widget.callback?.(value, canvas, node);
|
||
node.onWidgetChanged?.(widget.name, value, previous, widget);
|
||
}
|
||
|
||
export function comboValues(widget) {
|
||
let values = widget?.options?.values;
|
||
if (typeof values === "function") values = values(widget);
|
||
return Array.isArray(values) ? values : [];
|
||
}
|
||
|
||
// A value the widget cannot hold: a switch holds only true or false, a
|
||
// choice only one of its options, a number only a finite number. Other
|
||
// widgets are never judged (an empty text field is a real value).
|
||
export function holdsUnknownValue(widget) {
|
||
if (widget?.type === "toggle") return widget.value !== true && widget.value !== false;
|
||
if (widget?.type === "combo") return !comboValues(widget).includes(widget.value);
|
||
if (widget?.type === "number" || widget?.type === "slider") return !Number.isFinite(widget.value);
|
||
return false;
|
||
}
|
||
|
||
// Saved values come back by position, and a card used to save an empty value
|
||
// of its own, so an input appended after a release can receive the card's ""
|
||
// from an older save. Put `fallbacks[name]` back on each named
|
||
// widget holding a value it cannot hold; returns the names it reset.
|
||
export function resetUnknownValues(widgets, fallbacks = {}) {
|
||
const reset = [];
|
||
for (const [name, fallback] of Object.entries(fallbacks)) {
|
||
const widget = widgets?.find((item) => item?.name === name);
|
||
if (widget && holdsUnknownValue(widget)) {
|
||
widget.value = fallback;
|
||
reset.push(name);
|
||
}
|
||
}
|
||
return reset;
|
||
}
|
||
|
||
// A widget the card sets from its own controls (a gear menu), or the node's
|
||
// own face writes, is never wired. Its input gets one of the card's widget
|
||
// types (js/widget_cards), which make the stock widget marked socketless, so
|
||
// the frontend gives it no input slot in either renderer: no dot to draw,
|
||
// and nothing for a link dragged over the node to land on. A STRING gets a
|
||
// one-line text box, a choice the combo.
|
||
export const SOCKETLESS_TEXT = "AUSBOSS_SOCKETLESS_TEXT";
|
||
export const SOCKETLESS_CHOICE = "AUSBOSS_SOCKETLESS_COMBO";
|
||
|
||
export function markSocketless(nodeData, names) {
|
||
for (const name of names) {
|
||
const spec = nodeData?.input?.optional?.[name] ?? nodeData?.input?.required?.[name];
|
||
const type = Array.isArray(spec) ? spec[0] : nodeData?.inputs?.[name]?.type;
|
||
const widgetType = type === "STRING" ? SOCKETLESS_TEXT : SOCKETLESS_CHOICE;
|
||
for (const options of [nodeData?.input?.optional?.[name]?.[1], nodeData?.input?.required?.[name]?.[1], nodeData?.inputs?.[name]]) {
|
||
if (options && typeof options === "object") Object.assign(options, { widgetType, socketless: true });
|
||
}
|
||
}
|
||
}
|
||
|
||
// A choice's words on the card: the row's own, else the value itself.
|
||
// `labels` is a map from value to words, or a function of the value and of
|
||
// all the values, for a list that is only known on the user's machine (the
|
||
// model files they have).
|
||
export function choiceLabel(row, value, values = []) {
|
||
const own = typeof row?.labels === "function" ? row.labels(value, values) : row?.labels?.[value];
|
||
return own ?? String(value);
|
||
}
|
||
|
||
function comboLabels(row, widget) {
|
||
const values = comboValues(widget);
|
||
return values.map((value) => choiceLabel(row, value, values));
|
||
}
|
||
|
||
// A widget's hover text. The frontend keeps a node's tooltips in its
|
||
// definition and not on the widget, so a card that mirrors a hidden widget
|
||
// looks there too. Without this no row of a card says anything on hover.
|
||
// A tooltip the widget carries itself still wins.
|
||
export function widgetTooltip(node, name, widget = null) {
|
||
const own = widget?.options?.tooltip ?? widget?.tooltip;
|
||
if (typeof own === "string" && own) return own;
|
||
const inputs = node?.constructor?.nodeData?.input;
|
||
const spec = inputs?.required?.[name] ?? inputs?.optional?.[name];
|
||
const text = Array.isArray(spec) ? spec[1]?.tooltip : undefined;
|
||
return typeof text === "string" ? text : "";
|
||
}
|