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); +});