Files
ausboss-ComfyUI-AusBoss/js/shared/widget_card_math.mjs

221 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 : "";
}