A toggle can say what its variable writes while off, with its own negative. Either a Value or an Off value makes the variable hold text; with neither it writes true/false. $name.bool reads the switch either way.
351 lines
13 KiB
Python
351 lines
13 KiB
Python
"""Toggle resolution for WP_Toggles — pure logic, no ComfyUI imports.
|
|
|
|
The node's widget stores one JSON value (``TogglesConfig``):
|
|
|
|
``{"version": 1, "mode": "any" | "one", "group_var": str, "rows": [...]}``
|
|
|
|
Each row is a label (``{"id", "kind": "label", "name", "color", "collapsed"}``)
|
|
or a toggle::
|
|
|
|
{"id": "1a2b3c4d", "kind": "toggle", "name": "LoRA character",
|
|
"var": "use_lora_character", "on": true, "pin": true, "out": 1,
|
|
"style": "switch", "on_label": "", "off_label": "", "needs": "",
|
|
"upstream": true, "control": "bypass"}
|
|
|
|
* ``Any on``: a toggle is on when its own switch is on AND the toggle it
|
|
``needs`` is on (transitively). A loop in ``needs`` counts as off.
|
|
* ``Pick one``: one toggle is on; ``needs`` is ignored. Which one, first
|
|
match wins: the toggle the upstream ``group_var`` names (by name or
|
|
variable, any case; only with ``group_upstream``), then the first toggle
|
|
upstream set to a yes word, then the first toggle whose switch is on.
|
|
``group_var`` (optional) gets the picked toggle's ``value`` (text that may
|
|
read ``$vars``; the node resolves it) or else its name, ``""`` when none
|
|
is on. It is internal unless ``group_visible``.
|
|
|
|
``upstream`` (optional, stored only as ``true``): when the upstream Context
|
|
holds the toggle's variable and its whole value is a yes/no word
|
|
(:data:`TRUTHY` / :data:`FALSY`, any case), that value replaces the switch
|
|
before ``needs`` / Pick one apply (:func:`with_upstream`). Anything else
|
|
leaves the switch alone.
|
|
|
|
``control`` / ``groups`` (mute or bypass other nodes) are frontend-only: the
|
|
canvas sets node modes when a toggle flips, and the run never sees them.
|
|
|
|
Every toggle writes ``"true"`` / ``"false"`` to its variable. It is
|
|
internal (for rules only) unless the row carries ``visible: true`` (stored
|
|
only as ``true``; the canvas's globe "internal" button turned off), which
|
|
lets the Assembler print it; :func:`internal_vars` lists the hidden ones.
|
|
A pinned toggle (``pin``) also has a BOOLEAN output; ``out`` is its output
|
|
slot (1-based, slot 0 is the context). The frontend keeps ``out`` unique;
|
|
:func:`output_values` falls back to row order when it isn't.
|
|
|
|
Mirrored in TS at ``src/extension/toggles.ts`` via the shared
|
|
``tests/fixtures/toggles-corpus.json``. Keep the two in step.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
#: BOOLEAN outputs the node declares. Never lower it: a saved workflow may
|
|
#: link any slot up to it, and validation indexes RETURN_TYPES by slot.
|
|
MAX_OUTPUTS = 16
|
|
MODES = ("any", "one")
|
|
STYLES = ("switch", "checkbox", "buttons", "chip")
|
|
TRUE = "true"
|
|
FALSE = "false"
|
|
|
|
VAR_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_]*$")
|
|
#: Words an upstream value may be to set an ``upstream`` toggle (the same
|
|
#: yes/no words as WP Var → Bool, but the whole value must be one).
|
|
TRUTHY = frozenset({"true", "yes", "on", "1"})
|
|
FALSY = frozenset({"false", "no", "off", "0"})
|
|
|
|
|
|
@dataclass
|
|
class Toggle:
|
|
id: str
|
|
name: str
|
|
var: str
|
|
on: bool
|
|
pin: bool = False
|
|
out: int = 0
|
|
needs: str = ""
|
|
upstream: bool = False
|
|
value: str = ""
|
|
visible: bool = False
|
|
#: Negative text, filed under ``var`` while the toggle is on (and under
|
|
#: ``group_var`` when it is the Pick one pick).
|
|
negative: str = ""
|
|
#: What the variable gets, and the negative it carries, while the toggle
|
|
#: is OFF (empty: nothing). Either value makes the toggle write text
|
|
#: instead of true/false.
|
|
off_value: str = ""
|
|
off_negative: str = ""
|
|
|
|
@property
|
|
def writes_text(self) -> bool:
|
|
"""A Value or an Off value: the variable holds text, not true/false."""
|
|
return bool(self.value or self.off_value)
|
|
|
|
|
|
@dataclass
|
|
class Config:
|
|
mode: str = "any"
|
|
group_var: str = ""
|
|
toggles: list[Toggle] = field(default_factory=list)
|
|
group_visible: bool = False
|
|
group_upstream: bool = False
|
|
#: Set by :func:`with_upstream`: the toggle the upstream ``group_var``
|
|
#: picked, and the toggles upstream set to a yes word (row order).
|
|
forced: str = ""
|
|
upstream_on: list[str] = field(default_factory=list)
|
|
|
|
|
|
def _str(v: Any) -> str:
|
|
return v.strip() if isinstance(v, str) else ""
|
|
|
|
|
|
def parse_config(raw: Any) -> Config:
|
|
"""Widget JSON (string or dict) → :class:`Config`. Anything broken is
|
|
dropped quietly; an unreadable value is an empty node."""
|
|
data = raw
|
|
if isinstance(raw, str):
|
|
try:
|
|
data = json.loads(raw) if raw.strip() else {}
|
|
except ValueError:
|
|
data = {}
|
|
if not isinstance(data, dict):
|
|
data = {}
|
|
raw_mode = data.get("mode")
|
|
mode = raw_mode if isinstance(raw_mode, str) and raw_mode in MODES else "any"
|
|
toggles: list[Toggle] = []
|
|
rows = data.get("rows")
|
|
for row in rows if isinstance(rows, list) else []:
|
|
if not isinstance(row, dict) or row.get("kind", "toggle") != "toggle":
|
|
continue
|
|
tid = _str(row.get("id"))
|
|
if not tid:
|
|
continue
|
|
out = row.get("out")
|
|
value = row.get("value")
|
|
negative = row.get("negative")
|
|
off_value = row.get("off_value")
|
|
off_negative = row.get("off_negative")
|
|
toggles.append(Toggle(
|
|
id=tid,
|
|
name=_str(row.get("name")) or tid,
|
|
var=_str(row.get("var")),
|
|
on=row.get("on") is True,
|
|
pin=row.get("pin") is True,
|
|
out=out if isinstance(out, int) and not isinstance(out, bool) else 0,
|
|
needs=_str(row.get("needs")),
|
|
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 "",
|
|
off_value=off_value if isinstance(off_value, str) else "",
|
|
off_negative=off_negative.strip() if isinstance(off_negative, str) else "",
|
|
))
|
|
return Config(
|
|
mode=mode,
|
|
group_var=_str(data.get("group_var")),
|
|
toggles=toggles,
|
|
group_visible=data.get("group_visible") is True,
|
|
group_upstream=data.get("group_upstream") is True,
|
|
)
|
|
|
|
|
|
def upstream_bool(value: Any) -> bool | None:
|
|
"""An upstream value as a switch position, or ``None`` when it isn't a
|
|
lone yes/no word."""
|
|
if isinstance(value, bool):
|
|
return value
|
|
if not isinstance(value, str):
|
|
return None
|
|
low = value.strip().lower()
|
|
if low in TRUTHY:
|
|
return True
|
|
if low in FALSY:
|
|
return False
|
|
return None
|
|
|
|
|
|
def with_upstream(cfg: Config, upstream: dict[str, Any] | None) -> tuple[Config, list[str]]:
|
|
"""``cfg`` with each ``upstream`` toggle's switch set from ``upstream``
|
|
where it holds a yes/no word for the toggle's variable. Returns the new
|
|
config and the ids of the toggles it set."""
|
|
if not upstream:
|
|
return cfg, []
|
|
set_ids: list[str] = []
|
|
on_ids: list[str] = []
|
|
toggles: list[Toggle] = []
|
|
for t in cfg.toggles:
|
|
val = upstream_bool(upstream.get(t.var)) if t.upstream and t.var else None
|
|
if val is None:
|
|
toggles.append(t)
|
|
continue
|
|
set_ids.append(t.id)
|
|
if val:
|
|
on_ids.append(t.id)
|
|
toggles.append(Toggle(**{**t.__dict__, "on": val}))
|
|
forced = ""
|
|
named = upstream.get(cfg.group_var) if cfg.group_var else None
|
|
if cfg.mode == "one" and cfg.group_upstream and isinstance(named, str) and named.strip():
|
|
key = named.strip().lower()
|
|
hit = next((t for t in toggles if key in (t.name.lower(), t.var.lower())), None)
|
|
if hit is not None:
|
|
forced = hit.id
|
|
set_ids.append(hit.id)
|
|
out = Config(**{**cfg.__dict__, "toggles": toggles, "forced": forced, "upstream_on": on_ids})
|
|
return out, set_ids
|
|
|
|
|
|
def picked_id(cfg: Config) -> str | None:
|
|
"""Pick one: the toggle that is on (see the module docstring)."""
|
|
ids = {t.id for t in cfg.toggles}
|
|
if cfg.forced in ids:
|
|
return cfg.forced
|
|
first_up = next((i for i in cfg.upstream_on if i in ids), None)
|
|
if first_up is not None:
|
|
return first_up
|
|
return next((t.id for t in cfg.toggles if t.on), None)
|
|
|
|
|
|
def effective(cfg: Config) -> dict[str, bool]:
|
|
"""Toggle id → whether it is on after ``needs`` / Pick one."""
|
|
if cfg.mode == "one":
|
|
return {t.id: t.id == picked_id(cfg) for t in cfg.toggles}
|
|
by_id = {t.id: t for t in cfg.toggles}
|
|
memo: dict[str, bool] = {}
|
|
|
|
def eff(tid: str, seen: frozenset[str]) -> bool:
|
|
if tid in memo:
|
|
return memo[tid]
|
|
t = by_id.get(tid)
|
|
if t is None or tid in seen:
|
|
return False # missing parent or a loop: off
|
|
val = t.on and (not t.needs or eff(t.needs, seen | {tid}))
|
|
memo[tid] = val
|
|
return val
|
|
|
|
return {t.id: eff(t.id, frozenset()) for t in cfg.toggles}
|
|
|
|
|
|
def problems(cfg: Config) -> list[str]:
|
|
"""What the node warns about: bad or repeated variable names, a needs
|
|
loop. The canvas shows the same in the widget."""
|
|
out: list[str] = []
|
|
seen: dict[str, str] = {}
|
|
for t in cfg.toggles:
|
|
if not t.var:
|
|
continue
|
|
if not VAR_RE.match(t.var):
|
|
out.append(f'"{t.name}" has a variable name that can\'t be used: {t.var}')
|
|
continue
|
|
if t.var in seen:
|
|
out.append(f'"{t.name}" and "{seen[t.var]}" both write ${t.var}; the lower one wins')
|
|
else:
|
|
seen[t.var] = t.name
|
|
if cfg.group_var and not VAR_RE.match(cfg.group_var):
|
|
out.append(f"The Pick one variable can't be used: {cfg.group_var}")
|
|
if cfg.mode == "any":
|
|
by_id = {t.id: t for t in cfg.toggles}
|
|
for t in cfg.toggles:
|
|
cur, seen_ids = t, {t.id}
|
|
while cur.needs and cur.needs in by_id:
|
|
if cur.needs in seen_ids:
|
|
out.append(f'"{t.name}" needs itself through a loop, so it stays off')
|
|
break
|
|
seen_ids.add(cur.needs)
|
|
cur = by_id[cur.needs]
|
|
return out
|
|
|
|
|
|
#: Cross-node internal: every toggle variable's on/off, what `$name.bool`
|
|
#: reads (a toggle with a value writes the value, not the boolean).
|
|
BOOLS_KEY = "__wp_toggle_bools__"
|
|
|
|
|
|
def resolve(cfg: Config) -> dict[str, str]:
|
|
"""Variable → its value (plus the Pick one variable), in row order. A
|
|
toggle with a Value or an Off value writes the Value while on and the Off
|
|
value (often ``""``) while off; one with neither writes ``"true"`` /
|
|
``"false"``. A later row with the same variable wins."""
|
|
eff = effective(cfg)
|
|
values: dict[str, str] = {}
|
|
for t in cfg.toggles:
|
|
if t.var and VAR_RE.match(t.var):
|
|
if t.writes_text:
|
|
values[t.var] = t.value if eff[t.id] else t.off_value
|
|
else:
|
|
values[t.var] = TRUE if eff[t.id] else FALSE
|
|
if cfg.mode == "one" and cfg.group_var and VAR_RE.match(cfg.group_var):
|
|
picked = next((t for t in cfg.toggles if eff[t.id]), None)
|
|
values[cfg.group_var] = (picked.value or picked.name) if picked else ""
|
|
return values
|
|
|
|
|
|
def bools(cfg: Config) -> dict[str, bool]:
|
|
"""Each toggle variable's on/off (`$name.bool`); a later row wins."""
|
|
eff = effective(cfg)
|
|
return {t.var: eff[t.id] for t in cfg.toggles if t.var and VAR_RE.match(t.var)}
|
|
|
|
|
|
def own_negatives(cfg: Config) -> dict[str, str]:
|
|
"""Toggle variable → its negative: the Negative while on, the Off
|
|
negative while off, left out when that one is empty. A later row writing
|
|
the same variable decides, as in :func:`resolve`."""
|
|
eff = effective(cfg)
|
|
out: dict[str, str] = {}
|
|
for t in cfg.toggles:
|
|
if t.var and VAR_RE.match(t.var):
|
|
text = (t.negative if eff[t.id] else t.off_negative).strip()
|
|
if text:
|
|
out[t.var] = text
|
|
else:
|
|
out.pop(t.var, None)
|
|
return out
|
|
|
|
|
|
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
|
|
:func:`resolve`) and the Pick one variable unless ``group_visible``."""
|
|
flags: dict[str, bool] = {}
|
|
for t in cfg.toggles:
|
|
if t.var and VAR_RE.match(t.var):
|
|
flags[t.var] = not t.visible
|
|
if cfg.mode == "one" and cfg.group_var and VAR_RE.match(cfg.group_var):
|
|
flags[cfg.group_var] = not cfg.group_visible
|
|
return {k for k, v in flags.items() if v}
|
|
|
|
|
|
def output_slots(cfg: Config) -> list[Toggle]:
|
|
"""Pinned toggles in output order (slot 1, 2, …). Uses each row's ``out``
|
|
when those are 1..N with no gaps or repeats, else row order."""
|
|
pinned = [t for t in cfg.toggles if t.pin][:MAX_OUTPUTS]
|
|
outs = sorted(t.out for t in pinned)
|
|
if outs == list(range(1, len(pinned) + 1)):
|
|
return sorted(pinned, key=lambda t: t.out)
|
|
return pinned
|
|
|
|
|
|
def output_values(cfg: Config) -> list[bool]:
|
|
"""The 16 BOOLEAN outputs: pinned toggles first, then ``False`` padding."""
|
|
eff = effective(cfg)
|
|
vals = [eff[t.id] for t in output_slots(cfg)]
|
|
return vals + [False] * (MAX_OUTPUTS - len(vals))
|