Merge pull request #189 from ausboss/feat/workflow-switches-pick-one
Workflow Switches: a card can show its switches as one pill, where one is always picked
This commit is contained in:
@@ -31,6 +31,17 @@ All notable changes to ComfyUI-AusBoss are documented here.
|
||||
it moves; a node at its smallest height is one line taller for it. The
|
||||
size line above it no longer wraps in and out mid-drag, and the full
|
||||
editor's size box and status panel wait the same way.
|
||||
- **Workflow Switches 🆎: a card can show its switches as one pill, where
|
||||
one is always picked.** It is for choices, such as masking by words or by
|
||||
a click. In the card's Settings, set **One at a time** to on and the new
|
||||
**Show as** to **one pill**. Each switch is then a part of one pill, the
|
||||
one in use is filled, and a click on another one changes to it. A click on
|
||||
the filled one does nothing, so nobody switches them all off or two on.
|
||||
Up to four short names sit side by side. More choices, or long names,
|
||||
show as a list to pick from, and the card gets wider when the names need
|
||||
the room. A workflow saved with none or several on shows that as it is,
|
||||
with an amber edge, and one click settles it. The cards you have look and
|
||||
work as before: rows are still what a card opens with.
|
||||
|
||||
## 2.11.2 - 2026-10-13
|
||||
|
||||
|
||||
@@ -67,6 +67,32 @@ A small sign of two arrows beside the label shows that a switch has an off
|
||||
side. Point at it to see which nodes. To take a node off that side again,
|
||||
select it and pick **Take the 1 selected off that side**.
|
||||
|
||||
## One pill to pick from
|
||||
|
||||
Sometimes the switches on a card are choices, and exactly one should be in
|
||||
use: mask by words or by a click, say, or one of three upscalers. The card
|
||||
can show them as one pill. Each switch is a part of the pill, the one in use
|
||||
is filled, and a click on another one changes to it.
|
||||
|
||||
1. Make a switch for each choice.
|
||||
2. Open **Settings** and set **One at a time** to **on**.
|
||||
3. Set **Show as** to **one pill**.
|
||||
|
||||
One choice is always picked. A click on the filled one does nothing, so
|
||||
nobody can switch them all off or switch two on.
|
||||
|
||||
Up to four short names sit side by side. With more choices, or with long
|
||||
names, the card shows a list to pick from instead. The card gets wider when
|
||||
the names need the room.
|
||||
|
||||
In the edit view the pill sits above the rows. You pick in the pill, and the
|
||||
rows keep their frame button and three dots. After **Done** only the pill is
|
||||
left. To edit again, right-click the node and pick **Edit switches**.
|
||||
|
||||
A workflow can be saved with none of the choices on, or with several. The
|
||||
pill then shows that as it is, with an amber edge. Click the one you want
|
||||
and the others are switched off.
|
||||
|
||||
## The small view
|
||||
|
||||
Press **Done** and the card shrinks to the labels and their switches. That is
|
||||
@@ -128,6 +154,9 @@ Open **Settings** at the bottom of the edit view.
|
||||
with numbers in numeric order (*2 · …* before *10 · …*).
|
||||
- **One at a time**: switching a row on switches the other rows off. Use it
|
||||
for alternatives, such as two upscalers.
|
||||
- **Show as** (there while One at a time is on): **rows** gives every switch
|
||||
a row with its own off | on. **one pill** shows them as one pill to pick
|
||||
from, see above.
|
||||
|
||||
## Technical details
|
||||
|
||||
@@ -171,6 +200,20 @@ Open **Settings** at the bottom of the edit view.
|
||||
- **One at a time** switches the other rows off, which runs their off sides.
|
||||
A node that one row runs and another switches off ends up running, and the
|
||||
row you switch on has the last word.
|
||||
- **A pill or a list.** Two to four rows are a pill when their names fit on
|
||||
a card no wider than 400 px. More rows, or longer names, are a list. With
|
||||
one row the card stays in the row view. Groups listed on the card are
|
||||
choices too, and a row with no nodes cannot be picked.
|
||||
- **A click on a choice** does what One at a time does: that row goes on and
|
||||
every other row off, their settings and off sides included. A row that is
|
||||
only partly on is striped, as in the row view.
|
||||
- **Switch everything off** is left out of the right-click menu while the
|
||||
card shows as one pill.
|
||||
- **The pill is stored as one more setting**: `"show": "pill"` beside
|
||||
`"exclusive": true`, in the node's `ausboss_workflow_switches` property.
|
||||
`"show": "rows"`, or no `show` at all, is the row view. So a workflow saved
|
||||
by an older version of this pack opens as rows, and an older version
|
||||
ignores the setting.
|
||||
- **The off side is stored next to the nodes it belongs to**, as `nodes_off`
|
||||
and `groups_off` beside `nodes` and `groups`. They are left out while
|
||||
nothing is on the off side, so a workflow that does not use it is saved as
|
||||
|
||||
@@ -39,6 +39,9 @@ export const DEFAULT_SETTINGS = Object.freeze({
|
||||
match: "", // comma-separated title text, read when groups is "matching"
|
||||
order: "canvas", // group rows: "canvas" (the way the workflow reads) | "title"
|
||||
exclusive: false, // one at a time: switching a row on switches the other rows off
|
||||
// with one at a time on, how the card shows its rows: "rows" (each with its
|
||||
// own off | on) | "pill" (every row is a choice in one pill, one always picked)
|
||||
show: "rows",
|
||||
// the made switches: { id, title, nodes, groups, on, off }, and nodes_off /
|
||||
// groups_off (the off side) while they hold something
|
||||
switches: Object.freeze([]),
|
||||
@@ -48,6 +51,7 @@ const CHOICES = {
|
||||
off: ["bypass", "mute"],
|
||||
groups: ["all", "numbered", "matching", "none"],
|
||||
order: ["canvas", "title"],
|
||||
show: ["rows", "pill"],
|
||||
};
|
||||
|
||||
export function cleanTitle(title) {
|
||||
@@ -687,6 +691,82 @@ export function valueAllPlan(rows, on) {
|
||||
return [...sides.values()].filter(([pair, side]) => pair.now !== side);
|
||||
}
|
||||
|
||||
// ---------- one pill for the whole card ----------
|
||||
|
||||
// With One at a time on, a card can show its rows as one control: every row
|
||||
// is a choice, and exactly one is picked. Up to PILL_CHOICES_MAX choices sit
|
||||
// side by side in a segmented pill; more are a list to pick from, the widget
|
||||
// cards' rule.
|
||||
export const PILL_CHOICES_MAX = 4;
|
||||
// What lies between a node's edge and the names in its pill, both sides
|
||||
// together: the panel's frame (12 a side in Nodes 2.0, 10 in the classic
|
||||
// renderer), the card's border and padding (9) and the pill's own (3).
|
||||
export const PILL_FRAME = 48;
|
||||
// The room a segment keeps beside its name: 6 px a side, and 2 for rounding.
|
||||
export const PILL_SEGMENT_PAD = 14;
|
||||
// The widest a node is made for the sake of its pill. Names that need more
|
||||
// are a list too.
|
||||
export const PILL_NODE_WIDTH_MAX = 400;
|
||||
|
||||
// What a choice is called: the row's title, or a plain word for a group
|
||||
// nobody named.
|
||||
export function rowLabel(row) {
|
||||
return row?.title || (row?.kind === "switch" ? "Switch" : "Group");
|
||||
}
|
||||
|
||||
// A guess at a name's width where it cannot be measured (under node:test).
|
||||
const guessWidth = (text) => [...String(text ?? "")].length * 6.5;
|
||||
|
||||
// How wide a node must be for its pill to show every name in full. The
|
||||
// segments are equally wide, so the longest name decides. `measure` gives a
|
||||
// name's width in pixels, drawn the way a segment draws it.
|
||||
export function pillNodeWidth(labels, measure = guessWidth) {
|
||||
const longest = (labels ?? []).reduce((widest, label) => Math.max(widest, Number(measure(label)) || 0), 0);
|
||||
return Math.ceil(PILL_FRAME + (labels?.length ?? 0) * (longest + PILL_SEGMENT_PAD));
|
||||
}
|
||||
|
||||
// How a card shows its rows: "rows" (one row each, the look every card had
|
||||
// before, and the default), "pill" (one segmented pill) or "list" (one list
|
||||
// to pick from). The last two need One at a time, Show as set to one pill,
|
||||
// and at least two rows: one row alone has nothing to be picked against.
|
||||
// `width` is the node width the pill needs, which the card holds as its floor
|
||||
// so that no name is cut short; 0 for the other two.
|
||||
export function cardView(settings, rows, measure = guessWidth) {
|
||||
const count = rows?.length ?? 0;
|
||||
if (!settings?.exclusive || settings.show !== "pill" || count < 2) return { view: "rows", width: 0 };
|
||||
if (count <= PILL_CHOICES_MAX) {
|
||||
const width = pillNodeWidth(rows.map(rowLabel), measure);
|
||||
if (width <= PILL_NODE_WIDTH_MAX) return { view: "pill", width };
|
||||
}
|
||||
return { view: "list", width: 0 };
|
||||
}
|
||||
|
||||
// Where a pick-one card stands. It is settled ("one", with the row that is
|
||||
// `picked`) when exactly one row is on and no other row is partly on. Any
|
||||
// other state is told as it is: "none" (nothing on), "partly" (one row, and
|
||||
// only some of it) or "several" (`count` rows on or partly on). A graph edited
|
||||
// by hand, or saved before the card was a pill, can be in any of them.
|
||||
export function choiceState(rows) {
|
||||
const on = (rows ?? []).filter((row) => row.state === "on");
|
||||
const mixed = (rows ?? []).filter((row) => row.state === "mixed");
|
||||
if (on.length === 1 && !mixed.length) return { kind: "one", picked: on[0], count: 1 };
|
||||
if (!on.length && !mixed.length) return { kind: "none", picked: null, count: 0 };
|
||||
if (!on.length && mixed.length === 1) return { kind: "partly", picked: null, count: 1 };
|
||||
return { kind: "several", picked: null, count: on.length + mixed.length };
|
||||
}
|
||||
|
||||
// What a click on a choice changes: that row goes on and every other row
|
||||
// off, as one at a time does. `modes` and `values` are switchPlan's and
|
||||
// valuePlan's answers. Both are empty for the choice that is already the only
|
||||
// one on, so a click on it does nothing and one stays picked; from any other
|
||||
// state one click leaves exactly this row on. A row with nothing in it cannot
|
||||
// be picked: it would only switch the others off.
|
||||
export function choosePlan(rows, target, settings) {
|
||||
if (!target || target.state === "empty") return { modes: [], values: [] };
|
||||
const one = { ...settings, exclusive: true };
|
||||
return { modes: switchPlan(rows, target, true, one), values: valuePlan(rows, target, true, one) };
|
||||
}
|
||||
|
||||
// ---------- making and changing switches ----------
|
||||
|
||||
function withSwitches(settings, switches) {
|
||||
|
||||
+199
-17
@@ -16,6 +16,10 @@
|
||||
// folds all of that away and leaves the labels and their switches: the small
|
||||
// view a shared workflow ships with.
|
||||
//
|
||||
// A card with One at a time on can show its rows as one control instead
|
||||
// (Show as: one pill): every row is a choice in a segmented pill, or in a
|
||||
// list when the names are too many or too long, and one is always picked.
|
||||
//
|
||||
// Only what the user made is stored (which nodes each switch holds, by id,
|
||||
// the values it remembers and the settings, in node.properties). Every row's
|
||||
// state is read from the nodes' own modes and values, so a reloaded workflow, an undo or a node bypassed by
|
||||
@@ -29,13 +33,14 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange, showToast } from "../shared/index.mjs";
|
||||
import { keepKeyInField } from "../shared/canvas_passthrough.mjs";
|
||||
import { holdNodeMinWidth, pinVuePanelHeight } from "../shared/panel_layout.mjs";
|
||||
import { holdNodeMinWidth, holdVueNodeMinWidth, pinVuePanelHeight } from "../shared/panel_layout.mjs";
|
||||
import { cardHeight, commitWidgetValue, ensureCardCss } from "../shared/widget_card.mjs";
|
||||
import {
|
||||
DEFAULT_SETTINGS, SETTINGS_PROPERTY, SWITCHES_MAX, TITLE_MAX, addMembers, addOffMembers, addSwitch, flipTo, frameBounds,
|
||||
groupBounds, hasOffSide, heldCount, listedRows, modeMatters, moveSwitch, normalizeSettings, offFreshCount, offHeldCount,
|
||||
pairValue, placeNodes, readGroups, readSwitches, removeMembers, removeOffMembers, removeSwitch, renameSwitch,
|
||||
rowsSignature, saveValues, snapshotNode, suggestTitle, switchAllPlan, switchPlan, valueAllPlan, valuePlan,
|
||||
DEFAULT_SETTINGS, SETTINGS_PROPERTY, SWITCHES_MAX, TITLE_MAX, addMembers, addOffMembers, addSwitch, cardView, choiceState,
|
||||
choosePlan, flipTo, frameBounds, groupBounds, hasOffSide, heldCount, listedRows, modeMatters, moveSwitch, normalizeSettings,
|
||||
offFreshCount, offHeldCount, pairValue, placeNodes, readGroups, readSwitches, removeMembers, removeOffMembers, removeSwitch,
|
||||
renameSwitch, rowLabel, rowsSignature, saveValues, snapshotNode, suggestTitle, switchAllPlan, switchPlan, valueAllPlan,
|
||||
valuePlan,
|
||||
} from "../shared/workflow_switches.mjs";
|
||||
|
||||
const NODE_CLASS = "AUSBOSS_NODES_WorkflowSwitches";
|
||||
@@ -64,6 +69,9 @@ const RECHECK_MS = 150;
|
||||
const FRAME_FILL = 0.8;
|
||||
// What a group with no color set is drawn with (LGraphGroup.defaultColour).
|
||||
const NO_COLOR = "#335";
|
||||
// The type a pill's choices are set in. Their names are measured with it, so
|
||||
// the card can be as wide as its pill needs.
|
||||
const CHOICE_FONT = `600 10.5px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif`;
|
||||
|
||||
const live = new Set();
|
||||
let hookedCanvas = null;
|
||||
@@ -102,6 +110,17 @@ function ensureCss() {
|
||||
.ausboss-ws-sides svg{display:block;width:12px;height:12px}
|
||||
.ausboss-ws.edit .ausboss-ws-row.sided{grid-template-columns:10px minmax(0,1fr) auto auto 84px 26px 26px}
|
||||
.ausboss-ws.edit .ausboss-ws-row.sided .ausboss-ws-sides{display:grid}
|
||||
.ausboss-ws .ausboss-ws-pick{grid-template-columns:minmax(0,1fr)}
|
||||
.ausboss-ws-pick [hidden]{display:none}
|
||||
.ausboss-ws-choices button{padding:0 6px;font:${CHOICE_FONT}}
|
||||
.ausboss-ws-choices button.mixed{background:repeating-linear-gradient(135deg,rgba(0,180,170,.2) 0 3px,transparent 3px 7px);color:#bfe6e2}
|
||||
.ausboss-ws-choices button.empty{opacity:.35;pointer-events:none}
|
||||
.ausboss-ws-choices.settled button.on{cursor:default}
|
||||
.ausboss-ws-choices.unsettled,.ausboss-ws-choice-list.unsettled{border-color:rgba(255,196,107,.6)}
|
||||
.ausboss-ws.pick .ausboss-ws-pill{display:none}
|
||||
.ausboss-ws.pick:not(.edit) .ausboss-ws-row{display:none}
|
||||
.ausboss-ws.pick.edit .ausboss-ws-row{grid-template-columns:10px minmax(0,1fr) auto 26px 26px}
|
||||
.ausboss-ws.pick.edit .ausboss-ws-row.sided{grid-template-columns:10px minmax(0,1fr) auto auto 26px 26px}
|
||||
.ausboss-ws-hint{height:${HINT_HEIGHT}px;flex:none;display:flex;align-items:center;padding:0;border:none;background:transparent;color:#78908e;font:inherit;font-size:11px;line-height:1.35;text-align:left;user-select:none;pointer-events:auto}
|
||||
.ausboss-ws-hint.link{cursor:pointer}
|
||||
.ausboss-ws-hint.link:hover{color:#d8ecea}
|
||||
@@ -220,8 +239,25 @@ function settingsOpen(node) {
|
||||
return Boolean(node.properties?.[SHOW_SETTINGS]);
|
||||
}
|
||||
|
||||
// The narrowest the node goes: what its view needs, and what its pill needs
|
||||
// to show every name in full.
|
||||
function minWidth(node) {
|
||||
return editOpen(node) ? EDIT_MIN_WIDTH : SMALL_MIN_WIDTH;
|
||||
const view = editOpen(node) ? EDIT_MIN_WIDTH : SMALL_MIN_WIDTH;
|
||||
return Math.max(view, node.__ausbossWorkflowSwitches?.pillWidth ?? 0);
|
||||
}
|
||||
|
||||
// A name's width in a pill, measured in the type the pill sets it in.
|
||||
let measuring = null;
|
||||
function nameWidth(text) {
|
||||
measuring ??= document.createElement("canvas").getContext("2d");
|
||||
if (!measuring) return String(text).length * 7;
|
||||
measuring.font = CHOICE_FONT;
|
||||
return measuring.measureText(String(text)).width;
|
||||
}
|
||||
|
||||
// How the card shows its rows: "rows", "pill" or "list".
|
||||
function viewOf(settings, rows) {
|
||||
return cardView(settings, rows, nameWidth);
|
||||
}
|
||||
|
||||
function read(state) {
|
||||
@@ -316,6 +352,22 @@ function switchRow(state, key, side) {
|
||||
refresh(state);
|
||||
}
|
||||
|
||||
// A click on a choice of the pill, or a pick from its list: that row goes on
|
||||
// and every other row off. The choice that is already the only one on changes
|
||||
// nothing, so one is always picked.
|
||||
function chooseRow(state, key) {
|
||||
const { settings, rows } = read(state);
|
||||
const target = rows.find((row) => rowKey(row) === key);
|
||||
if (target) {
|
||||
const { modes, values } = choosePlan(rows, target, settings);
|
||||
applyPlan(state, modes, values);
|
||||
}
|
||||
// Drawn again even when nothing changed: a list must not keep showing a
|
||||
// pick that was not taken.
|
||||
state.signature = "";
|
||||
refresh(state);
|
||||
}
|
||||
|
||||
function switchAll(state, on) {
|
||||
const { settings, rows } = read(state);
|
||||
applyPlan(state, switchAllPlan(rows, on, settings), valueAllPlan(rows, on));
|
||||
@@ -460,8 +512,14 @@ function buildSettings(state) {
|
||||
{ flips: true },
|
||||
);
|
||||
exclusive.row.querySelector(".ausboss-card-seg")?.classList.add("ausboss-card-bool");
|
||||
const show = segmentRow(
|
||||
state, "show", "Show as",
|
||||
"Rows gives each switch a row with its own off | on. One pill makes the switches choices in a single pill, where one is always picked.",
|
||||
["rows", "pill"],
|
||||
{ pill: "one pill" },
|
||||
);
|
||||
|
||||
const rows = { off, groups, match: { row: match.row }, order, exclusive };
|
||||
const rows = { off, groups, match: { row: match.row }, order, exclusive, show };
|
||||
const sync = (settings, edit, open) => {
|
||||
header.classList.toggle("open", open);
|
||||
header.style.display = edit ? "" : "none";
|
||||
@@ -469,7 +527,8 @@ function buildSettings(state) {
|
||||
for (const entry of Object.values(rows)) entry.row.classList.toggle("hidden", !edit || !open);
|
||||
match.row.classList.toggle("hidden", !edit || !open || settings.groups !== "matching");
|
||||
order.row.classList.toggle("hidden", !edit || !open || !listing);
|
||||
for (const entry of [off, groups, order, exclusive]) entry.sync(settings);
|
||||
show.row.classList.toggle("hidden", !edit || !open || !settings.exclusive);
|
||||
for (const entry of [off, groups, order, exclusive, show]) entry.sync(settings);
|
||||
if (document.activeElement !== text) text.value = settings.match;
|
||||
};
|
||||
return { header, rows: Object.values(rows).map((entry) => entry.row), sync };
|
||||
@@ -480,6 +539,7 @@ function settingsRowCount(settings) {
|
||||
let count = 3; // when off, groups, one at a time
|
||||
if (settings.groups !== "none") count += 1; // group order
|
||||
if (settings.groups === "matching") count += 1; // match
|
||||
if (settings.exclusive) count += 1; // show as
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -729,7 +789,7 @@ function buildRow(state, key, made) {
|
||||
});
|
||||
|
||||
const sync = (data, settings) => {
|
||||
const label = data.title || (made ? "Switch" : "Group");
|
||||
const label = rowLabel(data);
|
||||
row.classList.toggle("off", data.state === "off");
|
||||
row.classList.toggle("mixed", data.state === "mixed");
|
||||
row.classList.toggle("empty", data.state === "empty");
|
||||
@@ -793,14 +853,122 @@ function buildRow(state, key, made) {
|
||||
return { row, title, name, sync };
|
||||
}
|
||||
|
||||
// ---------- one pill for the whole card ----------
|
||||
|
||||
// Where a pick-one card stands when it is not settled on one choice, in a
|
||||
// few words; nothing when it is.
|
||||
function standText(stand, rows) {
|
||||
if (stand.kind === "none") return "Nothing is picked";
|
||||
if (stand.kind === "several") return `${stand.count} are on at once`;
|
||||
if (stand.kind !== "partly") return "";
|
||||
return `"${rowLabel(rows.find((row) => row.state === "mixed"))}" is only partly on`;
|
||||
}
|
||||
|
||||
// The card's rows as one control: a segmented pill with a segment for each
|
||||
// row, or a list of them when a pill cannot hold them (cardView decides). The
|
||||
// rows that are on are the filled ones, so a graph with none or several on
|
||||
// shows as it is, with an amber edge, and the next click leaves one.
|
||||
function buildPick(state) {
|
||||
const row = el("div", "ausboss-card-row ausboss-ws-pick hidden");
|
||||
const pill = el("div", "ausboss-card-seg ausboss-ws-choices");
|
||||
pill.setAttribute("role", "radiogroup");
|
||||
const list = el("select", "ausboss-card-select ausboss-ws-choice-list");
|
||||
list.addEventListener("change", () => {
|
||||
if (list.value) chooseRow(state, list.value);
|
||||
});
|
||||
row.append(pill, list);
|
||||
const choices = new Map();
|
||||
|
||||
const syncPill = (rows, stand, note) => {
|
||||
const settled = stand.kind === "one";
|
||||
pill.classList.toggle("settled", settled);
|
||||
pill.classList.toggle("unsettled", !settled);
|
||||
const seen = new Set();
|
||||
let cursor = pill.firstElementChild;
|
||||
for (const data of rows) {
|
||||
const key = rowKey(data);
|
||||
let choice = choices.get(key);
|
||||
if (!choice) {
|
||||
choice = button("");
|
||||
choice.setAttribute("role", "radio");
|
||||
choice.addEventListener("click", () => chooseRow(state, key));
|
||||
choices.set(key, choice);
|
||||
}
|
||||
const label = rowLabel(data);
|
||||
const empty = data.state === "empty";
|
||||
choice.textContent = label;
|
||||
choice.classList.toggle("on", data.state === "on");
|
||||
choice.classList.toggle("mixed", data.state === "mixed");
|
||||
choice.classList.toggle("empty", empty);
|
||||
choice.disabled = empty;
|
||||
choice.setAttribute("aria-checked", String(data.state === "on"));
|
||||
choice.title = empty ? `"${label}" holds no nodes, so there is nothing to pick.`
|
||||
: !settled ? `${note}. Click: use only "${label}".`
|
||||
: data === stand.picked ? `"${label}" is picked. Click another one to change.`
|
||||
: `Click: use "${label}" and switch the others off`;
|
||||
if (cursor === choice) cursor = cursor.nextElementSibling;
|
||||
else pill.insertBefore(choice, cursor);
|
||||
seen.add(key);
|
||||
}
|
||||
for (const [key, choice] of choices) {
|
||||
if (seen.has(key)) continue;
|
||||
choice.remove();
|
||||
choices.delete(key);
|
||||
}
|
||||
};
|
||||
|
||||
const syncList = (rows, stand, note) => {
|
||||
const settled = stand.kind === "one";
|
||||
list.classList.toggle("unsettled", !settled);
|
||||
list.title = settled ? "Pick the one to use. The others are switched off." : `${note}. Pick the one to use.`;
|
||||
const options = [];
|
||||
if (!settled) {
|
||||
// What the list reads while no single row is on; never one of the
|
||||
// picks, and short enough for the narrowest card.
|
||||
const short = { none: "Pick one", partly: "Partly on: pick one", several: `${stand.count} are on: pick one` };
|
||||
const waiting = el("option", "", short[stand.kind]);
|
||||
waiting.value = "";
|
||||
waiting.disabled = true;
|
||||
waiting.hidden = true;
|
||||
options.push(waiting);
|
||||
}
|
||||
for (const data of rows) {
|
||||
const mark = settled ? "" : data.state === "on" ? " (on)" : data.state === "mixed" ? " (partly on)" : "";
|
||||
const option = el("option", "", `${rowLabel(data)}${mark}`);
|
||||
option.value = rowKey(data);
|
||||
option.disabled = data.state === "empty";
|
||||
options.push(option);
|
||||
}
|
||||
list.replaceChildren(...options);
|
||||
list.value = settled ? rowKey(stand.picked) : "";
|
||||
};
|
||||
|
||||
const sync = (view, rows) => {
|
||||
row.classList.toggle("hidden", view === "rows");
|
||||
pill.hidden = view !== "pill";
|
||||
list.hidden = view !== "list";
|
||||
const stand = choiceState(rows);
|
||||
const note = standText(stand, rows);
|
||||
syncPill(view === "pill" ? rows : [], stand, note);
|
||||
if (view === "list") syncList(rows, stand, note);
|
||||
else list.replaceChildren();
|
||||
};
|
||||
return { row, sync };
|
||||
}
|
||||
|
||||
// ---------- the panel ----------
|
||||
|
||||
function declaredHeight(state) {
|
||||
return state.height + WRAPPER_INSET;
|
||||
}
|
||||
|
||||
function measureCard(listed, edit, open, settings) {
|
||||
const rows = listed ? Array.from({ length: listed }, () => ({ widget: "switch" })) : [{ widget: "hint", height: HINT_HEIGHT }];
|
||||
// With the rows shown as one control (`picking`), that control is one row,
|
||||
// and the rows under it show in the edit view only.
|
||||
function measureCard(listed, edit, open, settings, picking) {
|
||||
const rows = [];
|
||||
if (!listed) rows.push({ widget: "hint", height: HINT_HEIGHT });
|
||||
if (picking) rows.push({ widget: "pick" });
|
||||
if (!picking || edit) for (let index = 0; index < listed; index += 1) rows.push({ widget: "switch" });
|
||||
if (edit) {
|
||||
rows.push({ widget: "bar" }, { group: "settings" });
|
||||
if (open) for (let index = 0; index < settingsRowCount(settings); index += 1) rows.push({ widget: "setting" });
|
||||
@@ -818,7 +986,12 @@ function render(state, settings, rows, groupCount, pick) {
|
||||
const node = state.node;
|
||||
const edit = editOpen(node);
|
||||
const open = settingsOpen(node);
|
||||
const { view, width: pillWidth } = viewOf(settings, rows);
|
||||
const picking = view !== "rows";
|
||||
state.root.classList.toggle("edit", edit);
|
||||
state.root.classList.toggle("pick", picking);
|
||||
state.pick.sync(view, rows);
|
||||
state.pillWidth = pillWidth;
|
||||
|
||||
const seen = new Set();
|
||||
let cursor = state.list.firstElementChild;
|
||||
@@ -847,12 +1020,18 @@ function render(state, settings, rows, groupCount, pick) {
|
||||
state.hint.textContent = hintText(edit, groupCount, settings);
|
||||
state.hint.classList.toggle("link", !edit);
|
||||
state.bar.classList.toggle("hidden", !edit);
|
||||
state.done.title = picking
|
||||
? "Show only the pill. Right-click the node and pick Edit switches to edit again."
|
||||
: "Show only the labels and their switches. Double-click a label, or right-click the node, to edit again.";
|
||||
state.add.classList.toggle("ready", pick.count > 0);
|
||||
state.add.textContent = pick.count > 0 ? `+ Switch from ${pick.count} selected` : "Select nodes, then add a switch";
|
||||
state.settings.sync(settings, edit, open);
|
||||
|
||||
const height = measureCard(rows.length, edit, open, settings);
|
||||
const height = measureCard(rows.length, edit, open, settings, picking);
|
||||
const floor = minWidth(node);
|
||||
// Nodes 2.0 holds the floor on the node's element: told here, as the floor
|
||||
// follows the names in the pill and the node may not change size with it.
|
||||
holdVueNodeMinWidth(state.root, floor);
|
||||
if (height !== state.height || (node.size?.[0] ?? 0) < floor) {
|
||||
state.height = height;
|
||||
const width = Math.max(floor, node.size?.[0] || NODE_WIDTH);
|
||||
@@ -924,16 +1103,17 @@ function buildPanel(node) {
|
||||
const hint = button("ausboss-ws-hint hidden");
|
||||
const bar = el("div", "ausboss-card-row ausboss-ws-bar hidden");
|
||||
const add = button("ausboss-ws-add", "+ Switch", "Select the nodes of one part on the canvas, then press this: they get one switch");
|
||||
const done = button("ausboss-ws-done", "Done", "Show only the labels and their switches. Double-click a label, or right-click the node, to edit again.");
|
||||
const done = button("ausboss-ws-done", "Done");
|
||||
bar.append(add, done);
|
||||
card.append(list, hint, bar);
|
||||
root.append(card);
|
||||
|
||||
const state = {
|
||||
node, root, list, hint, bar, add, rows: new Map(), settings: null, renaming: null,
|
||||
node, root, list, hint, bar, add, done, rows: new Map(), settings: null, pick: null, pillWidth: 0, renaming: null,
|
||||
height: 0, signature: "", checkedAt: 0, timer: null, disposed: false,
|
||||
};
|
||||
node.__ausbossWorkflowSwitches = state;
|
||||
state.pick = buildPick(state);
|
||||
card.append(state.pick.row, list, hint, bar);
|
||||
state.settings = buildSettings(state);
|
||||
card.append(state.settings.header, ...state.settings.rows);
|
||||
|
||||
@@ -1025,14 +1205,16 @@ app.registerExtension({
|
||||
chainCallback(nodeType.prototype, "getExtraMenuOptions", function (_canvas, options) {
|
||||
const state = this.__ausbossWorkflowSwitches;
|
||||
if (!state || !Array.isArray(options)) return;
|
||||
const { settings } = read(state);
|
||||
const { settings, rows } = read(state);
|
||||
const edit = editOpen(this);
|
||||
const items = [{
|
||||
content: edit ? "Show only the switches" : "Edit switches",
|
||||
callback: () => setEdit(state, !edit),
|
||||
}];
|
||||
if (!settings.exclusive) items.push({ content: "Switch everything on", callback: () => switchAll(state, true) });
|
||||
items.push({ content: "Switch everything off", callback: () => switchAll(state, false) }, null);
|
||||
// A card shown as one pill always has one picked: no "everything off".
|
||||
if (viewOf(settings, rows).view === "rows") items.push({ content: "Switch everything off", callback: () => switchAll(state, false) });
|
||||
items.push(null);
|
||||
options.unshift(...items);
|
||||
});
|
||||
// The card is a list sized to its rows: width may grow, but a corner
|
||||
|
||||
@@ -2,13 +2,14 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
DEFAULT_SETTINGS, MEMBERS_MAX, MODE_ALWAYS, MODE_BYPASS, MODE_NEVER, SWITCHES_MAX, TITLE_MAX, VALUE_TEXT_MAX,
|
||||
addMembers, addOffMembers, addSwitch, canvasOrder, centreInside, cleanIds, cleanSnapshots, flipTo, frameBounds, groupBounds,
|
||||
groupListed, hasOffSide, heldCount, isRunning, listedRows, matchTerms, modeMatters, moveSwitch, nodeBounds,
|
||||
normalizeSettings, normalizeSwitches, offFreshCount, offHeldCount, offMode, pairNow, pairValue, partState, placeNodes,
|
||||
readGroups, readSwitches, removeMembers, removeOffMembers, removeSwitch, renameSwitch, rowsSignature, saveValues,
|
||||
snapshotNode, stackRows, suggestTitle, switchAllPlan, switchPlan, titleOrder, unionRect, valueAllPlan, valuePairs,
|
||||
valuePlan,
|
||||
DEFAULT_SETTINGS, MEMBERS_MAX, MODE_ALWAYS, MODE_BYPASS, MODE_NEVER, PILL_CHOICES_MAX, PILL_FRAME, PILL_NODE_WIDTH_MAX,
|
||||
PILL_SEGMENT_PAD, SWITCHES_MAX, TITLE_MAX, VALUE_TEXT_MAX,
|
||||
addMembers, addOffMembers, addSwitch, canvasOrder, cardView, centreInside, choiceState, choosePlan, cleanIds, cleanSnapshots,
|
||||
flipTo, frameBounds, groupBounds, groupListed, hasOffSide, heldCount, isRunning, listedRows, matchTerms, modeMatters,
|
||||
moveSwitch, nodeBounds, normalizeSettings, normalizeSwitches, offFreshCount, offHeldCount, offMode, pairNow, pairValue,
|
||||
partState, pillNodeWidth, placeNodes, readGroups, readSwitches, removeMembers, removeOffMembers, removeSwitch, renameSwitch,
|
||||
rowLabel, rowsSignature, saveValues, snapshotNode, stackRows, suggestTitle, switchAllPlan, switchPlan, titleOrder, unionRect,
|
||||
valueAllPlan, valuePairs, valuePlan,
|
||||
} from "../js/shared/workflow_switches.mjs";
|
||||
|
||||
const node = (id, x, y, { w = 200, h = 100, mode = MODE_ALWAYS, ...rest } = {}) => ({ id, pos: [x, y], size: [w, h], mode, ...rest });
|
||||
@@ -22,12 +23,13 @@ test("settings fall back to the defaults for anything a workflow left behind", (
|
||||
assert.deepEqual(normalizeSettings("nonsense"), defaults);
|
||||
assert.deepEqual(
|
||||
normalizeSettings({ off: "mute", groups: "matching", match: "upscale", order: "title", exclusive: true }),
|
||||
{ off: "mute", groups: "matching", match: "upscale", order: "title", exclusive: true, switches: [] },
|
||||
{ off: "mute", groups: "matching", match: "upscale", order: "title", exclusive: true, show: "rows", switches: [] },
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeSettings({ off: "delete", groups: 3, order: null, exclusive: "yes", match: 7, switches: "all" }),
|
||||
normalizeSettings({ off: "delete", groups: 3, order: null, exclusive: "yes", show: "tabs", match: 7, switches: "all" }),
|
||||
defaults,
|
||||
);
|
||||
assert.equal(normalizeSettings({ exclusive: true, show: "pill" }).show, "pill");
|
||||
assert.equal(normalizeSettings({ match: "x".repeat(500) }).match.length, 200);
|
||||
assert.equal(normalizeSettings({ groups: "none" }).groups, "none");
|
||||
// Every read hands out a list of its own: the frozen default is never shared.
|
||||
@@ -523,7 +525,10 @@ test("a switch saved before the off side existed is stored, read and switched ex
|
||||
+ '{"id":1,"title":"The cut-out","nodes":[8],"groups":[],"on":{},"off":{}},'
|
||||
+ '{"id":2,"title":"Fast","nodes":[],"groups":[3],"on":{"7":{"value":8}},"off":{"7":{"value":25}}}]}';
|
||||
const settings = normalizeSettings(JSON.parse(before));
|
||||
assert.equal(JSON.stringify(settings), before);
|
||||
// The switches read back as they were saved. The settings beside them have
|
||||
// since grown by one key, which reads as its default.
|
||||
const now = before.replace('"switches":[', '"show":"rows","switches":[');
|
||||
assert.equal(JSON.stringify(settings), now);
|
||||
// Nothing that changes such a switch brings the off side in.
|
||||
const changed = [
|
||||
addMembers(settings, 1, { nodes: [9], groups: [4] }), removeMembers(settings, 1, { nodes: [8] }), renameSwitch(settings, 1, "Renamed"),
|
||||
@@ -532,7 +537,7 @@ test("a switch saved before the off side existed is stored, read and switched ex
|
||||
for (const next of changed) {
|
||||
for (const entry of next.switches) assert.deepEqual(Object.keys(entry), ["id", "title", "nodes", "groups", "on", "off"]);
|
||||
}
|
||||
assert.equal(JSON.stringify(removeOffMembers(settings, 1, { nodes: [8] })), before);
|
||||
assert.equal(JSON.stringify(removeOffMembers(settings, 1, { nodes: [8] })), now);
|
||||
// Read from a graph: no off side, and the state is the one the nodes alone give.
|
||||
const placed = placeNodes([node(8, 0, 0, { mode: MODE_BYPASS })]);
|
||||
const [cutOut] = readSwitches(settings.switches, placed, []);
|
||||
@@ -754,3 +759,150 @@ test("the signature changes with anything the card shows, and only then", () =>
|
||||
assert.notEqual(rowsSignature(rows, { ...settings, off: "mute" }), base);
|
||||
assert.notEqual(rowsSignature(rows, addSwitch(settings, { title: "New" }).settings), base);
|
||||
});
|
||||
|
||||
// ---------- one pill for the whole card ----------
|
||||
|
||||
// Every character 6 px wide: a name's width is then easy to say in a test.
|
||||
const sixPx = (text) => String(text).length * 6;
|
||||
const choice = (title, state, extra = {}) => ({ kind: "switch", title, state, members: [], ...extra });
|
||||
|
||||
test("a card shows its rows as before unless One at a time and the pill are both on", () => {
|
||||
const rows = [choice("Say it", "on"), choice("Touch it", "off")];
|
||||
// A workflow saved by an earlier version has no `show`: rows, as it always was.
|
||||
for (const saved of [{}, { exclusive: true }, { exclusive: false, show: "pill" }, { exclusive: true, show: "rows" }]) {
|
||||
assert.deepEqual(cardView(normalizeSettings(saved), rows, sixPx), { view: "rows", width: 0 });
|
||||
}
|
||||
const pill = normalizeSettings({ exclusive: true, show: "pill" });
|
||||
assert.equal(cardView(pill, rows, sixPx).view, "pill");
|
||||
// One row alone has nothing to be picked against, and no row is the hint.
|
||||
assert.equal(cardView(pill, [rows[0]], sixPx).view, "rows");
|
||||
assert.equal(cardView(pill, [], sixPx).view, "rows");
|
||||
assert.equal(cardView(pill, undefined, sixPx).view, "rows");
|
||||
});
|
||||
|
||||
test("up to four short names are a pill, and more or longer ones are a list", () => {
|
||||
const pill = normalizeSettings({ exclusive: true, show: "pill" });
|
||||
const named = (...titles) => titles.map((title) => choice(title, "off"));
|
||||
for (let count = 2; count <= PILL_CHOICES_MAX; count += 1) {
|
||||
assert.equal(cardView(pill, named(...Array.from({ length: count }, (_, index) => `Option ${index + 1}`)), sixPx).view, "pill");
|
||||
}
|
||||
assert.equal(cardView(pill, named("1", "2", "3", "4", "5"), sixPx).view, "list");
|
||||
assert.deepEqual(cardView(pill, named("A", "B", "C", "D", "E", "F"), sixPx), { view: "list", width: 0 });
|
||||
// The segments are equally wide, so the longest name decides the width.
|
||||
assert.equal(pillNodeWidth(["Say it", "Touch it"], sixPx), PILL_FRAME + 2 * (8 * 6 + PILL_SEGMENT_PAD));
|
||||
assert.equal(pillNodeWidth(["A", "Touch it", "B"], sixPx), PILL_FRAME + 3 * (8 * 6 + PILL_SEGMENT_PAD));
|
||||
assert.deepEqual(cardView(pill, named("Say it", "Touch it"), sixPx), { view: "pill", width: PILL_FRAME + 2 * (8 * 6 + PILL_SEGMENT_PAD) });
|
||||
// The card is never asked to be wider than PILL_NODE_WIDTH_MAX for its pill.
|
||||
const fits = Math.floor(((PILL_NODE_WIDTH_MAX - PILL_FRAME) / 2 - PILL_SEGMENT_PAD) / 6);
|
||||
assert.equal(cardView(pill, named("x".repeat(fits), "short"), sixPx).view, "pill");
|
||||
assert.equal(cardView(pill, named("x".repeat(fits + 1), "short"), sixPx).view, "list");
|
||||
assert.ok(cardView(pill, named("x".repeat(fits), "short"), sixPx).width <= PILL_NODE_WIDTH_MAX);
|
||||
// Four names leave each less room than two do.
|
||||
assert.equal(cardView(pill, named("x".repeat(fits), "a", "b", "c"), sixPx).view, "list");
|
||||
// A group nobody named still has something to read, and without a way to
|
||||
// measure there is a guess, never a throw.
|
||||
assert.equal(rowLabel({ kind: "group", title: "" }), "Group");
|
||||
assert.equal(rowLabel({ kind: "switch", title: "" }), "Switch");
|
||||
assert.equal(rowLabel({ kind: "group", title: "4 · Upscale" }), "4 · Upscale");
|
||||
assert.equal(cardView(pill, named("Say it", "Touch it")).view, "pill");
|
||||
});
|
||||
|
||||
test("a pick-one card says honestly where it stands", () => {
|
||||
const a = choice("A", "on");
|
||||
assert.deepEqual(choiceState([a, choice("B", "off"), choice("C", "empty")]), { kind: "one", picked: a, count: 1 });
|
||||
assert.deepEqual(choiceState([choice("A", "off"), choice("B", "off")]), { kind: "none", picked: null, count: 0 });
|
||||
assert.deepEqual(choiceState([choice("A", "on"), choice("B", "on"), choice("C", "off")]), { kind: "several", picked: null, count: 2 });
|
||||
// A row that is only partly on is never the pick, alone or beside another.
|
||||
assert.deepEqual(choiceState([choice("A", "mixed"), choice("B", "off")]), { kind: "partly", picked: null, count: 1 });
|
||||
assert.deepEqual(choiceState([choice("A", "on"), choice("B", "mixed")]), { kind: "several", picked: null, count: 2 });
|
||||
assert.deepEqual(choiceState([]), { kind: "none", picked: null, count: 0 });
|
||||
assert.deepEqual(choiceState(undefined), { kind: "none", picked: null, count: 0 });
|
||||
});
|
||||
|
||||
test("a click on a choice leaves exactly that row on, and a click on the picked one does nothing", () => {
|
||||
const settings = normalizeSettings({ exclusive: true, show: "pill" });
|
||||
const graph = () => {
|
||||
const nodes = [node(1, 0, 0), node(2, 0, 200, { mode: MODE_BYPASS }), node(3, 0, 400, { mode: MODE_BYPASS })];
|
||||
const made = normalizeSettings({
|
||||
switches: [{ id: 1, title: "Say it", nodes: [1] }, { id: 2, title: "Touch it", nodes: [2] }, { id: 3, title: "Paint it", nodes: [3] }],
|
||||
}).switches;
|
||||
return { nodes, read: () => readSwitches(made, placeNodes(nodes), []) };
|
||||
};
|
||||
const click = (g, index) => {
|
||||
const rows = g.read();
|
||||
const { modes, values } = choosePlan(rows, rows[index], settings);
|
||||
for (const [member, mode] of modes) member.mode = mode;
|
||||
return { changed: modes.length + values.length, states: g.read().map((row) => row.state) };
|
||||
};
|
||||
// Settled: the picked one does nothing, another one moves the pick.
|
||||
let g = graph();
|
||||
assert.deepEqual(click(g, 0), { changed: 0, states: ["on", "off", "off"] });
|
||||
assert.deepEqual(click(g, 2), { changed: 2, states: ["off", "off", "on"] });
|
||||
assert.deepEqual(g.nodes.map((member) => member.mode), [MODE_BYPASS, MODE_BYPASS, MODE_ALWAYS]);
|
||||
assert.deepEqual(click(g, 2), { changed: 0, states: ["off", "off", "on"] });
|
||||
// None on (an old file, or everything bypassed by hand): one click picks.
|
||||
g = graph();
|
||||
g.nodes[0].mode = MODE_BYPASS;
|
||||
assert.equal(choiceState(g.read()).kind, "none");
|
||||
assert.deepEqual(click(g, 1), { changed: 1, states: ["off", "on", "off"] });
|
||||
// Several on: a click on one of them keeps only that one.
|
||||
g = graph();
|
||||
g.nodes[1].mode = MODE_ALWAYS;
|
||||
g.nodes[2].mode = MODE_ALWAYS;
|
||||
assert.deepEqual(choiceState(g.read()).count, 3);
|
||||
assert.deepEqual(click(g, 1), { changed: 2, states: ["off", "on", "off"] });
|
||||
// It is one at a time whatever the card's own setting says, and it mutes when off means mute.
|
||||
g = graph();
|
||||
const loose = { ...settings, exclusive: false, off: "mute" };
|
||||
const rows = g.read();
|
||||
for (const [member, mode] of choosePlan(rows, rows[1], loose).modes) member.mode = mode;
|
||||
assert.deepEqual(g.nodes.map((member) => member.mode), [MODE_NEVER, MODE_ALWAYS, MODE_NEVER]);
|
||||
// Nothing to pick: no row, or a row with nothing in it.
|
||||
assert.deepEqual(choosePlan(rows, undefined, settings), { modes: [], values: [] });
|
||||
assert.deepEqual(choosePlan(rows, { state: "empty", members: [] }, settings), { modes: [], values: [] });
|
||||
});
|
||||
|
||||
test("the pill picks between all three kinds of rows: nodes, settings and a row with an off side", () => {
|
||||
const settings = normalizeSettings({ exclusive: true, show: "pill" });
|
||||
const steps = { id: 21, pos: [0, 0], size: [200, 100], mode: MODE_ALWAYS, widgets: [{ name: "value", value: 25 }] };
|
||||
const nodes = [
|
||||
node(1, 0, 200), // "Describe" holds it
|
||||
steps, // "Fast" changes its value: 8 for on, 25 for off
|
||||
node(3, 0, 400, { mode: MODE_BYPASS }), // "Edit" runs it
|
||||
node(4, 0, 600), // and this one runs only while "Edit" is off
|
||||
];
|
||||
const made = normalizeSettings({
|
||||
switches: [
|
||||
{ id: 1, title: "Describe", nodes: [1] },
|
||||
{ id: 2, title: "Fast", on: { 21: { value: 8 } }, off: { 21: { value: 25 } } },
|
||||
{ id: 3, title: "Edit", nodes: [3], nodes_off: [4] },
|
||||
],
|
||||
}).switches;
|
||||
const read = () => readSwitches(made, placeNodes(nodes), []);
|
||||
const click = (index) => {
|
||||
const rows = read();
|
||||
const { modes, values } = choosePlan(rows, rows[index], settings);
|
||||
for (const [member, mode] of modes) member.mode = mode;
|
||||
for (const [pair, side] of values) pair.control.value = pairValue(pair, side, pair.control.value);
|
||||
return read().map((row) => row.state);
|
||||
};
|
||||
const now = () => [nodes[0].mode, steps.widgets[0].value, nodes[2].mode, nodes[3].mode];
|
||||
assert.deepEqual(read().map((row) => row.state), ["on", "off", "off"]);
|
||||
assert.deepEqual(click(1), ["off", "on", "off"]);
|
||||
assert.deepEqual(now(), [MODE_BYPASS, 8, MODE_BYPASS, MODE_ALWAYS]);
|
||||
assert.deepEqual(click(2), ["off", "off", "on"]);
|
||||
assert.deepEqual(now(), [MODE_BYPASS, 25, MODE_ALWAYS, MODE_BYPASS]);
|
||||
assert.deepEqual(click(0), ["on", "off", "off"]);
|
||||
assert.deepEqual(now(), [MODE_ALWAYS, 25, MODE_BYPASS, MODE_ALWAYS]);
|
||||
// A setting changed by hand to a third value reads partly on, and a click on its row sets it.
|
||||
steps.widgets[0].value = 12;
|
||||
assert.equal(choiceState(read()).kind, "several");
|
||||
assert.deepEqual(click(1), ["off", "on", "off"]);
|
||||
assert.equal(steps.widgets[0].value, 8);
|
||||
});
|
||||
|
||||
test("the pill view is part of what the card draws", () => {
|
||||
const rows = [choice("Say it", "on"), choice("Touch it", "off")];
|
||||
const base = rowsSignature(rows, normalizeSettings({ exclusive: true }));
|
||||
assert.notEqual(rowsSignature(rows, normalizeSettings({ exclusive: true, show: "pill" })), base);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user