diff --git a/docs/plans/2026-06-29-textgate-protected-design.md b/docs/plans/2026-06-29-textgate-protected-design.md new file mode 100644 index 0000000..c163015 --- /dev/null +++ b/docs/plans/2026-06-29-textgate-protected-design.md @@ -0,0 +1,60 @@ +# Text Gate — "Protected" mode (standalone text node) + +**Goal:** A `protected` switch that turns the Text Gate into a standalone text +node: no pause, it outputs the text you typed every run, ignoring upstream. Toggle +off → back to the normal pause/edit/Pass gate using the upstream text. + +Decisions (from brainstorming): +- Protected = **plain-text-node behavior** (no pause), not a "still-pause-but-lock". +- The upstream wire is **kept but its value ignored** while protected (toggle off + resumes upstream seamlessly — no reconnecting). + +## Backend (`gates/textgate.py`) + +The authored text and the flag must reach `run()`, so: + +- `text` input: `required` → **`optional`** (`forceInput` kept), so the node runs + standalone. Existing connections still work. +- New serializing widgets: + - `protected` (BOOLEAN, default `False`) — the switch. + - `stored_text` (STRING) — the authored text, hidden in the UI behind the DOM + editor; the textarea syncs into it. +- `run(self, unique_id=None, text=None, signal=None, protected=False, stored_text="")`: + - `protected` → `return (stored_text, signal)` immediately — no `GateBus`, no + pause, upstream ignored. (Returns early *before* importing comfy, so it stays + import-safe/unit-testable.) + - else → current pause flow, guarding an unconnected input with `text or ""`. +- `IS_CHANGED`: `protected` → return `stored_text` (cache-friendly like a real text + node; downstream only re-runs when the text changes). Else → `float("nan")` (so + the existing NaN test still passes). + +## Frontend (`web/text_gate.js`) + +- Hide the auto-created `stored_text` widget (`computeSize → [0,-4]`, the pool + node's trick); the DOM textarea stays the single editor and writes its value into + `stored_text` on every edit (persists + reaches the backend). +- Read the `protected` boolean toggle (label "🔒 Protected (text node)"). On **ON**: + snapshot the current textarea into `stored_text`, hide Pass / Run-from-here, show + status "🔒 protected — outputs this text, upstream ignored", keep the textarea + editable. On **OFF**: revert to the normal pause UI. +- Ignore the `datasete-textgate-show` socket while protected. On load, populate the + textarea from `stored_text`. + +## Persistence & compat + +`protected` + `stored_text` are real widgets → save/reload restores mode + text. +Old saved TextGates get `protected=false`, `stored_text=""` defaults (the DOM editor +is `serialize:false`, so old nodes carry no conflicting widgets_values). + +## Testing + +- Unit: `run(protected=True, stored_text="hi")` → `("hi", signal)` without touching + `GateBus`; `IS_CHANGED(protected=True, stored_text="hi")` → `"hi"`; + `IS_CHANGED(protected=False)` → `NaN`; `text` is in `INPUT_TYPES()["optional"]`. +- Frontend: `node --check`; manual — toggle protect, edit freely, Run doesn't + overwrite, save/reload keeps text, toggle off resumes upstream. + +## Rejected + +A frontend-only "lock" that still pauses — doesn't give true text-node behavior +(you'd still click Pass each run), which is the point of the switch. diff --git a/gates/textgate.py b/gates/textgate.py index 3abcc7a..ece6b55 100644 --- a/gates/textgate.py +++ b/gates/textgate.py @@ -22,26 +22,39 @@ class TextGate: @classmethod def INPUT_TYPES(cls): + # `text` is optional so the node can run standalone in protected mode. + # `protected` + `stored_text` are serializing widgets carrying the + # authored text-node state (stored_text is hidden by the frontend). return { - "required": { - "text": ("STRING", {"forceInput": True}), - }, "optional": { + "text": ("STRING", {"forceInput": True}), "signal": (ANY, {}), + "protected": ("BOOLEAN", {"default": False}), + # single-line so the frontend can fully hide it (the DOM editor + # is the real text box); the value still holds arbitrary text. + "stored_text": ("STRING", {"default": ""}), }, "hidden": {"unique_id": "UNIQUE_ID"}, } @classmethod - def IS_CHANGED(cls, **kwargs): - return float("nan") + def IS_CHANGED(cls, protected=False, stored_text="", **kwargs): + # Protected = plain text node: cache on the authored text so downstream + # only re-runs when it changes. Otherwise never cache (always pause). + return stored_text if protected else float("nan") + + def run(self, unique_id=None, text=None, signal=None, + protected=False, stored_text=""): + if protected: + # Standalone text node: emit the authored text, ignore upstream, no + # pause. Returns before importing comfy, so it stays import-safe. + return (stored_text, signal) - def run(self, text, unique_id, signal=None): from . import gate_server import comfy.model_management as mm gate_bus.GateBus.arm(unique_id) - gate_server.send_text(unique_id, text) + gate_server.send_text(unique_id, text or "") try: edited = gate_bus.GateBus.wait_payload( unique_id, should_cancel=mm.processing_interrupted) diff --git a/tests/test_textgate.py b/tests/test_textgate.py index 8feb4bf..1979bb6 100644 --- a/tests/test_textgate.py +++ b/tests/test_textgate.py @@ -16,3 +16,31 @@ def test_textgate_io_shape(): def test_textgate_is_changed_nan(): v = textgate.TextGate.IS_CHANGED(text="hi", unique_id="1") assert math.isnan(v) + + +def test_textgate_text_input_is_optional(): + it = textgate.TextGate.INPUT_TYPES() + assert "text" in it["optional"] + assert "protected" in it["optional"] + assert "stored_text" in it["optional"] + + +def test_textgate_protected_returns_stored_text_without_pause(): + # protected mode must return the stored text directly — no GateBus, no comfy + out = textgate.TextGate().run( + unique_id="1", text="from upstream", signal="sig", + protected=True, stored_text="my authored text", + ) + assert out == ("my authored text", "sig") + + +def test_textgate_is_changed_protected_returns_stored_text(): + v = textgate.TextGate.IS_CHANGED( + unique_id="1", protected=True, stored_text="frozen") + assert v == "frozen" + + +def test_textgate_is_changed_not_protected_is_nan(): + v = textgate.TextGate.IS_CHANGED( + unique_id="1", protected=False, stored_text="ignored") + assert math.isnan(v) diff --git a/web/text_gate.js b/web/text_gate.js index 820d47b..d2f88b7 100644 --- a/web/text_gate.js +++ b/web/text_gate.js @@ -28,6 +28,43 @@ const MIN_EDITOR_H = 140; // textarea floor const BTN_ROW_H = 34; // Pass button row const MARGIN = 10; // ComfyUI DOM-widget inset, matches the other nodes +// ---- protected-mode widgets ------------------------------------------------- +// `protected` (BOOLEAN toggle) + `stored_text` (hidden STRING) are real backend +// widgets. When protected, the node acts as a plain text node: it outputs +// stored_text and ignores upstream (no pause). The DOM textarea is the visible +// editor and mirrors its value into stored_text so it persists and reaches run(). + +function widgetByName(node, name) { + return node.widgets?.find((w) => w.name === name); +} + +function isProtected(node) { + return !!widgetByName(node, "protected")?.value; +} + +// mirror the editor text into the hidden stored_text widget (persist + backend) +function syncStored(node) { + const w = widgetByName(node, "stored_text"); + if (w) w.value = node._tg?.area?.value ?? ""; +} + +// collapse the auto-created stored_text widget out of the layout (pool_id trick) +function hideStoredWidget(node) { + const w = widgetByName(node, "stored_text"); + if (w) w.computeSize = () => [0, -4]; +} + +// reflect the persisted protected/stored_text state into the editor + UI +function applyPersistedMode(node) { + if (!node._tg) return; + if (isProtected(node)) { + node._tg.area.value = widgetByName(node, "stored_text")?.value ?? ""; + setState(node, "protected"); + } else { + setState(node, "idle"); + } +} + // ---- server call ------------------------------------------------------------ async function postPass(node, text) { @@ -64,10 +101,17 @@ function setState(node, s) { node._tgState = s; const tg = node._tg; if (!tg) return; - tg.pass.style.display = s === "passed" ? "none" : ""; + // Pass is hidden once passed AND in protected mode (no pause there); + // Run-from-here only in the passed state. + tg.pass.style.display = (s === "passed" || s === "protected") ? "none" : ""; tg.runHere.style.display = s === "passed" ? "" : "none"; if (s === "paused") tg.status.textContent = "edit, then Pass"; else if (s === "passed") tg.status.textContent = "passed — Run from here to re-run"; + else if (s === "protected") tg.status.textContent = "🔒 protected — outputs this text (upstream ignored)"; + else tg.status.textContent = ""; + tg.area.placeholder = s === "protected" + ? "type text (used as a text node)…" + : "waiting for a run…"; node.setDirtyCanvas?.(true, true); } @@ -123,6 +167,8 @@ function setupTextGateNode(node) { area.placeholder = "waiting for a run…"; // don't let typing/space toggle node selection or graph shortcuts area.onkeydown = (e) => e.stopPropagation(); + // keep the hidden stored_text widget mirrored so edits persist + reach run() + area.oninput = () => syncStored(node); const btns = document.createElement("div"); btns.className = "tgate-btns"; @@ -173,6 +219,22 @@ function setupTextGateNode(node) { return r; }; + // protected-mode wiring: hide the stored_text widget, label + react to the + // toggle, and reflect the persisted mode/text into the editor. + hideStoredWidget(node); + const pw = widgetByName(node, "protected"); + if (pw) { + pw.label = "🔒 Protected (text node)"; + const prev = pw.callback; + pw.callback = function () { + const r = prev?.apply(this, arguments); + if (isProtected(node)) { syncStored(node); setState(node, "protected"); } + else setState(node, "idle"); + return r; + }; + } + applyPersistedMode(node); + // sensible default size; the node stays freely resizable (no width floor lock) node.setSize([Math.max(node.size?.[0] || 0, MIN_W), node.computeSize()[1]]); syncWidgetWidth(node); @@ -187,6 +249,7 @@ app.registerExtension({ const d = e.detail || {}; const node = app.graph?.getNodeById?.(parseInt(d.id, 10)); if (!node || node.type !== NODE || !node._tg) return; + if (isProtected(node)) return; // protected = no pause; ignore stray events // Sticky edit by intent: a Run-from-here re-queue (the _tgKeepEdit flag) // keeps YOUR edited text so the gate re-emits it downstream; a normal // Queue shows whatever the upstream produced. Keying off the button — @@ -211,5 +274,14 @@ app.registerExtension({ setupTextGateNode(this); return r; }; + + // loaded workflows restore protected + stored_text after create — re-apply + // the mode so the editor + UI match the saved state. + const onConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function () { + const r = onConfigure?.apply(this, arguments); + if (this._tg) applyPersistedMode(this); + return r; + }; }, });