From de58c86ba29fa0fd113a5fd313ff56cd3f789485 Mon Sep 17 00:00:00 2001 From: ausboss <102309304+ausboss@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:23:35 -0500 Subject: [PATCH] Workflow Switches: a card can show its switches as one pill, where one is always picked One at a time already made switching a row on switch the other rows off, but every row kept its own off | on, so all of them could be off. For choices such as masking by words or by a click, the card can now show its rows as one control. - A new card setting, Show as (rows | one pill), is there while One at a time is on. Rows is the default and looks as before. - As one pill, each row is a segment of the pack's segmented pill, the row that is on is the filled one, and a click on another segment switches to it. A click on the picked one changes nothing, so one is always picked. - Two to four rows whose names fit are a pill, and the card's minimum width follows the names so none is cut short. More rows, or longer names, are a list to pick from. - A graph with none or several rows on, or a row only partly on, is drawn as it is, with an amber edge, and the next click leaves exactly one on. - In the edit view the pill sits above the rows, which keep their frame button and menu. Switch everything off is left out of the node's menu while the card shows as one pill. The stored settings grow by one key, show, which reads "rows" when it is missing, so a workflow saved by an earlier version opens as it did. A click on a choice uses the same plans as One at a time, for rows that hold nodes, rows that change settings and rows with an off side. --- CHANGELOG.md | 11 ++ js/docs/AUSBOSS_NODES_WorkflowSwitches.md | 43 +++++ js/shared/workflow_switches.mjs | 80 ++++++++ js/workflow_switches/index.js | 216 ++++++++++++++++++++-- tests/workflow_switches.test.mjs | 174 +++++++++++++++-- 5 files changed, 496 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af8a59f..b07e96a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/js/docs/AUSBOSS_NODES_WorkflowSwitches.md b/js/docs/AUSBOSS_NODES_WorkflowSwitches.md index 86a264a..c282930 100644 --- a/js/docs/AUSBOSS_NODES_WorkflowSwitches.md +++ b/js/docs/AUSBOSS_NODES_WorkflowSwitches.md @@ -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 diff --git a/js/shared/workflow_switches.mjs b/js/shared/workflow_switches.mjs index 5f6bc73..4fb8217 100644 --- a/js/shared/workflow_switches.mjs +++ b/js/shared/workflow_switches.mjs @@ -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) { diff --git a/js/workflow_switches/index.js b/js/workflow_switches/index.js index 136fcf0..34ca934 100644 --- a/js/workflow_switches/index.js +++ b/js/workflow_switches/index.js @@ -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 diff --git a/tests/workflow_switches.test.mjs b/tests/workflow_switches.test.mjs index 6710db8..e3783d9 100644 --- a/tests/workflow_switches.test.mjs +++ b/tests/workflow_switches.test.mjs @@ -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); +});