feat(toggles): a toggle's settings in a window, icons, colours and a Pick one negative (#361)
Each toggle's settings open in a kit Modal: name, variable, looks (icon, icon/name/control colours on kit IconPicker and ColorField), Pick one value and negative, style, needs, when off. The header and the row wear the toggle's looks; the icon is a column beside the name and $var. Labels take an icon and colour. Window warnings use the kit Banner. Bundle guide raised to 670 KB.
This commit is contained in:
@@ -6,25 +6,27 @@ Several on/off switches in one node. Each toggle writes `$its_variable` as `true
|
||||
## On the node
|
||||
|
||||
- **upstream (in)** `context`: Optional Context to extend. Only nodes after this one see the toggle variables. It can also set toggles (see below).
|
||||
- **Pencil (toolbar)**: Select the node and the indigo pencil in ComfyUI's selection toolbar opens the edit view: the mode, the grips to reorder, a pencil per row, Add toggle and Add label. Done goes back to the clean view, which shows only the switches.
|
||||
- **Pencil (toolbar)**: Select the node and the indigo pencil in ComfyUI's selection toolbar opens the edit view: the mode, the Pick one options, the grips to reorder, the row buttons, Add toggle and Add label. Done goes back to the clean view, which shows only the switches.
|
||||
- **Any on / Pick one**: Any on: every toggle is on or off by itself. Pick one: exactly one is on, like radio buttons.
|
||||
- **writes (Pick one)**: Optional variable that also gets the picked toggle's value (or its name), so one rule can branch on it.
|
||||
- **Globe (row)**: Edit view only. On (the default) keeps the toggle's variable internal: rules read it, the Assembler doesn't offer it and it never prints. Turn it off to use the variable in a prompt. Same in Any on and Pick one.
|
||||
- **Show in Assembler (Pick one)**: The picked variable shows in the Assembler and can print in the prompt. Off, it is internal.
|
||||
- **Style (Pick one)**: How every row looks in Pick one: Radio (a round dot, the default), Chip (the name across the row, lit when picked), Switch or Check. Turning one on turns the others off.
|
||||
- **Pencil (row)**: Opens the toggle's edit panel: name, variable, style, needs, what off mutes or bypasses, group and its output.
|
||||
- **Add toggle / Add label**: A new toggle, or a label that groups the toggles under it. Click a label's arrow to fold it. Drag a toggle's grip under a label to move it into that group; a dragged label takes its toggles along.
|
||||
- **Pencil, globe, trash (row)**: Each row in the edit view has three buttons, like a module on a Context node: the pencil opens the toggle's own settings in a window (name, variable, looks, style, needs, what off mutes or bypasses, group and its output) with the row drawn on top as the node will show it; the globe keeps its variable internal; the trash removes it.
|
||||
- **Add toggle / Add label**: A new toggle, or a label that groups the toggles under it. Click a label's arrow to fold it; its icon and colour buttons pick any icon and any colour. Drag a toggle's grip under a label to move it into that group; a dragged label takes its toggles along.
|
||||
- **control dots (in)**: A toggle that mutes or bypasses has a dot at the left of its row: wire the nodes it drives into it.
|
||||
- **context (out)** `context`: The Context with every toggle variable added. Internal ones (the globe) are for rules only; the rest show in the Assembler.
|
||||
- **toggle outputs** `BOOLEAN`: One BOOLEAN per toggle that has an output, at the right of its row. Up to 16.
|
||||
|
||||
## Editing
|
||||
|
||||
- **Name**: Shown on the row and on its output.
|
||||
- **Variable**: Gets true or false. It follows the name until you change it. Internal until you turn off the row's globe. Show puts it under the name.
|
||||
- **Style**: Switch, Check, Buttons (two words of your own, like Day / Night) or Chip (a small chip with the name). Greyed out in Pick one, where the Style in the top bar sets every row's look.
|
||||
- **Name**: Shown on the row and on its output. A Chip shows Name · $variable (the variable while Show is on). A name colour also colours its variable.
|
||||
- **Variable**: Gets true or false. It follows the name until you change it. Internal until you turn off the row's globe. Show puts it under the name, or after it on a Chip.
|
||||
- **Looks**: An icon before the name, and colours for the icon, the name and the control (switch, check, radio, chip or buttons), so Day can be yellow and Night blue. None keeps the theme's colour.
|
||||
- **Style**: Switch, Check, Buttons (two words of your own, like Day / Night) or Chip (a small chip with the name). Any on only: in Pick one the Style in the top bar sets every row's look.
|
||||
- **Value (Pick one)**: What the picked variable gets when this toggle is picked, instead of its name. A text box like the Assembler's: $ lists every variable upstream (Context Injector ones included) and the toggles' own, and tag, LoRA and embedding autocomplete work when they are on. A $name nothing upstream sets is flagged under the box and in WP Debug. Inline {a|b} picks need a seed, so they print as written; roll them in a wildcard instead. Drag the corner to make it taller.
|
||||
- **Needs**: Another toggle this one needs. While that one is off, this one is off too; it keeps your choice for when it comes back on.
|
||||
- **Negative (Pick one)**: Words to keep out of the image while this toggle is picked. They go with the picked variable, so an Assembler that prints it sends them to its negative output, like a wildcard option's negative. It can read $variables; a pick without one clears what the variable carried.
|
||||
- **Needs**: Any on only. Another toggle this one needs. While that one is off, this one is off too; it keeps your choice for when it comes back on.
|
||||
- **When off**: Nothing, Mute or Bypass: what happens to the nodes wired into the row's dot (and to the canvas groups you pick) while the toggle is off. On, they run. When several toggles drive one node, off wins: it runs only while all of them let it (Mute beats Bypass).
|
||||
- **Invert**: Mute or bypass while the toggle is ON instead, for a node used only as a switchboard.
|
||||
- **Canvas groups**: Groups on the canvas whose nodes When off also covers, by title. No wires needed.
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
- **Any extension picture in a WP Note.** The Pictures panel's **From the extension** now offers every screenshot from the docs, not just the toolbar one, found by typing part of its name (the grid shows only matches, so the panel stays tidy). After you add a picture the panel stays open, so its width slider is right there, and the icon list and picture grid use the window's height instead of a small scrolling box. The icon picker on the node now fills the editor's width.
|
||||
- **Export as plain wildcard files.** Import / Export → Export has a new **Wildcard files (.zip)** button: the picks become `.txt` wildcard files for Dynamic Prompts, Impact Pack, Prompt-PostProcessor, Easy-Use and A1111/Forge, so you can share a pack with people who don't use Wildcard Pipeline. References and `$variables` become `__folder/name__`, uneven weights become repeated lines, and a README in the zip lists what had no plain form (rules, tags, the chance of nothing, negatives).
|
||||
|
||||
- **More looks for WP Toggles.** Pick one has its own **Style** in the edit view: Radio (as before), Chip, Switch or Check, the same for every row; each row's Style is greyed out there since it no longer applies. A pick's **Value** is now a text box like the Assembler's that you can make taller: `$` lists every variable upstream (Context Injector ones too) and the toggles' own, tag, LoRA and embedding autocomplete work in it, and a `$name` nothing upstream sets is flagged under the box and reported in WP Debug after a run. Inline `{a|b}` picks need a seed, so in the Value and in Context Injector templates they print as written; both editors now say so. A Chip toggle now spans the whole row, in Any on and Pick one, so it's an easy target and lines up with the rows around it. In the edit view you can drag a toggle into or out of a label's group (the Group field still works), and a grouped toggle shows its label's colour on its left edge.
|
||||
- **A toggle's settings open in a window.** In WP Toggles' edit view each row now has a pencil, the globe and a trash button, like a module on a Context node. The pencil opens that toggle's settings in a window instead of a panel squeezed into the node, with the row drawn on top as the node will show it; Done saves, Cancel drops the changes (it asks first when there are any). Mode, order, labels and the Pick one options stay on the node.
|
||||
- **Icons and colours for WP Toggles.** A toggle can show an icon before its name and have its own colours for the icon, the name and the control itself, so Day can be yellow and Night blue. A label picks any colour with the colour picker (not just the eight presets) and an icon too; the label's icon button is a small square like its colour one. A Chip toggle shows its variable after the name (Day · `$day`) while Show is on, a variable wears its name's colour, and icons now sit level with the name.
|
||||
- **A negative for each Pick one toggle.** Under a toggle's Value there is now a Negative: words to keep out of the image while that toggle is picked (Night: "sunlight, blue sky"). They go with the picked variable, so an Assembler that prints it puts them in its negative output.
|
||||
- **More looks for WP Toggles.** Pick one has its own **Style**: Radio (as before), Chip, Switch or Check, the same for every row; a toggle's own Style and Needs are hidden there since they don't apply. A pick's **Value** is now a text box like the Assembler's that you can make taller: `$` lists every variable upstream (Context Injector ones too) and the toggles' own, tag, LoRA and embedding autocomplete work in it, and a `$name` nothing upstream sets is flagged under the box and reported in WP Debug after a run. Inline `{a|b}` picks need a seed, so in the Value and in Context Injector templates they print as written; both editors now say so. A Chip toggle now spans the whole row, in Any on and Pick one, so it's an easy target and lines up with the rows around it. In the edit view you can drag a toggle into or out of a label's group (the Group field still works), and a grouped toggle shows its label's colour on its left edge.
|
||||
|
||||
### Wildcards
|
||||
|
||||
|
||||
@@ -1748,11 +1748,15 @@
|
||||
},
|
||||
{
|
||||
"title": "Controls",
|
||||
"text": "upstream (in)\nOptional Context to extend. Only nodes after this one see the toggle variables. It can also set toggles (see below).\nPencil (toolbar)\nSelect the node and the indigo pencil in ComfyUI's selection toolbar opens the edit view: the mode, the grips to reorder, a pencil per row, Add toggle and Add label. Done goes back to the clean view, which shows only the switches.\nAny on / Pick one\nAny on: every toggle is on or off by itself. Pick one: exactly one is on, like radio buttons.\nwrites (Pick one)\nOptional variable that also gets the picked toggle's value (or its name), so one rule can branch on it.\nGlobe (row)\nEdit view only. On (the default) keeps the toggle's variable internal: rules read it, the Assembler doesn't offer it and it never prints. Turn it off to use the variable in a prompt. Same in Any on and Pick one.\nShow in Assembler (Pick one)\nThe picked variable shows in the Assembler and can print in the prompt. Off, it is internal.\nStyle (Pick one)\nHow every row looks in Pick one: Radio (a round dot, the default), Chip (the name across the row, lit when picked), Switch or Check. Turning one on turns the others off.\nPencil (row)\nOpens the toggle's edit panel: name, variable, style, needs, what off mutes or bypasses, group and its output.\nAdd toggle / Add label\nA new toggle, or a label that groups the toggles under it. Click a label's arrow to fold it. Drag a toggle's grip under a label to move it into that group; a dragged label takes its toggles along.\ncontrol dots (in)\nA toggle that mutes or bypasses has a dot at the left of its row: wire the nodes it drives into it.\ncontext (out)\nThe Context with every toggle variable added. Internal ones (the globe) are for rules only; the rest show in the Assembler.\ntoggle outputs\nOne BOOLEAN per toggle that has an output, at the right of its row. Up to 16."
|
||||
"text": "upstream (in)\nOptional Context to extend. Only nodes after this one see the toggle variables. It can also set toggles (see below).\nPencil (toolbar)\nSelect the node and the indigo pencil in ComfyUI's selection toolbar opens the edit view: the mode, the Pick one options, the grips to reorder, the row buttons, Add toggle and Add label. Done goes back to the clean view, which shows only the switches.\nAny on / Pick one\nAny on: every toggle is on or off by itself. Pick one: exactly one is on, like radio buttons.\nwrites (Pick one)\nOptional variable that also gets the picked toggle's value (or its name), so one rule can branch on it.\nGlobe (row)\nEdit view only. On (the default) keeps the toggle's variable internal: rules read it, the Assembler doesn't offer it and it never prints. Turn it off to use the variable in a prompt. Same in Any on and Pick one.\nShow in Assembler (Pick one)\nThe picked variable shows in the Assembler and can print in the prompt. Off, it is internal.\nStyle (Pick one)\nHow every row looks in Pick one: Radio (a round dot, the default), Chip (the name across the row, lit when picked), Switch or Check. Turning one on turns the others off.\nPencil, globe, trash (row)\nEach row in the edit view has three buttons, like a module on a Context node: the pencil opens the toggle's own settings in a window (name, variable, looks, style, needs, what off mutes or bypasses, group and its output) with the row drawn on top as the node will show it; the globe keeps its variable internal; the trash removes it.\nAdd toggle / Add label\nA new toggle, or a label that groups the toggles under it. Click a label's arrow to fold it; its icon and colour buttons pick any icon and any colour. Drag a toggle's grip under a label to move it into that group; a dragged label takes its toggles along.\ncontrol dots (in)\nA toggle that mutes or bypasses has a dot at the left of its row: wire the nodes it drives into it.\ncontext (out)\nThe Context with every toggle variable added. Internal ones (the globe) are for rules only; the rest show in the Assembler.\ntoggle outputs\nOne BOOLEAN per toggle that has an output, at the right of its row. Up to 16."
|
||||
},
|
||||
{
|
||||
"title": "Editing a toggle",
|
||||
"text": "Name\nShown on the row and on its output.\nVariable\nGets true or false. It follows the name until you change it. Internal until you turn off the row's globe. Show puts it under the name.\nStyle\nSwitch, Check, Buttons (two words of your own, like Day / Night) or Chip (a small chip with the name). Greyed out in Pick one, where the Style in the top bar sets every row's look.\nValue (Pick one)\nWhat the picked variable gets when this toggle is picked, instead of its name. A text box like the Assembler's: $ lists every variable upstream (Context Injector ones included) and the toggles' own, and tag, LoRA and embedding autocomplete work when they are on. A $name nothing upstream sets is flagged under the box and in WP Debug. Inline {a|b} picks need a seed, so they print as written; roll them in a wildcard instead. Drag the corner to make it taller.\nNeeds\nAnother toggle this one needs. While that one is off, this one is off too; it keeps your choice for when it comes back on.\nWhen off\nNothing, Mute or Bypass: what happens to the nodes wired into the row's dot (and to the canvas groups you pick) while the toggle is off. On, they run. When several toggles drive one node, off wins: it runs only while all of them let it (Mute beats Bypass).\nInvert\nMute or bypass while the toggle is ON instead, for a node used only as a switchboard.\nCanvas groups\nGroups on the canvas whose nodes When off also covers, by title. No wires needed.\nUpstream can set it\nWhen the upstream Context holds this toggle's variable as a yes/no word (true, yes, on, 1, false, no, off, 0), that value sets the switch for the run.\nGroup\nWhich label it sits under. You can also drag the toggle's grip under another label, or above the first one.\nBOOLEAN output\nShow an output for switch nodes. Off for a toggle only rules read.\nDrag the grip to reorder. A toggle dropped under a label joins that group (a folded one opens), and one dropped above the first label leaves its group. A label moves with its toggles. Moving a row never re-points a wire: each output stays with its toggle."
|
||||
"text": "Name\nShown on the row and on its output. A Chip shows Name · $variable (the variable while Show is on). A name colour also colours its variable.\nVariable\nGets true or false. It follows the name until you change it. Internal until you turn off the row's globe. Show puts it under the name, or after it on a Chip.\nLooks\nAn icon before the name, and colours for the icon, the name and the control (switch, check, radio, chip or buttons), so Day can be yellow and Night blue. None keeps the theme's colour.\nStyle\nSwitch, Check, Buttons (two words of your own, like Day / Night) or Chip (a small chip with the name). Any on only: in Pick one the Style in the top bar sets every row's look.\nValue (Pick one)\nWhat the picked variable gets when this toggle is picked, instead of its name. A text box like the Assembler's: $ lists every variable upstream (Context Injector ones included) and the toggles' own, and tag, LoRA and embedding autocomplete work when they are on. A $name nothing upstream sets is flagged under the box and in WP Debug. Inline {a|b} picks need a seed, so they print as written; roll them in a wildcard instead. Drag the corner to make it taller.\nNegative (Pick one)\nWords to keep out of the image while this toggle is picked. They go with the picked variable, so an Assembler that prints it sends them to its negative output, like a wildcard option's negative. It can read $variables; a pick without one clears what the variable carried.\nNeeds\nAny on only. Another toggle this one needs. While that one is off, this one is off too; it keeps your choice for when it comes back on.\nWhen off\nNothing, Mute or Bypass: what happens to the nodes wired into the row's dot (and to the canvas groups you pick) while the toggle is off. On, they run. When several toggles drive one node, off wins: it runs only while all of them let it (Mute beats Bypass).\nInvert\nMute or bypass while the toggle is ON instead, for a node used only as a switchboard.\nCanvas groups\nGroups on the canvas whose nodes When off also covers, by title. No wires needed.\nUpstream can set it\nWhen the upstream Context holds this toggle's variable as a yes/no word (true, yes, on, 1, false, no, off, 0), that value sets the switch for the run.\nGroup\nWhich label it sits under. You can also drag the toggle's grip under another label, or above the first one.\nBOOLEAN output\nShow an output for switch nodes. Off for a toggle only rules read.\nDrag the grip to reorder. A toggle dropped under a label joins that group (a folded one opens), and one dropped above the first label leaves its group. A label moves with its toggles. Moving a row never re-points a wire: each output stays with its toggle."
|
||||
},
|
||||
{
|
||||
"title": "Icons and colours",
|
||||
"text": "Give a toggle an icon and its own colours under Looks in its window: one for the icon, one for the name and one for the control itself, so Day can glow yellow and Night blue. A label's icon and colour buttons do the same for the group, and its toggles wear that colour on their left edge. The row at the top of the toggle's window shows each change before you press Done."
|
||||
},
|
||||
{
|
||||
"title": "Use a toggle in the prompt",
|
||||
@@ -1764,7 +1768,7 @@
|
||||
},
|
||||
{
|
||||
"title": "Pick one",
|
||||
"text": "One toggle is on. Which one, first match wins: the toggle the upstream picked variable names (with Upstream can pick), then the first toggle upstream set to a yes word, then the first one you switched on. The picked variable gets the toggle's Value, or its name; it is internal unless Show in Assembler is on. All rows share one look here: the Style next to those boxes picks Radio (the default), Chip, Switch or Check, and each row's own Style is greyed out."
|
||||
"text": "One toggle is on. Which one, first match wins: the toggle the upstream picked variable names (with Upstream can pick), then the first toggle upstream set to a yes word, then the first one you switched on. The picked variable gets the toggle's Value, or its name; it is internal unless Show in Assembler is on. A toggle's Negative, under its Value, goes with the picked variable: an Assembler that prints it sends those words to its negative output. All rows share one look here: the Style next to those boxes picks Radio (the default), Chip, Switch or Check, so a toggle's own Style and Needs are hidden."
|
||||
},
|
||||
{
|
||||
"title": "Merge the Boolean nodes you have",
|
||||
|
||||
@@ -76,6 +76,8 @@ class Toggle:
|
||||
upstream: bool = False
|
||||
value: str = ""
|
||||
visible: bool = False
|
||||
#: Pick one: negative text filed under ``group_var`` when this one is picked.
|
||||
negative: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -118,6 +120,7 @@ def parse_config(raw: Any) -> Config:
|
||||
continue
|
||||
out = row.get("out")
|
||||
value = row.get("value")
|
||||
negative = row.get("negative")
|
||||
toggles.append(Toggle(
|
||||
id=tid,
|
||||
name=_str(row.get("name")) or tid,
|
||||
@@ -129,6 +132,7 @@ def parse_config(raw: Any) -> Config:
|
||||
upstream=row.get("upstream") is True,
|
||||
value=value if isinstance(value, str) else "",
|
||||
visible=row.get("visible") is True,
|
||||
negative=negative.strip() if isinstance(negative, str) else "",
|
||||
))
|
||||
return Config(
|
||||
mode=mode,
|
||||
@@ -259,6 +263,16 @@ def resolve(cfg: Config) -> dict[str, str]:
|
||||
return values
|
||||
|
||||
|
||||
def picked_negative(cfg: Config) -> str:
|
||||
"""Pick one: the picked toggle's negative text ('' when none), the
|
||||
negative the Pick one variable carries (send-to-negative)."""
|
||||
if cfg.mode != "one" or not cfg.group_var or not VAR_RE.match(cfg.group_var):
|
||||
return ""
|
||||
eff = effective(cfg)
|
||||
picked = next((t for t in cfg.toggles if eff[t.id]), None)
|
||||
return picked.negative if picked else ""
|
||||
|
||||
|
||||
def internal_vars(cfg: Config) -> set[str]:
|
||||
"""Variables the node writes that stay out of the prompt: a toggle
|
||||
without ``visible`` (the last row writing a variable decides, as in
|
||||
|
||||
@@ -379,7 +379,10 @@ const ENTRY_LIMIT = 30 * 1024; // 30 KB
|
||||
// RAISED 650 -> 660 KB on 2026-11-12: every docs screenshot can go in a WP
|
||||
// Note (note-assets.json + the kit's ImageGrid/SearchInput in the note's lazy
|
||||
// editor chunk) put the total at ~652.9 KB.
|
||||
const TOTAL_LIMIT = 660 * 1024; // 660 KB
|
||||
// RAISED 660 -> 670 KB on 2026-11-12: with both of those, WP Toggles' toggle
|
||||
// window (Value box, icon picker and colour fields in a lazy chunk) and the
|
||||
// toggle looks put the total at ~660.0 KB, 9 bytes under the old line.
|
||||
const TOTAL_LIMIT = 670 * 1024; // 670 KB
|
||||
// The total budget is a guide, not a cap (Dumi, 2026-10-06), so the gate has
|
||||
// two bands: over TOTAL_LIMIT it warns (CI stays green and shows a
|
||||
// `::warning` annotation), and only past TOTAL_LIMIT + 5% does it fail. A
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* One WP Toggles toggle's settings, in a kit `Modal`: the node
|
||||
* keeps its edit view (mode, order, globe, remove, labels) and the row's
|
||||
* pencil opens this. It edits a draft of the row; Done hands the draft and
|
||||
* its group back to `TogglesWidget.vue`, which stores it (a name change
|
||||
* carries a variable that followed the old name along). The strip on top is
|
||||
* the row as the node will draw it.
|
||||
*/
|
||||
import { computed, defineAsyncComponent, ref } from "vue";
|
||||
import Input from "@/kit/Input.vue";
|
||||
import WpSegmented from "@/kit/WpSegmented.vue";
|
||||
import Checkbox from "@/kit/Checkbox.vue";
|
||||
import WpSelect from "@/kit/WpSelect.vue";
|
||||
import Combobox from "@/kit/Combobox.vue";
|
||||
import ColorField from "@/kit/ColorField.vue";
|
||||
import IconPicker from "@/kit/IconPicker.vue";
|
||||
import { vTooltip } from "@/kit/tooltip";
|
||||
import { vToggleRow } from "@/kit/toggle-row";
|
||||
import Modal from "@/kit/Modal.vue";
|
||||
import ConfirmModal from "@/kit/ConfirmModal.vue";
|
||||
import Button from "@/kit/Button.vue";
|
||||
import Kbd from "@/kit/Kbd.vue";
|
||||
import Banner from "@/kit/Banner.vue";
|
||||
import ToggleRowView from "./ToggleRow.vue";
|
||||
import { varColorClass } from "../shared/var-color";
|
||||
import type { PickStyle, ToggleControl, ToggleRow, ToggleStyle } from "../../extension/toggles";
|
||||
import type { ControlState } from "../../extension/toggle-control";
|
||||
import type { VarProducerLike } from "../../manager/components/RefChip.vue";
|
||||
import { INLINE_PICKS_NOTE, hasInlinePicks, tokenize } from "../context/editors/_shared/preview-tokens";
|
||||
|
||||
const RichTextInput = defineAsyncComponent(() => import("../../manager/components/RichTextInput.vue"));
|
||||
const NegativeField = defineAsyncComponent(() => import("../../manager/components/NegativeField.vue"));
|
||||
|
||||
interface Choice { value: string; label: string }
|
||||
|
||||
const props = defineProps<{
|
||||
t: ToggleRow;
|
||||
pickOne: boolean;
|
||||
pickStyle?: PickStyle | undefined;
|
||||
shownOn: boolean;
|
||||
effOn: boolean;
|
||||
parentName: string;
|
||||
blocked: boolean;
|
||||
problem?: string | undefined;
|
||||
needsOptions: Choice[];
|
||||
/** Label groups to move to; empty hides the field. */
|
||||
groupOptions: Choice[];
|
||||
groupId: string;
|
||||
/** All 16 outputs are taken (an unpinned toggle can't get one). */
|
||||
outputsFull: boolean;
|
||||
controlState?: ControlState | null | undefined;
|
||||
canvasGroups?: string[] | undefined;
|
||||
varSuggestions?: string[] | undefined;
|
||||
varProducers?: Map<string, VarProducerLike> | undefined;
|
||||
nodeTitle?: string | undefined;
|
||||
}>();
|
||||
const emit = defineEmits<{ done: [next: ToggleRow, groupId: string]; cancel: [] }>();
|
||||
|
||||
// Plain copies: the props are reactive proxies.
|
||||
const d = ref<ToggleRow>(JSON.parse(JSON.stringify(props.t)) as ToggleRow);
|
||||
const draftGroup = ref(props.groupId);
|
||||
const dirty = computed(() => JSON.stringify(d.value) !== JSON.stringify(props.t) || draftGroup.value !== props.groupId);
|
||||
|
||||
function patch(p: Partial<ToggleRow>): void {
|
||||
const next: Record<string, unknown> = { ...d.value, ...p };
|
||||
// An undefined field is stored as absent.
|
||||
for (const k of Object.keys(next)) if (next[k] === undefined) delete next[k];
|
||||
d.value = next as unknown as ToggleRow;
|
||||
}
|
||||
function value(ev: Event): string {
|
||||
return (ev.target as HTMLInputElement).value.trim();
|
||||
}
|
||||
|
||||
const CONTROLS = [
|
||||
{ value: "", label: "Nothing", hint: "Off only writes false and sends False" },
|
||||
{ value: "mute", label: "Mute", hint: "Off also mutes the nodes wired into the dot in front of the row" },
|
||||
{ value: "bypass", label: "Bypass", hint: "Off also bypasses the nodes wired into the dot in front of the row" },
|
||||
];
|
||||
function setControl(v: string): void {
|
||||
patch(v ? { control: v as ToggleControl } : { control: undefined, groups: undefined });
|
||||
}
|
||||
function setGroups(v: string[] | string | null): void {
|
||||
const list = Array.isArray(v) ? v : [];
|
||||
patch({ groups: list.length ? list : undefined });
|
||||
}
|
||||
|
||||
const STYLES: { value: ToggleStyle; label: string; hint: string }[] = [
|
||||
{ value: "switch", label: "Switch", hint: "A sliding switch" },
|
||||
{ value: "checkbox", label: "Check", hint: "A checkbox" },
|
||||
{ value: "buttons", label: "Buttons", hint: "Two buttons with your own words, e.g. Day / Night" },
|
||||
{ value: "chip", label: "Chip", hint: "A small chip with the name, lit when on" },
|
||||
];
|
||||
|
||||
/** `$names` the Pick one Value reads that nothing upstream (nor this node)
|
||||
* sets. Only with a graph to look at (`varSuggestions` given). */
|
||||
const missingVars = computed(() => {
|
||||
const known = props.varSuggestions;
|
||||
if (!props.pickOne || !known || !d.value.value?.includes("$")) return [];
|
||||
const names = tokenize(d.value.value, "assembler")
|
||||
.filter((tok) => tok.kind === "var" && tok.varName)
|
||||
.map((tok) => tok.varName ?? "");
|
||||
return [...new Set(names)].filter((n) => !known.includes(n));
|
||||
});
|
||||
const missingNote = computed(() => {
|
||||
const list = missingVars.value.map((v) => "$" + v).join(", ");
|
||||
return missingVars.value.length > 1
|
||||
? `${list}: nothing upstream sets these, so they print nothing`
|
||||
: `${list}: nothing upstream sets this, so it prints nothing`;
|
||||
});
|
||||
|
||||
/** A colour field's value: the colour, or `none`. */
|
||||
function colorValue(v: string | undefined): string {
|
||||
return v ?? "none";
|
||||
}
|
||||
/** A colour field's pick as a stored colour (`none` clears it). */
|
||||
function pickColor(v: string): string | undefined {
|
||||
const c = v.slice(0, 7).toLowerCase();
|
||||
return /^#[0-9a-f]{6}$/.test(c) ? c : undefined;
|
||||
}
|
||||
|
||||
/** The preview's switch: Any on shows (and flips) the draft's own choice. */
|
||||
const previewOn = computed(() => (props.pickOne ? props.shownOn : d.value.on));
|
||||
/** The header wears the toggle's own looks: icon colour (else its control
|
||||
* colour), control colour on the chip, name colour on the name line. */
|
||||
const headStyle = computed(() => {
|
||||
const s: Record<string, string> = {};
|
||||
if (d.value.icon_color) s["--wp-tgw-icon"] = d.value.icon_color;
|
||||
if (d.value.tint) s["--wp-tgw-tint"] = d.value.tint;
|
||||
if (d.value.text_color) s["--wp-tgw-text"] = d.value.text_color;
|
||||
return s;
|
||||
});
|
||||
function previewSet(on: boolean): void {
|
||||
if (!props.pickOne) patch({ on });
|
||||
}
|
||||
|
||||
// Closing with changes (Escape, ×, Cancel) asks first.
|
||||
const asking = ref(false);
|
||||
function cancel(): void {
|
||||
if (dirty.value) asking.value = true;
|
||||
else emit("cancel");
|
||||
}
|
||||
function onKey(ev: KeyboardEvent): void {
|
||||
// Ctrl/Cmd+Enter is Done from anywhere in the window.
|
||||
if (!asking.value && ev.key === "Enter" && (ev.ctrlKey || ev.metaKey)) {
|
||||
ev.preventDefault();
|
||||
emit("done", d.value, draftGroup.value);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :open="true" size="md" @update:open="(v: boolean) => { if (!v) cancel(); }" @keydown="onKey">
|
||||
<template #header="{ titleId }">
|
||||
<i class="pi wp-tgw__icon" :class="d.icon ? `pi-${d.icon}` : 'pi-sliders-h'" :style="headStyle" aria-hidden="true" data-test="tg-window-icon" />
|
||||
<div class="wp-tgw__titles">
|
||||
<div class="wp-tgw__title-row">
|
||||
<h3 :id="titleId" class="wp-tgw__title">{{ nodeTitle || 'WP Toggles' }}</h3>
|
||||
<span class="wp-tgw__chip" :style="headStyle">Toggle</span>
|
||||
</div>
|
||||
<div class="wp-tgw__sub" :style="headStyle" data-test="tg-window-sub">
|
||||
<span class="wp-tgw__subname">{{ d.name }}</span>
|
||||
<template v-if="d.var">
|
||||
<span aria-hidden="true"> · </span>
|
||||
<span class="wp-tgw__subvar" :class="d.text_color ? 'has-text' : varColorClass(d.var)">${{ d.var }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="wp-tgw__preview" data-test="tg-window-preview">
|
||||
<span class="wp-tgw__cap">ON THE NODE</span>
|
||||
<ToggleRowView
|
||||
:t="d"
|
||||
:pick-one="pickOne"
|
||||
:pick-style="pickStyle"
|
||||
:shown-on="previewOn"
|
||||
:eff-on="pickOne ? effOn : d.on && !blocked"
|
||||
:parent-name="parentName"
|
||||
:blocked="blocked"
|
||||
:indent="0"
|
||||
:editing="false"
|
||||
:needs-options="[]"
|
||||
:group-options="[]"
|
||||
group-id=""
|
||||
:outputs-full="outputsFull"
|
||||
:editable="false"
|
||||
:control-state="controlState"
|
||||
@set-on="previewSet"
|
||||
/>
|
||||
</div>
|
||||
<div class="wp-tgw__body">
|
||||
<div class="wp-tgr__edit" :data-test="`tg-editor-${d.id}`">
|
||||
<label class="wp-tgr__field">
|
||||
<span>Name <em>on the row and its output</em></span>
|
||||
<Input size="sm" :model-value="d.name" spellcheck="false" data-test="tg-name" @keydown.stop @change="patch({ name: value($event) })" />
|
||||
</label>
|
||||
<label class="wp-tgr__field">
|
||||
<span>Variable <em>true or false; the globe keeps it internal</em></span>
|
||||
<span class="wp-tgr__var-row">
|
||||
<Input
|
||||
size="sm"
|
||||
mono
|
||||
prefix="$"
|
||||
class="wp-tgr__var"
|
||||
:class="d.var ? varColorClass(d.var) : undefined"
|
||||
:tone="problem ? 'warn' : undefined"
|
||||
:model-value="d.var"
|
||||
placeholder="variable_name"
|
||||
spellcheck="false"
|
||||
data-test="tg-var"
|
||||
@keydown.stop
|
||||
@change="patch({ var: value($event).replace(/^\$/, '') })"
|
||||
/>
|
||||
<span v-tooltip="'Show the $variable under the name'" v-toggle-row class="wp-tgr__check">
|
||||
<Checkbox
|
||||
:model-value="!d.hide_var"
|
||||
size="sm"
|
||||
aria-label="Show the variable under the name"
|
||||
data-test="tg-show-var"
|
||||
@update:model-value="(v: boolean) => patch({ hide_var: v ? undefined : true })"
|
||||
/>
|
||||
<span aria-hidden="true">Show</span>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="wp-tgr__field">
|
||||
<span>Looks <em>icon and colours; None keeps the theme's</em></span>
|
||||
<span class="wp-tgr__looks">
|
||||
<IconPicker
|
||||
:model-value="d.icon ?? null"
|
||||
all
|
||||
size="sm"
|
||||
:color="d.icon_color"
|
||||
aria-label="Icon"
|
||||
data-test="tg-icon"
|
||||
@update:model-value="(v: string | null) => patch({ icon: v || undefined })"
|
||||
/>
|
||||
<span class="wp-tgr__look">
|
||||
<ColorField :model-value="colorValue(d.icon_color)" allow-none presets compact aria-label="Icon colour" data-test="tg-icon-color" @update:model-value="(v: string) => patch({ icon_color: pickColor(v) })" />
|
||||
Icon
|
||||
</span>
|
||||
<span class="wp-tgr__look">
|
||||
<ColorField :model-value="colorValue(d.text_color)" allow-none presets compact aria-label="Name colour" data-test="tg-text-color" @update:model-value="(v: string) => patch({ text_color: pickColor(v) })" />
|
||||
Name
|
||||
</span>
|
||||
<span class="wp-tgr__look">
|
||||
<ColorField :model-value="colorValue(d.tint)" allow-none presets compact aria-label="Control colour" data-test="tg-tint" @update:model-value="(v: string) => patch({ tint: pickColor(v) })" />
|
||||
Control
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="pickOne" class="wp-tgr__field">
|
||||
<span>Value <em>picked variable gets this, not the name</em></span>
|
||||
<RichTextInput
|
||||
class="wp-tgr__value"
|
||||
:model-value="d.value ?? ''"
|
||||
surface="combine"
|
||||
multiline
|
||||
:rows="2"
|
||||
:placeholder="d.name"
|
||||
:var-suggestions="varSuggestions ?? []"
|
||||
:var-producers="varProducers"
|
||||
:graph-aware="!!varProducers"
|
||||
aria-label="Value"
|
||||
data-test="tg-value"
|
||||
@update:model-value="(v: string) => patch({ value: v.trim() ? v : undefined })"
|
||||
/>
|
||||
<Banner v-if="missingVars.length" tone="warn" size="sm" data-test="tg-missing-vars">{{ missingNote }}</Banner>
|
||||
<Banner v-if="hasInlinePicks(d.value ?? '')" tone="warn" size="sm" data-test="tg-inline-picks">{{ INLINE_PICKS_NOTE }}</Banner>
|
||||
<NegativeField
|
||||
:model-value="d.negative"
|
||||
surface="combine"
|
||||
label="toggle"
|
||||
test-id="tg-negative"
|
||||
:var-suggestions="varSuggestions ?? []"
|
||||
:var-producers="varProducers"
|
||||
:graph-aware="!!varProducers"
|
||||
@update:model-value="(v: string | undefined) => patch({ negative: v })"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!pickOne" class="wp-tgr__field">
|
||||
<span>Style</span>
|
||||
<WpSegmented
|
||||
:model-value="d.style"
|
||||
:options="STYLES"
|
||||
size="sm"
|
||||
block
|
||||
aria-label="Style"
|
||||
test-prefix="tg-style"
|
||||
@update:model-value="(v: ToggleStyle) => patch({ style: v })"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="d.style === 'buttons' && !pickOne" class="wp-tgr__two">
|
||||
<label class="wp-tgr__field">
|
||||
<span>On word</span>
|
||||
<Input size="sm" :model-value="d.on_label ?? ''" placeholder="On" data-test="tg-on-label" @keydown.stop @change="patch({ on_label: value($event) || undefined })" />
|
||||
</label>
|
||||
<label class="wp-tgr__field">
|
||||
<span>Off word</span>
|
||||
<Input size="sm" :model-value="d.off_label ?? ''" placeholder="Off" data-test="tg-off-label" @keydown.stop @change="patch({ off_label: value($event) || undefined })" />
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="!pickOne" class="wp-tgr__field">
|
||||
<span>Needs <em>only on while that toggle is on</em></span>
|
||||
<WpSelect
|
||||
:model-value="d.needs ?? ''"
|
||||
:options="needsOptions"
|
||||
aria-label="Needs"
|
||||
data-test="tg-needs"
|
||||
@update:model-value="(v: string) => patch({ needs: v || undefined })"
|
||||
/>
|
||||
</div>
|
||||
<div class="wp-tgr__field">
|
||||
<span>When off <em>also mute or bypass other nodes</em></span>
|
||||
<WpSegmented
|
||||
:model-value="d.control ?? ''"
|
||||
:options="CONTROLS"
|
||||
size="sm"
|
||||
block
|
||||
aria-label="When off"
|
||||
test-prefix="tg-control"
|
||||
@update:model-value="setControl"
|
||||
/>
|
||||
</div>
|
||||
<span v-if="d.control" v-tooltip="'Mute or bypass while the toggle is ON instead, for a node used only as a switchboard'" v-toggle-row class="wp-tgr__check">
|
||||
<Checkbox
|
||||
:model-value="!!d.invert"
|
||||
size="sm"
|
||||
aria-label="Invert"
|
||||
data-test="tg-invert"
|
||||
@update:model-value="(v: boolean) => patch({ invert: v ? true : undefined })"
|
||||
/>
|
||||
<span aria-hidden="true">Invert: {{ d.control === "mute" ? "mute" : "bypass" }} while on</span>
|
||||
</span>
|
||||
<div v-if="d.control" class="wp-tgr__field">
|
||||
<span>Canvas groups <em>wire nodes into the dot in front of the row, or pick groups</em></span>
|
||||
<Combobox
|
||||
:model-value="d.groups ?? []"
|
||||
multiple
|
||||
:options="(canvasGroups ?? []).map((g) => ({ value: g, label: g }))"
|
||||
placeholder="No groups"
|
||||
size="sm"
|
||||
aria-label="Canvas groups"
|
||||
data-test="tg-groups"
|
||||
@update:model-value="setGroups"
|
||||
/>
|
||||
</div>
|
||||
<span v-tooltip="'When the upstream Context holds this variable as a yes/no word (true, on, 1, no …), it sets the switch'" v-toggle-row class="wp-tgr__check">
|
||||
<Checkbox
|
||||
:model-value="!!d.upstream"
|
||||
size="sm"
|
||||
aria-label="Upstream can set it"
|
||||
data-test="tg-upstream"
|
||||
@update:model-value="(v: boolean) => patch({ upstream: v ? true : undefined })"
|
||||
/>
|
||||
<span aria-hidden="true">Upstream can set it</span>
|
||||
</span>
|
||||
<div v-if="groupOptions.length" class="wp-tgr__field">
|
||||
<span>Group</span>
|
||||
<WpSelect
|
||||
:model-value="draftGroup"
|
||||
:options="groupOptions"
|
||||
aria-label="Group"
|
||||
data-test="tg-group"
|
||||
@update:model-value="(v: string) => (draftGroup = v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="wp-tgr__edit-foot">
|
||||
<span v-tooltip="!d.pin && outputsFull ? 'All 16 outputs are in use' : 'Show a BOOLEAN output for switch nodes'" v-toggle-row class="wp-tgr__check">
|
||||
<Checkbox
|
||||
:model-value="d.pin"
|
||||
size="sm"
|
||||
:disabled="!d.pin && outputsFull"
|
||||
aria-label="BOOLEAN output"
|
||||
data-test="tg-pin"
|
||||
@update:model-value="(v: boolean) => patch({ pin: v })"
|
||||
/>
|
||||
<span aria-hidden="true">BOOLEAN output</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<span class="wp-tgw__hint"><Kbd keys="Mod+Enter" size="sm" /> Done · Changes save with the node when you press Done.</span>
|
||||
<Button size="sm" data-test="tg-window-cancel" @click="cancel">Cancel</Button>
|
||||
<Button size="sm" variant="primary" data-test="tg-window-done" @click="emit('done', d, draftGroup)">Done</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
<ConfirmModal
|
||||
:open="asking"
|
||||
title="Discard your changes?"
|
||||
body="What you changed in this window isn't on the node yet."
|
||||
confirm-label="Discard"
|
||||
cancel-label="Keep editing"
|
||||
tone="warn"
|
||||
@confirm="asking = false; emit('cancel')"
|
||||
@cancel="asking = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* The row as the node draws it stays on top while the settings scroll. */
|
||||
.wp-tgw__preview {
|
||||
position: sticky;
|
||||
top: calc(-1 * var(--wp-space-5));
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: calc(-1 * var(--wp-space-5)) calc(-1 * var(--wp-space-5)) var(--wp-space-5);
|
||||
padding: 10px var(--wp-space-5);
|
||||
border-bottom: 1px solid var(--wp-border);
|
||||
background: var(--wp-bg-deep, var(--wp-bg));
|
||||
}
|
||||
.wp-tgw__icon { color: var(--wp-tgw-icon, var(--wp-tgw-tint, var(--wp-accent))); font-size: 14px; }
|
||||
.wp-tgw__titles { flex: 1; min-width: 0; }
|
||||
.wp-tgw__title-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.wp-tgw__title { margin: 0; font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.wp-tgw__chip {
|
||||
flex: none;
|
||||
padding: 2px 6px;
|
||||
border-radius: min(2px, var(--wp-radius-cap, 999px));
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: color-mix(in srgb, var(--wp-tgw-tint, var(--wp-accent)) 22%, transparent);
|
||||
color: var(--wp-tgw-tint, var(--wp-accent));
|
||||
}
|
||||
.wp-tgw__subname { color: var(--wp-tgw-text, var(--wp-text)); font-weight: 600; }
|
||||
.wp-tgw__subvar { font-family: var(--wp-font-mono, monospace); }
|
||||
.wp-tgw__subvar.has-text { color: var(--wp-tgw-text); }
|
||||
.wp-tgw__sub { margin-top: 3px; font-size: 11px; color: var(--wp-text-dim); }
|
||||
.wp-tgw__hint { display: inline-flex; align-items: center; gap: 4px; margin-right: auto; font-size: 11px; color: var(--wp-text-dim); }
|
||||
.wp-tgw__cap { font: 600 9px var(--wp-font-sans, sans-serif); letter-spacing: 0.08em; color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
/* The window is the panel: no second box around it. */
|
||||
.wp-tgw__body .wp-tgr__edit { padding: 0; border: 0; background: none; }
|
||||
.wp-tgr__edit { display: flex; flex-direction: column; gap: 10px; }
|
||||
.wp-tgr__field { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.wp-tgr__field > span { font-size: 10.5px; font-weight: 600; color: var(--wp-text-muted, var(--wp-text2)); }
|
||||
.wp-tgr__field em { font-style: normal; font-weight: 400; color: var(--wp-text-dim, var(--wp-text3)); margin-left: 4px; }
|
||||
.wp-tgr__two { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; }
|
||||
.wp-tgr__edit-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.wp-tgr__check { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; cursor: pointer; }
|
||||
.wp-tgr__var-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.wp-tgr__var-row .wp-tgr__var { flex: 1 1 0; min-width: 80px; }
|
||||
.wp-tgr__looks { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; }
|
||||
.wp-tgr__look { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--wp-text-muted, var(--wp-text2)); }
|
||||
</style>
|
||||
@@ -1,31 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* One toggle row of WP Toggles, plus its edit panel when open. Stateless:
|
||||
* One toggle row of WP Toggles (its settings open in `ToggleEditWindow.vue`). Stateless:
|
||||
* the parent (`TogglesWidget.vue`) works out every fact shown here and
|
||||
* applies every change this emits.
|
||||
*
|
||||
* The row carries `data-wp-out="toggle_<out>"` while it has an output, so
|
||||
* `extension/row-outputs.ts` puts that BOOLEAN dot at the row's right edge.
|
||||
*/
|
||||
import Button from "@/kit/Button.vue";
|
||||
import Input from "@/kit/Input.vue";
|
||||
import WpIconButton from "@/kit/WpIconButton.vue";
|
||||
import WpSegmented from "@/kit/WpSegmented.vue";
|
||||
import WpSwitch from "@/kit/WpSwitch.vue";
|
||||
import Checkbox from "@/kit/Checkbox.vue";
|
||||
import WpSelect from "@/kit/WpSelect.vue";
|
||||
import Combobox from "@/kit/Combobox.vue";
|
||||
import { vTooltip } from "@/kit/tooltip";
|
||||
import { varColorClass } from "../shared/var-color";
|
||||
import { computed, defineAsyncComponent } from "vue";
|
||||
import type { PickStyle, ToggleControl, ToggleRow, ToggleStyle } from "../../extension/toggles";
|
||||
import { computed } from "vue";
|
||||
import type { PickStyle, ToggleRow, ToggleStyle } from "../../extension/toggles";
|
||||
import { ctlInputName, type ControlState } from "../../extension/toggle-control";
|
||||
import { vToggleRow } from "@/kit/toggle-row";
|
||||
import type { VarProducerLike } from "../../manager/components/RefChip.vue";
|
||||
import { INLINE_PICKS_NOTE, hasInlinePicks, tokenize } from "../context/editors/_shared/preview-tokens";
|
||||
|
||||
// Loaded with the edit panel only: the clean view never needs the editor.
|
||||
const RichTextInput = defineAsyncComponent(() => import("../../manager/components/RichTextInput.vue"));
|
||||
|
||||
interface Choice { value: string; label: string }
|
||||
|
||||
@@ -68,50 +59,26 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
"set-on": [on: boolean];
|
||||
patch: [patch: Partial<ToggleRow>];
|
||||
rename: [name: string];
|
||||
"set-var": [v: string];
|
||||
edit: [];
|
||||
"move-group": [labelId: string];
|
||||
remove: [];
|
||||
}>();
|
||||
|
||||
const CONTROLS = [
|
||||
{ value: "", label: "Nothing", hint: "Off only writes false and sends False" },
|
||||
{ value: "mute", label: "Mute", hint: "Off also mutes the nodes wired into the dot in front of the row" },
|
||||
{ value: "bypass", label: "Bypass", hint: "Off also bypasses the nodes wired into the dot in front of the row" },
|
||||
];
|
||||
function setControl(v: string): void {
|
||||
emit("patch", v ? { control: v as ToggleControl } : { control: undefined, groups: undefined });
|
||||
}
|
||||
function setGroups(v: string[] | string | null): void {
|
||||
const list = Array.isArray(v) ? v : [];
|
||||
emit("patch", { groups: list.length ? list : undefined });
|
||||
}
|
||||
|
||||
const STYLES: { value: ToggleStyle; label: string; hint: string }[] = [
|
||||
{ value: "switch", label: "Switch", hint: "A sliding switch" },
|
||||
{ value: "checkbox", label: "Check", hint: "A checkbox" },
|
||||
{ value: "buttons", label: "Buttons", hint: "Two buttons with your own words, e.g. Day / Night" },
|
||||
{ value: "chip", label: "Chip", hint: "A small chip with the name, lit when on" },
|
||||
];
|
||||
|
||||
/** `$names` the Pick one Value reads that nothing upstream (nor this node)
|
||||
* sets. Only with a graph to look at (`varSuggestions` given). */
|
||||
const missingVars = computed(() => {
|
||||
const known = props.varSuggestions;
|
||||
if (!props.pickOne || !known || !props.t.value?.includes("$")) return [];
|
||||
const names = tokenize(props.t.value, "assembler")
|
||||
.filter((tok) => tok.kind === "var" && tok.varName)
|
||||
.map((tok) => tok.varName ?? "");
|
||||
return [...new Set(names)].filter((n) => !known.includes(n));
|
||||
/** The row's icon and name colours as CSS variables (absent: the theme's). */
|
||||
const looks = computed(() => {
|
||||
const s: Record<string, string> = {};
|
||||
if (props.t.icon_color) s["--wp-tg-icon"] = props.t.icon_color;
|
||||
if (props.t.text_color) s["--wp-tg-text"] = props.t.text_color;
|
||||
return s;
|
||||
});
|
||||
const missingNote = computed(() => {
|
||||
const list = missingVars.value.map((v) => "$" + v).join(", ");
|
||||
return missingVars.value.length > 1
|
||||
? `${list}: nothing upstream sets these, so they print nothing`
|
||||
: `${list}: nothing upstream sets this, so it prints nothing`;
|
||||
/** `tint` stands in for the accent the control is drawn with. Set on the
|
||||
* control only, so the row's own buttons (globe) keep the theme's. */
|
||||
const tintStyle = computed(() => {
|
||||
const c = props.t.tint;
|
||||
if (!c) return undefined;
|
||||
const s: Record<string, string> = {};
|
||||
for (const k of ["--wp-accent", "--wp-accent-text", "--wp-accent-500", "--wp-accent-600", "--check-on"]) s[k] = c;
|
||||
return s;
|
||||
});
|
||||
|
||||
/** The control this row draws: Pick one's shared look, else its own style. */
|
||||
const look = computed<ToggleStyle | "radio">(() => (props.pickOne ? props.pickStyle ?? "radio" : props.t.style));
|
||||
|
||||
@@ -126,9 +93,6 @@ function subClass(): string {
|
||||
if (props.parentName) return props.blocked ? "is-warn" : "is-linked";
|
||||
return props.t.var ? varColorClass(props.t.var) : "is-dim";
|
||||
}
|
||||
function value(ev: Event): string {
|
||||
return (ev.target as HTMLInputElement).value.trim();
|
||||
}
|
||||
function internalHint(): string {
|
||||
const v = `$${props.t.var}`;
|
||||
return props.t.visible
|
||||
@@ -144,22 +108,30 @@ function flip(): void {
|
||||
<div class="wp-tgr" :style="indent ? { marginLeft: `${indent}px` } : undefined">
|
||||
<div
|
||||
class="wp-tgr__row"
|
||||
:class="{ 'is-blocked': blocked, 'is-on': effOn, 'is-chip': look === 'chip', 'is-editing': editing }"
|
||||
:class="{ 'is-blocked': blocked, 'is-on': effOn, 'is-chip': look === 'chip', 'is-editing': editing, 'has-icon-color': !!t.icon_color, 'has-text-color': !!t.text_color }"
|
||||
:data-wp-out="t.pin && t.out ? `toggle_${t.out}` : undefined"
|
||||
:data-wp-socket="t.control ? ctlInputName(t.id) : undefined"
|
||||
:data-test="`tg-row-${t.id}`"
|
||||
:style="looks"
|
||||
>
|
||||
<template v-if="look === 'chip'">
|
||||
<span v-tooltip="blocked ? blockedHint() : t.var ? `$${t.var}` : ''" class="wp-tgr__chip">
|
||||
<span v-tooltip="blocked ? blockedHint() : t.var ? `$${t.var}` : ''" class="wp-tgr__chip" :style="tintStyle">
|
||||
<WpSwitch
|
||||
:model-value="shownOn"
|
||||
variant="chip"
|
||||
size="sm"
|
||||
:label="t.name"
|
||||
:icon="t.icon ? `pi-${t.icon}` : undefined"
|
||||
:disabled="blocked"
|
||||
:data-test="`tg-ctl-${t.id}`"
|
||||
@update:model-value="(v: boolean) => emit('set-on', v)"
|
||||
/>
|
||||
>
|
||||
<span class="wp-tgr__chipname">{{ t.name }}</span>
|
||||
<template v-if="t.var && !t.hide_var">
|
||||
<span class="wp-tgr__chipdot" aria-hidden="true">·</span>
|
||||
<span class="wp-tgr__chipvar" :class="varColorClass(t.var)" :data-test="`tg-chip-var-${t.id}`">${{ t.var }}</span>
|
||||
</template>
|
||||
</WpSwitch>
|
||||
</span>
|
||||
<span v-if="parentName" class="wp-tgr__sub" :class="subClass()"><i class="pi pi-link" aria-hidden="true" />{{ sub() }}</span>
|
||||
<i
|
||||
@@ -171,7 +143,11 @@ function flip(): void {
|
||||
:aria-label="controlState.hint"
|
||||
/>
|
||||
</template>
|
||||
<span v-else class="wp-tgr__name">
|
||||
<template v-else>
|
||||
<!-- The icon is a column of its own, centred against the name and
|
||||
its $var, so both lines start at the same edge. -->
|
||||
<i v-if="t.icon" class="pi wp-tgr__icon" :class="`pi-${t.icon}`" aria-hidden="true" :data-test="`tg-icon-${t.id}`" />
|
||||
<span class="wp-tgr__name">
|
||||
<span class="wp-tgr__title">
|
||||
<b>{{ t.name }}</b>
|
||||
<i
|
||||
@@ -188,6 +164,7 @@ function flip(): void {
|
||||
<i v-if="t.upstream" v-tooltip="'Upstream can set it'" class="pi pi-arrow-down-right wp-tgr__up" aria-label="Upstream can set it" />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<i
|
||||
v-if="problem"
|
||||
v-tooltip="problem"
|
||||
@@ -195,30 +172,39 @@ function flip(): void {
|
||||
role="img"
|
||||
:aria-label="problem"
|
||||
/>
|
||||
<WpIconButton
|
||||
v-if="editable && t.var"
|
||||
icon="pi-globe"
|
||||
tone="accent"
|
||||
:pressed="!t.visible"
|
||||
:label="t.visible ? 'Mark internal' : 'Unmark internal'"
|
||||
:hint="internalHint()"
|
||||
:data-test="`tg-internal-${t.id}`"
|
||||
@click="emit('patch', { visible: t.visible ? undefined : true })"
|
||||
/>
|
||||
<WpIconButton
|
||||
v-if="editable"
|
||||
icon="pi-pencil"
|
||||
:pressed="editing"
|
||||
:label="`Edit ${t.name}`"
|
||||
:data-test="`tg-edit-${t.id}`"
|
||||
@click="emit('edit')"
|
||||
/>
|
||||
<span v-if="editable" class="wp-tgr__btns">
|
||||
<WpIconButton
|
||||
icon="pi-pencil"
|
||||
:pressed="editing"
|
||||
:label="`Edit ${t.name}`"
|
||||
:data-test="`tg-edit-${t.id}`"
|
||||
@click="emit('edit')"
|
||||
/>
|
||||
<WpIconButton
|
||||
v-if="t.var"
|
||||
icon="pi-globe"
|
||||
tone="accent"
|
||||
:pressed="!t.visible"
|
||||
:label="t.visible ? 'Mark internal' : 'Unmark internal'"
|
||||
:hint="internalHint()"
|
||||
:data-test="`tg-internal-${t.id}`"
|
||||
@click="emit('patch', { visible: t.visible ? undefined : true })"
|
||||
/>
|
||||
<WpIconButton
|
||||
icon="pi-trash"
|
||||
tone="danger"
|
||||
:label="`Remove ${t.name}`"
|
||||
:data-test="`tg-remove-${t.id}`"
|
||||
@click="emit('remove')"
|
||||
/>
|
||||
</span>
|
||||
<template v-if="look === 'radio'">
|
||||
<span
|
||||
v-tooltip="shownOn ? 'Picked' : 'Pick this one'"
|
||||
role="radio"
|
||||
tabindex="0"
|
||||
class="wp-tgr__radio"
|
||||
:style="tintStyle"
|
||||
:class="{ 'is-on': shownOn }"
|
||||
:aria-checked="shownOn"
|
||||
:aria-label="t.name"
|
||||
@@ -228,7 +214,7 @@ function flip(): void {
|
||||
@keydown.enter.prevent.stop="flip"
|
||||
/>
|
||||
</template>
|
||||
<span v-else-if="look === 'checkbox'" v-tooltip="blockedHint()" class="wp-tgr__ctl">
|
||||
<span v-else-if="look === 'checkbox'" v-tooltip="blockedHint()" class="wp-tgr__ctl" :style="tintStyle">
|
||||
<Checkbox
|
||||
:model-value="shownOn"
|
||||
:disabled="blocked"
|
||||
@@ -237,7 +223,7 @@ function flip(): void {
|
||||
@update:model-value="(v: boolean) => emit('set-on', v)"
|
||||
/>
|
||||
</span>
|
||||
<span v-else-if="look === 'buttons'" v-tooltip="blockedHint()" class="wp-tgr__ctl">
|
||||
<span v-else-if="look === 'buttons'" v-tooltip="blockedHint()" class="wp-tgr__ctl" :style="tintStyle">
|
||||
<WpSegmented
|
||||
:model-value="t.on ? 'on' : 'off'"
|
||||
:options="[{ value: 'on', label: t.on_label || 'On' }, { value: 'off', label: t.off_label || 'Off' }]"
|
||||
@@ -248,7 +234,7 @@ function flip(): void {
|
||||
@update:model-value="(v: string) => emit('set-on', v === 'on')"
|
||||
/>
|
||||
</span>
|
||||
<span v-else-if="look === 'switch'" v-tooltip="blockedHint()" class="wp-tgr__ctl">
|
||||
<span v-else-if="look === 'switch'" v-tooltip="blockedHint()" class="wp-tgr__ctl" :style="tintStyle">
|
||||
<WpSwitch
|
||||
:model-value="shownOn"
|
||||
size="sm"
|
||||
@@ -260,174 +246,6 @@ function flip(): void {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="editable && editing" class="wp-tgr__edit" :data-test="`tg-editor-${t.id}`">
|
||||
<div class="wp-tgr__edit-head">
|
||||
<span class="wp-tgr__cap">EDIT TOGGLE</span>
|
||||
<Button variant="ghost" icon="times" size="sm" aria-label="Done" @click="emit('edit')" />
|
||||
</div>
|
||||
<label class="wp-tgr__field">
|
||||
<span>Name <em>on the row and its output</em></span>
|
||||
<Input size="sm" :model-value="t.name" spellcheck="false" data-test="tg-name" @keydown.stop @change="emit('rename', value($event))" />
|
||||
</label>
|
||||
<label class="wp-tgr__field">
|
||||
<span>Variable <em>true or false; the globe keeps it internal</em></span>
|
||||
<span class="wp-tgr__var-row">
|
||||
<Input
|
||||
size="sm"
|
||||
mono
|
||||
prefix="$"
|
||||
class="wp-tgr__var"
|
||||
:class="t.var ? varColorClass(t.var) : undefined"
|
||||
:tone="problem ? 'warn' : undefined"
|
||||
:model-value="t.var"
|
||||
placeholder="variable_name"
|
||||
spellcheck="false"
|
||||
data-test="tg-var"
|
||||
@keydown.stop
|
||||
@change="emit('set-var', value($event).replace(/^\$/, ''))"
|
||||
/>
|
||||
<span v-tooltip="'Show the $variable under the name'" v-toggle-row class="wp-tgr__check">
|
||||
<Checkbox
|
||||
:model-value="!t.hide_var"
|
||||
size="sm"
|
||||
aria-label="Show the variable under the name"
|
||||
data-test="tg-show-var"
|
||||
@update:model-value="(v: boolean) => emit('patch', { hide_var: v ? undefined : true })"
|
||||
/>
|
||||
<span aria-hidden="true">Show</span>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<div v-if="pickOne" class="wp-tgr__field">
|
||||
<span>Value <em>picked variable gets this, not the name</em></span>
|
||||
<RichTextInput
|
||||
class="wp-tgr__value"
|
||||
:model-value="t.value ?? ''"
|
||||
surface="combine"
|
||||
multiline
|
||||
:rows="2"
|
||||
:placeholder="t.name"
|
||||
:var-suggestions="varSuggestions ?? []"
|
||||
:var-producers="varProducers"
|
||||
:graph-aware="!!varProducers"
|
||||
aria-label="Value"
|
||||
data-test="tg-value"
|
||||
@update:model-value="(v: string) => emit('patch', { value: v.trim() ? v : undefined })"
|
||||
/>
|
||||
<p v-if="missingVars.length" class="wp-tgr__note" data-test="tg-missing-vars">
|
||||
<i class="pi pi-exclamation-triangle" aria-hidden="true" />{{ missingNote }}
|
||||
</p>
|
||||
<p v-if="hasInlinePicks(t.value ?? '')" class="wp-tgr__note" data-test="tg-inline-picks">
|
||||
<i class="pi pi-exclamation-triangle" aria-hidden="true" />{{ INLINE_PICKS_NOTE }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="wp-tgr__field">
|
||||
<span>Style <em v-if="pickOne">Pick one sets every row's look at the top</em></span>
|
||||
<WpSegmented
|
||||
:model-value="t.style"
|
||||
:options="STYLES"
|
||||
size="sm"
|
||||
block
|
||||
:disabled="pickOne"
|
||||
aria-label="Style"
|
||||
test-prefix="tg-style"
|
||||
@update:model-value="(v: ToggleStyle) => emit('patch', { style: v })"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="t.style === 'buttons' && !pickOne" class="wp-tgr__two">
|
||||
<label class="wp-tgr__field">
|
||||
<span>On word</span>
|
||||
<Input size="sm" :model-value="t.on_label ?? ''" placeholder="On" data-test="tg-on-label" @keydown.stop @change="emit('patch', { on_label: value($event) || undefined })" />
|
||||
</label>
|
||||
<label class="wp-tgr__field">
|
||||
<span>Off word</span>
|
||||
<Input size="sm" :model-value="t.off_label ?? ''" placeholder="Off" data-test="tg-off-label" @keydown.stop @change="emit('patch', { off_label: value($event) || undefined })" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="wp-tgr__field">
|
||||
<span>Needs <em>{{ pickOne ? "not used while Pick one is on" : "only on while that toggle is on" }}</em></span>
|
||||
<WpSelect
|
||||
:model-value="t.needs ?? ''"
|
||||
:options="needsOptions"
|
||||
aria-label="Needs"
|
||||
data-test="tg-needs"
|
||||
:disabled="pickOne"
|
||||
@update:model-value="(v: string) => emit('patch', { needs: v || undefined })"
|
||||
/>
|
||||
</div>
|
||||
<div class="wp-tgr__field">
|
||||
<span>When off <em>also mute or bypass other nodes</em></span>
|
||||
<WpSegmented
|
||||
:model-value="t.control ?? ''"
|
||||
:options="CONTROLS"
|
||||
size="sm"
|
||||
block
|
||||
aria-label="When off"
|
||||
test-prefix="tg-control"
|
||||
@update:model-value="setControl"
|
||||
/>
|
||||
</div>
|
||||
<span v-if="t.control" v-tooltip="'Mute or bypass while the toggle is ON instead, for a node used only as a switchboard'" v-toggle-row class="wp-tgr__check">
|
||||
<Checkbox
|
||||
:model-value="!!t.invert"
|
||||
size="sm"
|
||||
aria-label="Invert"
|
||||
data-test="tg-invert"
|
||||
@update:model-value="(v: boolean) => emit('patch', { invert: v ? true : undefined })"
|
||||
/>
|
||||
<span aria-hidden="true">Invert: {{ t.control === "mute" ? "mute" : "bypass" }} while on</span>
|
||||
</span>
|
||||
<div v-if="t.control" class="wp-tgr__field">
|
||||
<span>Canvas groups <em>wire nodes into the dot in front of the row, or pick groups</em></span>
|
||||
<Combobox
|
||||
:model-value="t.groups ?? []"
|
||||
multiple
|
||||
:options="(canvasGroups ?? []).map((g) => ({ value: g, label: g }))"
|
||||
placeholder="No groups"
|
||||
size="sm"
|
||||
aria-label="Canvas groups"
|
||||
data-test="tg-groups"
|
||||
@update:model-value="setGroups"
|
||||
/>
|
||||
</div>
|
||||
<span v-tooltip="'When the upstream Context holds this variable as a yes/no word (true, on, 1, no …), it sets the switch'" v-toggle-row class="wp-tgr__check">
|
||||
<Checkbox
|
||||
:model-value="!!t.upstream"
|
||||
size="sm"
|
||||
aria-label="Upstream can set it"
|
||||
data-test="tg-upstream"
|
||||
@update:model-value="(v: boolean) => emit('patch', { upstream: v ? true : undefined })"
|
||||
/>
|
||||
<span aria-hidden="true">Upstream can set it</span>
|
||||
</span>
|
||||
<div v-if="groupOptions.length" class="wp-tgr__field">
|
||||
<span>Group</span>
|
||||
<WpSelect
|
||||
:model-value="groupId"
|
||||
:options="groupOptions"
|
||||
aria-label="Group"
|
||||
data-test="tg-group"
|
||||
@update:model-value="(v: string) => emit('move-group', v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="wp-tgr__edit-foot">
|
||||
<span v-tooltip="!t.pin && outputsFull ? 'All 16 outputs are in use' : 'Show a BOOLEAN output for switch nodes'" v-toggle-row class="wp-tgr__check">
|
||||
<Checkbox
|
||||
:model-value="t.pin"
|
||||
size="sm"
|
||||
:disabled="!t.pin && outputsFull"
|
||||
aria-label="BOOLEAN output"
|
||||
data-test="tg-pin"
|
||||
@update:model-value="(v: boolean) => emit('patch', { pin: v })"
|
||||
/>
|
||||
<span aria-hidden="true">BOOLEAN output</span>
|
||||
</span>
|
||||
<span class="wp-tgr__foot-btns">
|
||||
<Button variant="ghost" size="sm" icon="pi-trash" data-test="tg-delete" @click="emit('remove')">Remove</Button>
|
||||
<Button variant="primary" size="sm" icon="pi-check" data-test="tg-done" @click="emit('edit')">Done</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -491,16 +309,57 @@ function flip(): void {
|
||||
.wp-tgr__sub.is-warn { font-family: var(--wp-font-sans, sans-serif); color: var(--wp-amber, var(--wp-warn)); }
|
||||
.wp-tgr__sub.is-dim { font-family: var(--wp-font-sans, sans-serif); font-style: italic; color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
.wp-tgr__title { display: flex; align-items: center; gap: 5px; min-width: 0; }
|
||||
/* A fixed box with the glyph centred in it: the icon font's own line box sat
|
||||
* the glyph a pixel or two off the name's middle. */
|
||||
.wp-tgr__icon {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
color: var(--wp-tg-icon, var(--wp-text-muted, var(--wp-text2)));
|
||||
}
|
||||
/* The chip says "Name · $var" (the var when Show is on). */
|
||||
.wp-tgr__chipname {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 16px;
|
||||
color: var(--wp-tg-text, inherit);
|
||||
}
|
||||
/* A name colour carries over to its variable (row line and chip). */
|
||||
.wp-tgr__row.has-text-color .wp-tgr__chipvar,
|
||||
.wp-tgr__row.has-text-color .wp-tgr__sub:not(.is-linked):not(.is-warn) { color: var(--wp-tg-text); }
|
||||
.wp-tgr__chipdot { flex: none; line-height: 16px; color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
.wp-tgr__chipvar {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font: 10.5px/16px var(--wp-font-mono, monospace);
|
||||
}
|
||||
.wp-tgr__chip :deep(.wp-switch--chip .wp-switch__icon) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
.wp-tgr__name b { color: var(--wp-tg-text, inherit); }
|
||||
/* Own colours win over the chip's lit accent, so a yellow sun stays yellow. */
|
||||
.wp-tgr__row.has-icon-color .wp-tgr__chip :deep(.wp-switch--chip .wp-switch__icon) { color: var(--wp-tg-icon); }
|
||||
.wp-tgr__btns { display: inline-flex; align-items: center; gap: 2px; flex: none; }
|
||||
/* Mute / bypass icon: dim while it isn't acting, coloured while it is
|
||||
* (bypass in the host's bypass violet, mute in amber). */
|
||||
.wp-tgr__ctlicon { flex: none; font-size: 10px; color: var(--wp-text-dim, var(--wp-text3)); opacity: 0.7; }
|
||||
.wp-tgr__ctlicon.is-active { opacity: 1; }
|
||||
.wp-tgr__ctlicon.is-active.is-bypass { color: var(--wp-violet); }
|
||||
.wp-tgr__ctlicon.is-active.is-mute { color: var(--wp-amber, var(--wp-warn)); }
|
||||
.wp-tgr__var-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.wp-tgr__var-row .wp-tgr__var { flex: 1 1 0; min-width: 80px; }
|
||||
.wp-tgr__up { font-size: 9px; color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
.wp-tgr__note { margin: 0; display: flex; align-items: baseline; gap: 5px; font-size: 10.5px; line-height: 1.4; color: var(--wp-amber, var(--wp-warn)); }
|
||||
.wp-tgr__warn { font-size: 11px; color: var(--wp-amber, var(--wp-warn)); }
|
||||
.wp-tgr__ctl { display: inline-flex; flex: none; }
|
||||
.wp-tgr__radio {
|
||||
@@ -526,27 +385,4 @@ function flip(): void {
|
||||
}
|
||||
.wp-tgr__radio:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--wp-border-focus, var(--wp-accent)); }
|
||||
|
||||
/* Edit panel */
|
||||
.wp-tgr__edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background: var(--wp-bg-deep, var(--wp-bg));
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: min(4px, var(--wp-radius-cap, 999px));
|
||||
}
|
||||
.wp-tgr__edit-head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.wp-tgr__cap {
|
||||
font: 600 9px var(--wp-font-sans, sans-serif);
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--wp-text-dim, var(--wp-text-muted, var(--wp-text2)));
|
||||
}
|
||||
.wp-tgr__field { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.wp-tgr__field > span { font-size: 10.5px; font-weight: 600; color: var(--wp-text-muted, var(--wp-text2)); }
|
||||
.wp-tgr__field em { font-style: normal; font-weight: 400; color: var(--wp-text-dim, var(--wp-text3)); margin-left: 4px; }
|
||||
.wp-tgr__two { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; }
|
||||
.wp-tgr__edit-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.wp-tgr__foot-btns { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.wp-tgr__check { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; cursor: pointer; }
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import TogglesWidget from "./TogglesWidget.vue";
|
||||
import { defaultTogglesConfig } from "../../extension/toggles";
|
||||
import { defaultTogglesConfig, type ToggleRow, type TogglesConfig } from "../../extension/toggles";
|
||||
|
||||
describe("TogglesWidget edit request (toolbar pencil)", () => {
|
||||
it("flips the edit view each time the request is bumped", async () => {
|
||||
const w = mount(TogglesWidget, { props: { modelValue: defaultTogglesConfig(), editRequest: 0 } });
|
||||
const cfg: TogglesConfig = {
|
||||
...defaultTogglesConfig(),
|
||||
rows: [
|
||||
{ id: "00000001", kind: "toggle", name: "Day", var: "day", on: true, pin: false, out: 0, style: "switch", icon: "sun" },
|
||||
{ id: "aaaa0001", kind: "label", name: "Time", color: 2, hex: "#ffaa00", icon: "clock" },
|
||||
{ id: "00000002", kind: "toggle", name: "Night", var: "night", on: false, pin: false, out: 0, style: "switch" },
|
||||
],
|
||||
};
|
||||
|
||||
/** The toggle window is teleported to body and loads lazily. */
|
||||
async function settle(): Promise<void> {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await vi.dynamicImportSettled();
|
||||
await flushPromises();
|
||||
}
|
||||
}
|
||||
function q(sel: string): HTMLElement | null {
|
||||
return document.body.querySelector<HTMLElement>(sel);
|
||||
}
|
||||
function lastConfig(w: { emitted: (e: string) => unknown[][] | undefined }): TogglesConfig | undefined {
|
||||
const all = w.emitted("update:modelValue");
|
||||
return all?.[all.length - 1]?.[0] as TogglesConfig | undefined;
|
||||
}
|
||||
|
||||
describe("TogglesWidget edit view (toolbar pencil)", () => {
|
||||
it("flips the edit view in the node each time the request is bumped", async () => {
|
||||
const w = mount(TogglesWidget, { props: { modelValue: cfg, editRequest: 0 } });
|
||||
expect(w.find(".wp-tg--edit").exists()).toBe(false);
|
||||
await w.setProps({ editRequest: 1 });
|
||||
expect(w.find(".wp-tg--edit").exists()).toBe(true);
|
||||
@@ -13,4 +37,65 @@ describe("TogglesWidget edit request (toolbar pencil)", () => {
|
||||
expect(w.find(".wp-tg--edit").exists()).toBe(false);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("keeps the group buttons in the node", async () => {
|
||||
const w = mount(TogglesWidget, { props: { modelValue: cfg, editRequest: 0 } });
|
||||
await w.setProps({ editRequest: 1 });
|
||||
expect(w.find('[data-test="tg-color-aaaa0001"]').exists()).toBe(true);
|
||||
expect(w.find('[data-test="tg-label-icon-aaaa0001"]').exists()).toBe(true);
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TogglesWidget toggle window", () => {
|
||||
it("a row's pencil opens its window; Done stores the draft and the variable follows the name", async () => {
|
||||
const w = mount(TogglesWidget, { attachTo: document.body, props: { modelValue: cfg, editRequest: 0, nodeTitle: "Light" } });
|
||||
await w.setProps({ editRequest: 1 });
|
||||
await w.find('[data-test="tg-edit-00000001"]').trigger("click");
|
||||
await settle();
|
||||
expect(q('[data-test="modal"]')?.textContent).toContain("Light");
|
||||
const name = q('[data-test="tg-name"]') as HTMLInputElement | null;
|
||||
if (name) {
|
||||
name.value = "Sunrise";
|
||||
name.dispatchEvent(new Event("change"));
|
||||
}
|
||||
await settle();
|
||||
expect(w.emitted("update:modelValue")).toBeUndefined();
|
||||
q('[data-test="tg-window-done"]')?.click();
|
||||
await settle();
|
||||
const row = lastConfig(w)?.rows.find((r): r is ToggleRow => r.id === "00000001" && r.kind === "toggle");
|
||||
expect(row).toMatchObject({ name: "Sunrise", var: "sunrise", icon: "sun" });
|
||||
expect(q('[data-test="modal"]')).toBeNull();
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("Done moves the toggle to the group picked in its window", async () => {
|
||||
const w = mount(TogglesWidget, { attachTo: document.body, props: { modelValue: cfg, editRequest: 0 } });
|
||||
await w.setProps({ editRequest: 1 });
|
||||
await w.find('[data-test="tg-edit-00000002"]').trigger("click");
|
||||
await settle();
|
||||
// Night sits under "Time"; Top moves it above the label.
|
||||
const win = w.findComponent({ name: "ToggleEditWindow" });
|
||||
expect(win.exists()).toBe(true);
|
||||
win.vm.$emit("done", cfg.rows[2], "");
|
||||
await settle();
|
||||
expect(lastConfig(w)?.rows.map((r) => r.id)).toEqual(["00000001", "00000002", "aaaa0001"]);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("the row's trash removes the toggle", async () => {
|
||||
const w = mount(TogglesWidget, { props: { modelValue: cfg, editRequest: 0 } });
|
||||
await w.setProps({ editRequest: 1 });
|
||||
await w.find('[data-test="tg-remove-00000002"]').trigger("click");
|
||||
expect(lastConfig(w)?.rows.map((r) => r.id)).toEqual(["00000001", "aaaa0001"]);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("wears the label's own colour and icon", () => {
|
||||
const w = mount(TogglesWidget, { props: { modelValue: cfg } });
|
||||
const label = w.find('[data-test="tg-label-aaaa0001"]');
|
||||
expect(label.attributes("style")).toContain("color: rgb(255, 170, 0)");
|
||||
expect(label.find(".pi-clock").exists()).toBe(true);
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,21 +9,25 @@
|
||||
*
|
||||
* Two views: the clean view shows only the rows and their controls; the
|
||||
* pencil in ComfyUI's selection toolbar (`editRequest`) opens the edit view
|
||||
* (mode, grips, row editors, add buttons) and Done closes it. A node with no toggles opens in the edit view.
|
||||
* The view is per session, never saved.
|
||||
* (mode, Pick one options, grips, row buttons, add buttons) and Done closes
|
||||
* it. A node with no toggles opens in the edit view. The view is per
|
||||
* session, never saved. A row's pencil opens that toggle's own settings in
|
||||
* a window (`ToggleEditWindow.vue`) with a preview of the row.
|
||||
*
|
||||
* Toggles belong to the label above them. Labels move with their toggles
|
||||
* (outer list), toggles move inside their group (inner lists); the edit
|
||||
* panel's Group field moves one to another label.
|
||||
* Toggles belong to the label above them. One sortable list holds labels
|
||||
* and toggles, so a toggle drags into or out of a group and a label drags
|
||||
* with its toggles (`dragRow`); the edit panel's Group field moves one too.
|
||||
*
|
||||
* Value contract: `modelValue` is a `TogglesConfig`; every edit emits the
|
||||
* whole next config (`src/widgets/toggles.ts` fits the outputs to it).
|
||||
*/
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { computed, defineAsyncComponent, nextTick, ref, watch } from "vue";
|
||||
import Button from "@/kit/Button.vue";
|
||||
import Input from "@/kit/Input.vue";
|
||||
import WpIconButton from "@/kit/WpIconButton.vue";
|
||||
import WpSegmented from "@/kit/WpSegmented.vue";
|
||||
import ColorField from "@/kit/ColorField.vue";
|
||||
import IconPicker from "@/kit/IconPicker.vue";
|
||||
import SortableList from "@/kit/SortableList.vue";
|
||||
import Checkbox from "@/kit/Checkbox.vue";
|
||||
import { vTooltip } from "@/kit/tooltip";
|
||||
@@ -56,6 +60,9 @@ import type { ControlState } from "../../extension/toggle-control";
|
||||
import type { VarProducerLike } from "../../manager/components/RefChip.vue";
|
||||
import { vToggleRow } from "@/kit/toggle-row";
|
||||
|
||||
// The window loads on first open only: most canvases never edit a toggle.
|
||||
const ToggleEditWindow = defineAsyncComponent(() => import("./ToggleEditWindow.vue"));
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: TogglesConfig;
|
||||
@@ -65,15 +72,20 @@ const props = withDefaults(
|
||||
controls?: Record<string, ControlState>;
|
||||
/** Canvas group titles, for the control's group picker. */
|
||||
canvasGroups?: string[];
|
||||
/** Bumped by the pencil in ComfyUI's selection toolbar: flips the
|
||||
* edit view (extension/node-toolbar.ts). */
|
||||
/** Bumped by the pencil in ComfyUI's selection toolbar: opens the
|
||||
* edit window (extension/node-toolbar.ts). */
|
||||
editRequest?: number;
|
||||
/** Variables upstream of the node, for the Pick one Value's `$` list. */
|
||||
upstreamVars?: string[];
|
||||
/** Who writes each upstream variable (the Value's chip hover card). */
|
||||
varProducers?: Map<string, VarProducerLike> | undefined;
|
||||
/** The node's title, for the toggle window's header. */
|
||||
nodeTitle?: string;
|
||||
}>(),
|
||||
{ nodeMode: 0, controls: () => ({}), canvasGroups: () => [], editRequest: 0, upstreamVars: () => [], varProducers: undefined },
|
||||
{
|
||||
nodeMode: 0, controls: () => ({}), canvasGroups: () => [], editRequest: 0, upstreamVars: () => [],
|
||||
varProducers: undefined, nodeTitle: "",
|
||||
},
|
||||
);
|
||||
const emit = defineEmits<{ "update:modelValue": [next: TogglesConfig] }>();
|
||||
|
||||
@@ -152,6 +164,7 @@ function shownOn(t: ToggleRow): boolean {
|
||||
|
||||
/* ── rows ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** The toggle whose window is open. */
|
||||
const editing = ref<string | null>(null);
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
/** Edit view (see the header comment). */
|
||||
@@ -172,17 +185,11 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
async function focusEditor(): Promise<void> {
|
||||
await nextTick();
|
||||
root.value?.querySelector<HTMLInputElement>(".wp-tgr__edit input")?.select();
|
||||
}
|
||||
|
||||
function addToggle(): void {
|
||||
const t = newToggle(cfg.value);
|
||||
if (pinCount.value >= MAX_OUTPUTS) t.pin = false;
|
||||
setRows([...cfg.value.rows, t]);
|
||||
editing.value = t.id;
|
||||
void focusEditor();
|
||||
}
|
||||
function addLabel(): void {
|
||||
const used = new Set(cfg.value.rows.filter((r): r is LabelRow => r.kind === "label").map((r) => r.color));
|
||||
@@ -205,19 +212,39 @@ function removeLabel(l: LabelRow): void {
|
||||
setRows(cfg.value.rows.filter((r) => r.id !== l.id));
|
||||
}
|
||||
|
||||
function rename(t: ToggleRow, raw: string): void {
|
||||
const name = raw.trim() || t.name;
|
||||
// A variable that still follows the old name follows the new one.
|
||||
const follows = !t.var || t.var === uniqueVar(varFromName(t.name), cfg.value, t.id);
|
||||
const v = follows ? uniqueVar(varFromName(name) || t.var, cfg.value, t.id) : t.var;
|
||||
patchToggle(t.id, { name, var: v });
|
||||
}
|
||||
function setVar(t: ToggleRow, v: string): void {
|
||||
patchToggle(t.id, { var: v });
|
||||
}
|
||||
function toggleEdit(t: ToggleRow): void {
|
||||
editing.value = editing.value === t.id ? null : t.id;
|
||||
if (editing.value) void focusEditor();
|
||||
}
|
||||
/** The toggle whose window is open (gone if it was removed meanwhile). */
|
||||
const editingRow = computed(() => (editing.value ? byId.value.get(editing.value) : undefined));
|
||||
function windowProps(t: ToggleRow) {
|
||||
return { ...rowProps(t), nodeTitle: props.nodeTitle };
|
||||
}
|
||||
/** Done in a toggle's window: store its draft, moving it to its group. */
|
||||
function applyWindow(t: ToggleRow, next: ToggleRow, groupId: string): void {
|
||||
editing.value = null;
|
||||
let row: ToggleRow = { ...next, id: t.id, kind: "toggle" };
|
||||
const name = row.name.trim() || t.name;
|
||||
if (name !== t.name && row.var === t.var) {
|
||||
// A variable that still follows the old name follows the new one.
|
||||
const follows = !t.var || t.var === uniqueVar(varFromName(t.name), cfg.value, t.id);
|
||||
if (follows) row = { ...row, var: uniqueVar(varFromName(name) || t.var, cfg.value, t.id) };
|
||||
}
|
||||
row = { ...row, name };
|
||||
const rows = cfg.value.rows.map((r) => (r.id === t.id ? row : r));
|
||||
if (groupId === groupIdOf(t)) {
|
||||
setRows(rows);
|
||||
return;
|
||||
}
|
||||
const next2: ToggleGroup[] = groupsOf({ ...cfg.value, rows }).map((g) => ({ label: g.label, toggles: g.toggles.filter((x) => x.id !== t.id) }));
|
||||
const target = next2.find((g) => (g.label?.id ?? "") === groupId);
|
||||
if (!target) {
|
||||
setRows(rows);
|
||||
return;
|
||||
}
|
||||
target.toggles.push(row);
|
||||
if (target.label?.collapsed) target.label = { ...target.label, collapsed: false };
|
||||
setRows(rowsOf(next2));
|
||||
}
|
||||
/** `$` names a Pick one Value can read: upstream's, then the toggles' own. */
|
||||
const valueVars = computed(() => {
|
||||
@@ -265,16 +292,6 @@ function groupOptions(): { value: string; label: string }[] {
|
||||
function groupIdOf(t: ToggleRow): string {
|
||||
return groups.value.find((g) => g.toggles.some((x) => x.id === t.id))?.label?.id ?? "";
|
||||
}
|
||||
/** Move a toggle to the end of another label's group. */
|
||||
function moveToGroup(t: ToggleRow, labelId: string): void {
|
||||
const next: ToggleGroup[] = groups.value.map((g) => ({ label: g.label, toggles: g.toggles.filter((x) => x.id !== t.id) }));
|
||||
const target = next.find((g) => (g.label?.id ?? "") === labelId);
|
||||
if (!target) return;
|
||||
target.toggles.push(t);
|
||||
if (target.label?.collapsed) target.label = { ...target.label, collapsed: false };
|
||||
setRows(rowsOf(next));
|
||||
}
|
||||
|
||||
/* ── ordering ─────────────────────────────────────────────────────────── */
|
||||
|
||||
type LabelledGroup = ToggleGroup & { label: LabelRow };
|
||||
@@ -284,15 +301,28 @@ const shownRows = computed(() => visibleRows(cfg.value));
|
||||
function groupOfLabel(l: LabelRow): ToggleGroup {
|
||||
return groups.value.find((g) => g.label?.id === l.id) ?? { label: l, toggles: [] };
|
||||
}
|
||||
/** A label's colour as CSS: its own pick, else its variable colour. */
|
||||
function labelColor(l: LabelRow): string {
|
||||
return l.hex ?? `var(--wp-var-${l.color})`;
|
||||
}
|
||||
/** A grouped toggle wears its label's colour on its left edge. */
|
||||
function groupColorOf(t: ToggleRow): number | undefined {
|
||||
return groups.value.find((x) => x.toggles.some((y) => y.id === t.id))?.label?.color;
|
||||
function groupColorOf(t: ToggleRow): string | undefined {
|
||||
const l = groups.value.find((x) => x.toggles.some((y) => y.id === t.id))?.label;
|
||||
return l ? labelColor(l) : undefined;
|
||||
}
|
||||
|
||||
/* ── labels ───────────────────────────────────────────────────────────── */
|
||||
|
||||
function nextColor(l: LabelRow): void {
|
||||
patchLabel(l.id, { color: (l.color % 8) + 1 });
|
||||
/** The colour picker's value for a label: its own pick, else the swatch it
|
||||
* wears now, so the picker opens on the colour you see. */
|
||||
function labelPickerValue(l: LabelRow): string {
|
||||
if (l.hex) return l.hex;
|
||||
const el = root.value;
|
||||
const v = el ? getComputedStyle(el).getPropertyValue(`--wp-var-${l.color}`).trim() : "";
|
||||
return v || "none";
|
||||
}
|
||||
function setLabelColor(l: LabelRow, v: string): void {
|
||||
patchLabel(l.id, { hex: /^#[0-9a-f]{6}$/i.test(v.slice(0, 7)) ? v.slice(0, 7).toLowerCase() : undefined });
|
||||
}
|
||||
function onCount(g: ToggleGroup): number {
|
||||
return g.toggles.filter((t) => eff.value.get(t.id)).length;
|
||||
@@ -382,7 +412,7 @@ function foldedOuts(g: ToggleGroup): string | undefined {
|
||||
<div
|
||||
v-if="g.label"
|
||||
class="wp-tg__label"
|
||||
:class="`var-${g.label.color}`"
|
||||
:style="{ color: labelColor(g.label) }"
|
||||
:data-wp-out="foldedOuts(g)"
|
||||
:data-test="`tg-label-${g.label.id}`"
|
||||
>
|
||||
@@ -393,6 +423,7 @@ function foldedOuts(g: ToggleGroup): string | undefined {
|
||||
:data-test="`tg-fold-${g.label.id}`"
|
||||
@click="patchLabel(g.label.id, { collapsed: !g.label.collapsed })"
|
||||
/>
|
||||
<i v-if="g.label.icon" class="pi wp-tg__label-icon" :class="`pi-${g.label.icon}`" aria-hidden="true" />
|
||||
<span class="wp-tg__label-text">{{ g.label.name || "Group" }}</span>
|
||||
<span class="wp-tg__rule" />
|
||||
<span class="wp-tg__count">{{ onCount(g) }}/{{ g.toggles.length }} on</span>
|
||||
@@ -425,7 +456,7 @@ function foldedOuts(g: ToggleGroup): string | undefined {
|
||||
<div
|
||||
v-if="item.kind === 'label'"
|
||||
class="wp-tg__label"
|
||||
:class="`var-${item.color}`"
|
||||
:style="{ color: labelColor(item) }"
|
||||
:data-wp-out="foldedOuts(groupOfLabel(item))"
|
||||
:data-test="`tg-label-${item.id}`"
|
||||
>
|
||||
@@ -448,12 +479,23 @@ function foldedOuts(g: ToggleGroup): string | undefined {
|
||||
/>
|
||||
<span class="wp-tg__rule" />
|
||||
<span class="wp-tg__count">{{ groupOfLabel(item).toggles.length }}<template v-if="item.collapsed"> · {{ onCount(groupOfLabel(item)) }} on</template></span>
|
||||
<WpIconButton
|
||||
icon="pi-palette"
|
||||
:label="`Colour ${item.color} of 8`"
|
||||
hint="Next colour"
|
||||
<IconPicker
|
||||
:model-value="item.icon ?? null"
|
||||
all
|
||||
compact
|
||||
size="sm"
|
||||
:color="labelColor(item)"
|
||||
aria-label="Group icon"
|
||||
:data-test="`tg-label-icon-${item.id}`"
|
||||
@update:model-value="(v: string | null) => patchLabel(item.id, { icon: v || undefined })"
|
||||
/>
|
||||
<ColorField
|
||||
:model-value="labelPickerValue(item)"
|
||||
presets
|
||||
compact
|
||||
aria-label="Group colour"
|
||||
:data-test="`tg-color-${item.id}`"
|
||||
@click="nextColor(item)"
|
||||
@update:model-value="(v: string) => setLabelColor(item, v)"
|
||||
/>
|
||||
<WpIconButton
|
||||
icon="pi-times"
|
||||
@@ -468,16 +510,13 @@ function foldedOuts(g: ToggleGroup): string | undefined {
|
||||
v-else
|
||||
class="wp-tg__member"
|
||||
:class="{ 'is-grouped': groupColorOf(item) }"
|
||||
:style="groupColorOf(item) ? { '--wp-tg-group': `var(--wp-var-${groupColorOf(item)})` } : undefined"
|
||||
:style="groupColorOf(item) ? { '--wp-tg-group': groupColorOf(item) } : undefined"
|
||||
>
|
||||
<ToggleRowView
|
||||
v-bind="rowProps(item)"
|
||||
@set-on="(v: boolean) => setOn(item, v)"
|
||||
@patch="(p: Partial<ToggleRow>) => patchToggle(item.id, p)"
|
||||
@rename="(n: string) => rename(item, n)"
|
||||
@set-var="(v: string) => setVar(item, v)"
|
||||
@edit="toggleEdit(item)"
|
||||
@move-group="(id: string) => moveToGroup(item, id)"
|
||||
@remove="removeToggle(item)"
|
||||
/>
|
||||
</div>
|
||||
@@ -492,6 +531,13 @@ function foldedOuts(g: ToggleGroup): string | undefined {
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="!toggles.length" class="wp-tg__ghost">No toggles yet: select the node and press the pencil above it.</div>
|
||||
<ToggleEditWindow
|
||||
v-if="editingRow"
|
||||
:key="editingRow.id"
|
||||
v-bind="windowProps(editingRow)"
|
||||
@done="(next: ToggleRow, groupId: string) => editingRow && applyWindow(editingRow, next, groupId)"
|
||||
@cancel="editing = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -513,6 +559,7 @@ function foldedOuts(g: ToggleGroup): string | undefined {
|
||||
.wp-tg__pickstyle { display: inline-flex; align-items: center; gap: 8px; flex-basis: 100%; }
|
||||
.wp-tg__check { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; cursor: pointer; }
|
||||
.wp-tg__mode-cap { font-size: 10.5px; color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
.wp-tg__label-icon { font-size: 11px; }
|
||||
.wp-tg__label-text {
|
||||
font: 600 11px var(--wp-font-sans, sans-serif);
|
||||
padding: 0 4px;
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import ToggleEditWindow from "../ToggleEditWindow.vue";
|
||||
import type { ToggleRow as Row } from "../../../extension/toggles";
|
||||
|
||||
const base: Row = { id: "a", kind: "toggle", name: "Dawn", var: "dawn", on: true, pin: false, out: 0, style: "switch", control: "bypass" };
|
||||
|
||||
function open(t: Row, extra: { pickOne?: boolean; varSuggestions?: string[]; groupId?: string } = {}) {
|
||||
return mount(ToggleEditWindow, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
t, pickOne: extra.pickOne ?? false, shownOn: true, effOn: true, parentName: "", blocked: false,
|
||||
needsOptions: [{ value: "", label: "Nothing" }],
|
||||
groupOptions: extra.groupId !== undefined ? [{ value: "", label: "Top" }, { value: "g1", label: "Light" }] : [],
|
||||
groupId: extra.groupId ?? "", outputsFull: false, nodeTitle: "Light",
|
||||
...(extra.varSuggestions ? { varSuggestions: extra.varSuggestions } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
/** The window is teleported to body. */
|
||||
function q(sel: string): HTMLElement | null {
|
||||
return document.body.querySelector<HTMLElement>(sel);
|
||||
}
|
||||
function done(w: ReturnType<typeof open>): [Row, string] | undefined {
|
||||
q('[data-test="tg-window-done"]')?.click();
|
||||
return w.emitted("done")?.[0] as [Row, string] | undefined;
|
||||
}
|
||||
|
||||
describe("ToggleEditWindow", () => {
|
||||
it.each([
|
||||
["Show", { hide_var: true }],
|
||||
["Invert: bypass while on", { invert: true }],
|
||||
["Upstream can set it", { upstream: true }],
|
||||
["BOOLEAN output", { pin: true }],
|
||||
])("the words %s tick their box", async (words, patch) => {
|
||||
const w = open(base);
|
||||
const label = [...document.body.querySelectorAll<HTMLElement>(".wp-tgr__check > span")].find((s) => s.textContent === words);
|
||||
expect(label, words).toBeTruthy();
|
||||
label?.click();
|
||||
await flushPromises();
|
||||
expect(done(w)?.[0]).toMatchObject(patch);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("writes nothing until Done, then hands back the draft and its group", async () => {
|
||||
const w = open(base, { groupId: "" });
|
||||
const name = q('[data-test="tg-name"]') as HTMLInputElement | null;
|
||||
if (name) {
|
||||
name.value = "Sunrise";
|
||||
name.dispatchEvent(new Event("change"));
|
||||
}
|
||||
await flushPromises();
|
||||
expect(w.emitted("done")).toBeUndefined();
|
||||
expect(q('[data-test="tg-window-preview"]')?.textContent).toContain("Sunrise");
|
||||
const out = done(w);
|
||||
expect(out?.[0].name).toBe("Sunrise");
|
||||
expect(out?.[1]).toBe("");
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("Cancel without changes closes at once", async () => {
|
||||
const w = open(base);
|
||||
q('[data-test="tg-window-cancel"]')?.click();
|
||||
await flushPromises();
|
||||
expect(w.emitted("cancel")).toHaveLength(1);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("hides Style and Needs in Pick one", () => {
|
||||
const one = open(base, { pickOne: true });
|
||||
expect(q('[aria-label="Style"]')).toBeNull();
|
||||
expect(q('[data-test="tg-needs"]')).toBeNull();
|
||||
one.unmount();
|
||||
const any = open(base);
|
||||
expect(q('[aria-label="Style"]')).not.toBeNull();
|
||||
expect(q('[data-test="tg-needs"]')).not.toBeNull();
|
||||
any.unmount();
|
||||
});
|
||||
|
||||
it("offers a negative beside the Value in Pick one only", async () => {
|
||||
const one = open({ ...base, negative: "night sky" }, { pickOne: true });
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await vi.dynamicImportSettled();
|
||||
await flushPromises();
|
||||
}
|
||||
expect(q('[data-test="tg-negative"]')).not.toBeNull();
|
||||
one.unmount();
|
||||
const any = open(base);
|
||||
await flushPromises();
|
||||
expect(q('[data-test="tg-negative"]')).toBeNull();
|
||||
any.unmount();
|
||||
});
|
||||
|
||||
it("warns that inline picks print as written", () => {
|
||||
const w = open({ ...base, value: "{golden|soft} light" }, { pickOne: true });
|
||||
expect(q('[data-test="tg-inline-picks"]')).not.toBeNull();
|
||||
w.unmount();
|
||||
const plain = open({ ...base, value: "$time light" }, { pickOne: true });
|
||||
expect(q('[data-test="tg-inline-picks"]')).toBeNull();
|
||||
plain.unmount();
|
||||
});
|
||||
|
||||
it("flags a $name nothing upstream sets", () => {
|
||||
const w = open({ ...base, value: "$time and $nowhere" }, { pickOne: true, varSuggestions: ["time"] });
|
||||
const note = q('[data-test="tg-missing-vars"]')?.textContent ?? "";
|
||||
expect(note).toContain("$nowhere");
|
||||
expect(note).not.toContain("$time");
|
||||
w.unmount();
|
||||
});
|
||||
it("colours the header with the toggle's looks", () => {
|
||||
const w = open({ ...base, icon: "moon", icon_color: "#7aa7ff", text_color: "#a9c4ff", tint: "#4c7dff" });
|
||||
const icon = q('[data-test="tg-window-icon"]');
|
||||
expect(icon?.className).toContain("pi-moon");
|
||||
expect(icon?.getAttribute("style")).toContain("--wp-tgw-icon: #7aa7ff");
|
||||
expect(q('[data-test="tg-window-sub"]')?.getAttribute("style")).toContain("--wp-tgw-text: #a9c4ff");
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
@@ -47,15 +47,46 @@ describe("ToggleRow internal button", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToggleRow style in Pick one", () => {
|
||||
it("is disabled while Pick one is on", () => {
|
||||
const one = mountRow(base, { pickOne: true, editing: true });
|
||||
expect(one.find('[aria-label="Style"]').attributes("aria-disabled") ?? one.find('[aria-label="Style"] button').attributes("disabled")).toBeDefined();
|
||||
expect(one.text()).toContain("not used while Pick one is on");
|
||||
one.unmount();
|
||||
const any = mountRow(base, { editing: true });
|
||||
expect(any.find('[aria-label="Style"] button').attributes("disabled")).toBeUndefined();
|
||||
any.unmount();
|
||||
describe("ToggleRow chip", () => {
|
||||
it("says Name · $var, and the name alone when Show is off", () => {
|
||||
const chip: Row = { ...base, style: "chip" };
|
||||
const w = mountRow(chip, { editable: false });
|
||||
expect(w.find(".wp-tgr__chip").text().replace(/\s+/g, "")).toBe("Dawn·$dawn");
|
||||
w.unmount();
|
||||
const bare = mountRow({ ...chip, hide_var: true }, { editable: false });
|
||||
expect(bare.find('[data-test="tg-chip-var-a"]').exists()).toBe(false);
|
||||
expect(bare.find(".wp-tgr__chip").text()).toBe("Dawn");
|
||||
bare.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToggleRow looks", () => {
|
||||
it("draws the icon and hands its colours to the row", () => {
|
||||
const w = mountRow({ ...base, icon: "sun", icon_color: "#ffcc00", text_color: "#ff0000", tint: "#00aaff" }, {});
|
||||
const row = w.find(`[data-test="tg-row-${base.id}"]`);
|
||||
expect(row.find(".pi-sun").exists()).toBe(true);
|
||||
const style = row.attributes("style") ?? "";
|
||||
expect(style).toContain("--wp-tg-icon: #ffcc00");
|
||||
expect(style).toContain("--wp-tg-text: #ff0000");
|
||||
expect(style).not.toContain("--wp-accent");
|
||||
expect(w.find(".wp-tgr__ctl").attributes("style")).toContain("--wp-accent: #00aaff");
|
||||
expect(row.classes()).toContain("has-icon-color");
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("keeps the theme's look with no colours set", () => {
|
||||
const w = mountRow(base, {});
|
||||
expect(w.find(`[data-test="tg-row-${base.id}"]`).attributes("style") ?? "").not.toContain("--wp-accent");
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("offers edit, globe and remove on the row in the editor", async () => {
|
||||
const w = mountRow(base, { editable: true });
|
||||
expect(w.find(`[data-test="tg-edit-${base.id}"]`).exists()).toBe(true);
|
||||
expect(w.find(`[data-test="tg-internal-${base.id}"]`).exists()).toBe(true);
|
||||
await w.find(`[data-test="tg-remove-${base.id}"]`).trigger("click");
|
||||
expect(w.emitted("remove")).toHaveLength(1);
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,29 +126,11 @@ describe("ToggleRow Pick one style", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToggleRow Pick one Value", () => {
|
||||
it("warns that inline picks print as written", () => {
|
||||
const w = mountRow({ ...base, value: "{golden|soft} light" }, { pickOne: true, editing: true });
|
||||
expect(w.find('[data-test="tg-inline-picks"]').exists()).toBe(true);
|
||||
w.unmount();
|
||||
const plain = mountRow({ ...base, value: "$time light" }, { pickOne: true, editing: true });
|
||||
expect(plain.find('[data-test="tg-inline-picks"]').exists()).toBe(false);
|
||||
plain.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToggleRow Pick one Value variables", () => {
|
||||
it("flags a $name nothing upstream sets", () => {
|
||||
const w = mount(ToggleRow, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
t: { ...base, value: "$time and $nowhere" }, pickOne: true, shownOn: true, effOn: true, parentName: "", blocked: false,
|
||||
indent: 0, editing: true, needsOptions: [], groupOptions: [], groupId: "", outputsFull: false, editable: true,
|
||||
varSuggestions: ["time"],
|
||||
},
|
||||
});
|
||||
expect(w.find('[data-test="tg-missing-vars"]').text()).toContain("$nowhere");
|
||||
expect(w.find('[data-test="tg-missing-vars"]').text()).not.toContain("$time");
|
||||
describe("ToggleRow controls", () => {
|
||||
it("draws a checkbox for the Check style", () => {
|
||||
const w = mountRow({ ...base, style: "checkbox" }, { editable: false });
|
||||
expect(w.find(`[data-test="tg-ctl-${base.id}"]`).exists()).toBe(true);
|
||||
expect(w.find(".wp-tgr__ctl").html()).toContain("checkbox");
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ToggleRow from "../ToggleRow.vue";
|
||||
import type { ToggleRow as Row } from "../../../extension/toggles";
|
||||
|
||||
const row: Row = { id: "a", kind: "toggle", name: "Settings from the model", var: "model_settings", on: true, pin: false, out: 0, style: "switch", control: "bypass" };
|
||||
|
||||
function mountRow() {
|
||||
return mount(ToggleRow, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
t: row, pickOne: false, shownOn: true, effOn: true, parentName: "", blocked: false, indent: 0, editing: true,
|
||||
needsOptions: [], groupOptions: [], groupId: "", outputsFull: false, editable: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("ToggleRow edit panel: the words beside each box tick it", () => {
|
||||
it.each([
|
||||
["Show", { hide_var: true }],
|
||||
["Invert: bypass while on", { invert: true }],
|
||||
["Upstream can set it", { upstream: true }],
|
||||
["BOOLEAN output", { pin: true }],
|
||||
])("%s", async (words, patch) => {
|
||||
const w = mountRow();
|
||||
const label = w.findAll(".wp-tgr__check > span").find((s) => s.text() === words);
|
||||
expect(label, words).toBeTruthy();
|
||||
await label?.trigger("click");
|
||||
expect(w.emitted("patch")?.[0]).toEqual([patch]);
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -276,3 +276,35 @@ describe("dragRow (edit view drag across groups)", () => {
|
||||
expect(ids(dragRow(f, 1, 3))).toEqual(["a", "L2", "d", "L1", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTogglesConfig negative", () => {
|
||||
it("keeps a toggle's negative and drops a blank one", () => {
|
||||
const cfg = parseTogglesConfig(JSON.stringify({
|
||||
mode: "one",
|
||||
rows: [
|
||||
{ id: "t1", name: "Day", var: "day", negative: "night sky" },
|
||||
{ id: "t2", name: "Night", var: "night", negative: " " },
|
||||
],
|
||||
}));
|
||||
expect(cfg.rows.find((r) => r.id === "t1")).toMatchObject({ negative: "night sky" });
|
||||
expect(cfg.rows.find((r) => r.id === "t2")).not.toHaveProperty("negative");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTogglesConfig looks", () => {
|
||||
it("keeps icons and #rrggbb colours, drops anything else", () => {
|
||||
const cfg = parseTogglesConfig({
|
||||
rows: [
|
||||
{ id: "l1", kind: "label", name: "G", color: 3, hex: "#FFAA00", icon: "pi-clock" },
|
||||
{ id: "l2", kind: "label", name: "H", color: 3, hex: "red", icon: "<b>" },
|
||||
{ id: "t1", name: "Day", var: "day", icon: "sun", icon_color: "#ffcc00", text_color: "#123456", tint: "#00AAFF" },
|
||||
{ id: "t2", name: "Night", var: "night", icon: "", icon_color: "blue", tint: "#12345" },
|
||||
],
|
||||
});
|
||||
expect(cfg.rows[0]).toMatchObject({ hex: "#ffaa00", icon: "clock" });
|
||||
expect(cfg.rows[1]).not.toHaveProperty("hex");
|
||||
expect(cfg.rows[1]).not.toHaveProperty("icon");
|
||||
expect(cfg.rows[2]).toMatchObject({ icon: "sun", icon_color: "#ffcc00", text_color: "#123456", tint: "#00aaff" });
|
||||
for (const k of ["icon", "icon_color", "text_color", "tint"]) expect(cfg.rows[3]).not.toHaveProperty(k);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,9 +51,18 @@ export interface ToggleRow {
|
||||
hide_var?: true;
|
||||
/** Pick one: what the picked variable gets instead of the name (may read `$vars`). */
|
||||
value?: string;
|
||||
/** Pick one: negative text the picked variable carries (send-to-negative). */
|
||||
negative?: string;
|
||||
/** The variable shows in the Assembler and prints in a prompt (the
|
||||
* globe "internal" button turned off). Absent: internal, for rules only. */
|
||||
visible?: true;
|
||||
/** Looks only (absent: the theme's). A PrimeIcons name without `pi-`,
|
||||
* shown before the name; colours are `#rrggbb`. `tint` colours the
|
||||
* control (switch, check, radio, chip, buttons). */
|
||||
icon?: string;
|
||||
icon_color?: string;
|
||||
text_color?: string;
|
||||
tint?: string;
|
||||
}
|
||||
|
||||
export const TOGGLE_CONTROLS = ["mute", "bypass"] as const;
|
||||
@@ -63,8 +72,12 @@ export interface LabelRow {
|
||||
id: string;
|
||||
kind: "label";
|
||||
name: string;
|
||||
/** Variable colour 1..8. */
|
||||
/** Variable colour 1..8 (used while `hex` is absent). */
|
||||
color: number;
|
||||
/** Own colour `#rrggbb`, chosen in the colour picker. */
|
||||
hex?: string;
|
||||
/** PrimeIcons name without `pi-`, before the name. */
|
||||
icon?: string;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
@@ -187,6 +200,15 @@ export function toggleRows(cfg: TogglesConfig): ToggleRow[] {
|
||||
}
|
||||
|
||||
const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
|
||||
const HEX_RE = /^#[0-9a-fA-F]{6}$/;
|
||||
const ICON_RE = /^[a-z0-9-]{1,40}$/;
|
||||
/** A `#rrggbb` colour, or "" for anything else. */
|
||||
const hex = (v: unknown): string => (typeof v === "string" && HEX_RE.test(v.trim()) ? v.trim().toLowerCase() : "");
|
||||
/** A PrimeIcons name (without `pi-`), or "" for anything else. */
|
||||
const iconName = (v: unknown): string => {
|
||||
const s = str(v).replace(/^pi-/, "");
|
||||
return ICON_RE.test(s) ? s : "";
|
||||
};
|
||||
|
||||
export function parseTogglesConfig(raw: unknown): TogglesConfig {
|
||||
let data: unknown = raw;
|
||||
@@ -210,6 +232,8 @@ export function parseTogglesConfig(raw: unknown): TogglesConfig {
|
||||
if (o.kind === "label") {
|
||||
const c = typeof o.color === "number" && o.color >= 1 && o.color <= 8 ? Math.round(o.color) : 1;
|
||||
const label: LabelRow = { id, kind: "label", name: str(o.name), color: c };
|
||||
if (hex(o.hex)) label.hex = hex(o.hex);
|
||||
if (iconName(o.icon)) label.icon = iconName(o.icon);
|
||||
if (o.collapsed === true) label.collapsed = true;
|
||||
rows.push(label);
|
||||
continue;
|
||||
@@ -231,11 +255,16 @@ export function parseTogglesConfig(raw: unknown): TogglesConfig {
|
||||
if (str(o.off_label)) t.off_label = str(o.off_label);
|
||||
if (str(o.needs)) t.needs = str(o.needs);
|
||||
if (typeof o.value === "string" && o.value) t.value = o.value;
|
||||
if (typeof o.negative === "string" && o.negative.trim()) t.negative = o.negative;
|
||||
if (o.upstream === true) t.upstream = true;
|
||||
if (TOGGLE_CONTROLS.includes(o.control as ToggleControl)) t.control = o.control as ToggleControl;
|
||||
if (o.invert === true) t.invert = true;
|
||||
if (o.hide_var === true) t.hide_var = true;
|
||||
if (o.visible === true) t.visible = true;
|
||||
if (iconName(o.icon)) t.icon = iconName(o.icon);
|
||||
if (hex(o.icon_color)) t.icon_color = hex(o.icon_color);
|
||||
if (hex(o.text_color)) t.text_color = hex(o.text_color);
|
||||
if (hex(o.tint)) t.tint = hex(o.tint);
|
||||
const groups = Array.isArray(o.groups) ? o.groups.map(str).filter(Boolean) : [];
|
||||
if (groups.length) t.groups = [...new Set(groups)];
|
||||
rows.push(t);
|
||||
|
||||
@@ -244,24 +244,26 @@ export const NODE_FIELDS = {
|
||||
WP_Toggles: {
|
||||
controls: [
|
||||
{ term: "upstream (in)", sockets: ["in:upstream"], desc: "Optional Context to extend. Only nodes after this one see the toggle variables. It can also set toggles (see below)." },
|
||||
{ term: "Pencil (toolbar)", pin: "page:.wp-tb-icon--edit", desc: "Select the node and the indigo pencil in ComfyUI's selection toolbar opens the edit view: the mode, the grips to reorder, a pencil per row, Add toggle and Add label. Done goes back to the clean view, which shows only the switches." },
|
||||
{ term: "Pencil (toolbar)", pin: "page:.wp-tb-icon--edit", desc: "Select the node and the indigo pencil in ComfyUI's selection toolbar opens the edit view: the mode, the Pick one options, the grips to reorder, the row buttons, Add toggle and Add label. Done goes back to the clean view, which shows only the switches." },
|
||||
{ term: "Any on / Pick one", desc: "Any on: every toggle is on or off by itself. Pick one: exactly one is on, like radio buttons." },
|
||||
{ term: "writes (Pick one)", desc: "Optional variable that also gets the picked toggle's value (or its name), so one rule can branch on it." },
|
||||
{ term: "Globe (row)", desc: "Edit view only. On (the default) keeps the toggle's variable internal: rules read it, the Assembler doesn't offer it and it never prints. Turn it off to use the variable in a prompt. Same in Any on and Pick one." },
|
||||
{ term: "Show in Assembler (Pick one)", desc: "The picked variable shows in the Assembler and can print in the prompt. Off, it is internal." },
|
||||
{ term: "Style (Pick one)", desc: "How every row looks in Pick one: Radio (a round dot, the default), Chip (the name across the row, lit when picked), Switch or Check. Turning one on turns the others off." },
|
||||
{ term: "Pencil (row)", desc: "Opens the toggle's edit panel: name, variable, style, needs, what off mutes or bypasses, group and its output." },
|
||||
{ term: "Add toggle / Add label", desc: "A new toggle, or a label that groups the toggles under it. Click a label's arrow to fold it. Drag a toggle's grip under a label to move it into that group; a dragged label takes its toggles along." },
|
||||
{ term: "Pencil, globe, trash (row)", desc: "Each row in the edit view has three buttons, like a module on a Context node: the pencil opens the toggle's own settings in a window (name, variable, looks, style, needs, what off mutes or bypasses, group and its output) with the row drawn on top as the node will show it; the globe keeps its variable internal; the trash removes it." },
|
||||
{ term: "Add toggle / Add label", desc: "A new toggle, or a label that groups the toggles under it. Click a label's arrow to fold it; its icon and colour buttons pick any icon and any colour. Drag a toggle's grip under a label to move it into that group; a dragged label takes its toggles along." },
|
||||
{ term: "control dots (in)", pin: "in:ctl_*", desc: "A toggle that mutes or bypasses has a dot at the left of its row: wire the nodes it drives into it." },
|
||||
{ term: "context (out)", sockets: ["out:context"], desc: "The Context with every toggle variable added. Internal ones (the globe) are for rules only; the rest show in the Assembler." },
|
||||
{ term: "toggle outputs", sockets: ["out:toggle_*"], desc: "One BOOLEAN per toggle that has an output, at the right of its row. Up to 16." },
|
||||
],
|
||||
edit: [
|
||||
{ term: "Name", desc: "Shown on the row and on its output." },
|
||||
{ term: "Variable", desc: "Gets true or false. It follows the name until you change it. Internal until you turn off the row's globe. Show puts it under the name." },
|
||||
{ term: "Style", desc: "Switch, Check, Buttons (two words of your own, like Day / Night) or Chip (a small chip with the name). Greyed out in Pick one, where the Style in the top bar sets every row's look." },
|
||||
{ term: "Name", desc: "Shown on the row and on its output. A Chip shows Name · $variable (the variable while Show is on). A name colour also colours its variable." },
|
||||
{ term: "Variable", desc: "Gets true or false. It follows the name until you change it. Internal until you turn off the row's globe. Show puts it under the name, or after it on a Chip." },
|
||||
{ term: "Looks", desc: "An icon before the name, and colours for the icon, the name and the control (switch, check, radio, chip or buttons), so Day can be yellow and Night blue. None keeps the theme's colour." },
|
||||
{ term: "Style", desc: "Switch, Check, Buttons (two words of your own, like Day / Night) or Chip (a small chip with the name). Any on only: in Pick one the Style in the top bar sets every row's look." },
|
||||
{ term: "Value (Pick one)", desc: "What the picked variable gets when this toggle is picked, instead of its name. A text box like the Assembler's: $ lists every variable upstream (Context Injector ones included) and the toggles' own, and tag, LoRA and embedding autocomplete work when they are on. A $name nothing upstream sets is flagged under the box and in WP Debug. Inline {a|b} picks need a seed, so they print as written; roll them in a wildcard instead. Drag the corner to make it taller." },
|
||||
{ term: "Needs", desc: "Another toggle this one needs. While that one is off, this one is off too; it keeps your choice for when it comes back on." },
|
||||
{ term: "Negative (Pick one)", desc: "Words to keep out of the image while this toggle is picked. They go with the picked variable, so an Assembler that prints it sends them to its negative output, like a wildcard option's negative. It can read $variables; a pick without one clears what the variable carried." },
|
||||
{ term: "Needs", desc: "Any on only. Another toggle this one needs. While that one is off, this one is off too; it keeps your choice for when it comes back on." },
|
||||
{ term: "When off", desc: "Nothing, Mute or Bypass: what happens to the nodes wired into the row's dot (and to the canvas groups you pick) while the toggle is off. On, they run. When several toggles drive one node, off wins: it runs only while all of them let it (Mute beats Bypass)." },
|
||||
{ term: "Invert", desc: "Mute or bypass while the toggle is ON instead, for a node used only as a switchboard." },
|
||||
{ term: "Canvas groups", desc: "Groups on the canvas whose nodes When off also covers, by title. No wires needed." },
|
||||
|
||||
@@ -60,6 +60,16 @@ import VarToken from "../../../components/docs/VarToken.vue";
|
||||
</Banner>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="Icons and colours">
|
||||
<p>
|
||||
Give a toggle an icon and its own colours under <b>Looks</b> in its window: one for the
|
||||
icon, one for the name and one for the control itself, so Day can glow yellow and Night blue.
|
||||
A label's icon and colour buttons do the same for the group, and its toggles wear that
|
||||
colour on their left edge. The row at the top of the toggle's window shows each change
|
||||
before you press Done.
|
||||
</p>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="Use a toggle in the prompt">
|
||||
<p>
|
||||
A toggle's variable is internal by default: rules read it, but the Assembler doesn't offer
|
||||
@@ -97,9 +107,11 @@ import VarToken from "../../../components/docs/VarToken.vue";
|
||||
One toggle is on. Which one, first match wins: the toggle the upstream picked variable
|
||||
names (with <b>Upstream can pick</b>), then the first toggle upstream set to a yes word,
|
||||
then the first one you switched on. The picked variable gets the toggle's
|
||||
<b>Value</b>, or its name; it is internal unless <b>Show in Assembler</b> is on. All rows share one look here:
|
||||
the <b>Style</b> next to those boxes picks Radio (the default), Chip, Switch or Check, and
|
||||
each row's own Style is greyed out.
|
||||
<b>Value</b>, or its name; it is internal unless <b>Show in Assembler</b> is on. A toggle's
|
||||
<b>Negative</b>, under its Value, goes with the picked variable: an Assembler that prints it
|
||||
sends those words to its negative output. All rows share one look here:
|
||||
the <b>Style</b> next to those boxes picks Radio (the default), Chip, Switch or Check, so a
|
||||
toggle's own Style and Needs are hidden.
|
||||
</p>
|
||||
</DocSection>
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ export function create(node: TogglesHostNode, inputName: string) {
|
||||
const ctlNames: string[] = [];
|
||||
const ctlNode = node as unknown as CtlNode;
|
||||
/** Bumped by the pencil in ComfyUI's selection toolbar
|
||||
* (extension/node-toolbar.ts); the widget flips its edit view. */
|
||||
* (extension/node-toolbar.ts); the widget opens its edit window. */
|
||||
const editRequest = ref(0);
|
||||
nodeEditors.set(node, () => {
|
||||
editRequest.value += 1;
|
||||
@@ -238,6 +238,7 @@ export function create(node: TogglesHostNode, inputName: string) {
|
||||
controls,
|
||||
canvasGroups: canvasGroups.value,
|
||||
editRequest: editRequest.value,
|
||||
nodeTitle: (node as { title?: string }).title ?? "",
|
||||
upstreamVars: upstreamVars.value,
|
||||
varProducers: varProducers.value,
|
||||
"onUpdate:modelValue": onUpdate,
|
||||
|
||||
@@ -193,3 +193,33 @@ def test_pick_value_missing_variable_warns():
|
||||
warns = payload.debug["__wp_warnings__"]
|
||||
assert any(w["type"] == "unknown_var" and "$nowhere" in w["message"] for w in warns)
|
||||
assert all(w["node_id"] == "7" for w in warns)
|
||||
|
||||
|
||||
def test_pick_one_files_the_picked_negative():
|
||||
up = ContextPayload(
|
||||
context={"shade": "dark"},
|
||||
debug={},
|
||||
internals={negatives.NEG_KEY: {"time": [{"text": "old", "pick": None, "source": "x"}]}},
|
||||
)
|
||||
cfg = {"mode": "one", "group_var": "time", "rows": [
|
||||
{"id": "1", "kind": "toggle", "name": "Day", "var": "day", "on": False,
|
||||
"negative": "night sky"},
|
||||
{"id": "2", "kind": "toggle", "name": "Night", "var": "night", "on": True,
|
||||
"negative": " $shade sun "},
|
||||
]}
|
||||
payload = _run(config=cfg, upstream=up).values[0]
|
||||
assert payload.internals[negatives.NEG_KEY]["time"] == [
|
||||
{"text": "dark sun", "pick": None, "source": "WP_Toggles"},
|
||||
]
|
||||
|
||||
|
||||
def test_pick_one_without_negative_clears_old_one():
|
||||
up = ContextPayload(
|
||||
context={}, debug={},
|
||||
internals={negatives.NEG_KEY: {"time": [{"text": "old", "pick": None, "source": "x"}]}},
|
||||
)
|
||||
cfg = {"mode": "one", "group_var": "time", "rows": [
|
||||
{"id": "1", "kind": "toggle", "name": "Day", "var": "day", "on": True},
|
||||
]}
|
||||
payload = _run(config=cfg, upstream=up).values[0]
|
||||
assert "time" not in payload.internals[negatives.NEG_KEY]
|
||||
|
||||
@@ -77,6 +77,12 @@ class WPToggles(io.ComfyNode):
|
||||
)
|
||||
else:
|
||||
value_warnings = []
|
||||
# The pick's negative goes with the Pick one variable, so an
|
||||
# Assembler that prints it sends it to the negative prompt. Inline
|
||||
# picks stay as written here too (no seed).
|
||||
neg_text = toggles.picked_negative(cfg) if group in values else ""
|
||||
if neg_text and ("$" in neg_text or "{" in neg_text):
|
||||
neg_text = template.resolve_variables(neg_text, {**upstream_ctx, **values})
|
||||
|
||||
upstream_debug: dict = upstream.debug if upstream is not None else {}
|
||||
upstream_internals: dict = upstream.internals if upstream is not None else {}
|
||||
@@ -93,6 +99,11 @@ class WPToggles(io.ComfyNode):
|
||||
out_internals[negatives.NEG_KEY] = {
|
||||
k: v for k, v in neg_table.items() if k not in values
|
||||
}
|
||||
if neg_text.strip():
|
||||
out_internals[negatives.NEG_KEY] = {
|
||||
**(out_internals.get(negatives.NEG_KEY) or {}),
|
||||
group: [{"text": neg_text.strip(), "pick": None, "source": "WP_Toggles"}],
|
||||
}
|
||||
# Each toggle chooses whether its variable shows in the Assembler
|
||||
# (`visible`); the Pick one variable shows with `group_visible`.
|
||||
hidden_vars = toggles.internal_vars(cfg)
|
||||
|
||||
Reference in New Issue
Block a user