Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5419366bde | |||
| d0dafa1d39 | |||
| b4639a73d3 | |||
| 84fc4f1cf1 |
@@ -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.
|
||||||
+20
-7
@@ -22,26 +22,39 @@ class TextGate:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def INPUT_TYPES(cls):
|
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 {
|
return {
|
||||||
"required": {
|
|
||||||
"text": ("STRING", {"forceInput": True}),
|
|
||||||
},
|
|
||||||
"optional": {
|
"optional": {
|
||||||
|
"text": ("STRING", {"forceInput": True}),
|
||||||
"signal": (ANY, {}),
|
"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"},
|
"hidden": {"unique_id": "UNIQUE_ID"},
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def IS_CHANGED(cls, **kwargs):
|
def IS_CHANGED(cls, protected=False, stored_text="", **kwargs):
|
||||||
return float("nan")
|
# 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
|
from . import gate_server
|
||||||
import comfy.model_management as mm
|
import comfy.model_management as mm
|
||||||
|
|
||||||
gate_bus.GateBus.arm(unique_id)
|
gate_bus.GateBus.arm(unique_id)
|
||||||
gate_server.send_text(unique_id, text)
|
gate_server.send_text(unique_id, text or "")
|
||||||
try:
|
try:
|
||||||
edited = gate_bus.GateBus.wait_payload(
|
edited = gate_bus.GateBus.wait_payload(
|
||||||
unique_id, should_cancel=mm.processing_interrupted)
|
unique_id, should_cancel=mm.processing_interrupted)
|
||||||
|
|||||||
@@ -16,3 +16,31 @@ def test_textgate_io_shape():
|
|||||||
def test_textgate_is_changed_nan():
|
def test_textgate_is_changed_nan():
|
||||||
v = textgate.TextGate.IS_CHANGED(text="hi", unique_id="1")
|
v = textgate.TextGate.IS_CHANGED(text="hi", unique_id="1")
|
||||||
assert math.isnan(v)
|
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)
|
||||||
|
|||||||
+73
-1
@@ -28,6 +28,43 @@ const MIN_EDITOR_H = 140; // textarea floor
|
|||||||
const BTN_ROW_H = 34; // Pass button row
|
const BTN_ROW_H = 34; // Pass button row
|
||||||
const MARGIN = 10; // ComfyUI DOM-widget inset, matches the other nodes
|
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 ------------------------------------------------------------
|
// ---- server call ------------------------------------------------------------
|
||||||
|
|
||||||
async function postPass(node, text) {
|
async function postPass(node, text) {
|
||||||
@@ -64,10 +101,17 @@ function setState(node, s) {
|
|||||||
node._tgState = s;
|
node._tgState = s;
|
||||||
const tg = node._tg;
|
const tg = node._tg;
|
||||||
if (!tg) return;
|
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";
|
tg.runHere.style.display = s === "passed" ? "" : "none";
|
||||||
if (s === "paused") tg.status.textContent = "edit, then Pass";
|
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 === "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);
|
node.setDirtyCanvas?.(true, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +167,8 @@ function setupTextGateNode(node) {
|
|||||||
area.placeholder = "waiting for a run…";
|
area.placeholder = "waiting for a run…";
|
||||||
// don't let typing/space toggle node selection or graph shortcuts
|
// don't let typing/space toggle node selection or graph shortcuts
|
||||||
area.onkeydown = (e) => e.stopPropagation();
|
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");
|
const btns = document.createElement("div");
|
||||||
btns.className = "tgate-btns";
|
btns.className = "tgate-btns";
|
||||||
@@ -173,6 +219,22 @@ function setupTextGateNode(node) {
|
|||||||
return r;
|
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)
|
// 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]]);
|
node.setSize([Math.max(node.size?.[0] || 0, MIN_W), node.computeSize()[1]]);
|
||||||
syncWidgetWidth(node);
|
syncWidgetWidth(node);
|
||||||
@@ -187,6 +249,7 @@ app.registerExtension({
|
|||||||
const d = e.detail || {};
|
const d = e.detail || {};
|
||||||
const node = app.graph?.getNodeById?.(parseInt(d.id, 10));
|
const node = app.graph?.getNodeById?.(parseInt(d.id, 10));
|
||||||
if (!node || node.type !== NODE || !node._tg) return;
|
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)
|
// 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
|
// keeps YOUR edited text so the gate re-emits it downstream; a normal
|
||||||
// Queue shows whatever the upstream produced. Keying off the button —
|
// Queue shows whatever the upstream produced. Keying off the button —
|
||||||
@@ -211,5 +274,14 @@ app.registerExtension({
|
|||||||
setupTextGateNode(this);
|
setupTextGateNode(this);
|
||||||
return r;
|
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;
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user