Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions examples/dialog_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,9 @@ def _config(self) -> EditorConfig:
workflow_name=self._workflow_path.rsplit("/", 1)[-1],
start_node_title="Krita",
start_outputs=[
StartOutputConfig("prompt", "STRING"),
StartOutputConfig("prompt", "STRING", required=True),
StartOutputConfig("negative", "STRING"),
StartOutputConfig("seed", "INT"),
StartOutputConfig("seed", "INT", required=True),
StartOutputConfig("image", "IMAGE"),
],
)
Expand Down
31 changes: 30 additions & 1 deletion src/comfy_graph_bind/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
QApplication,
QDialog,
QDialogButtonBox,
QMessageBox,
QToolBar,
QVBoxLayout,
QWidget,
Expand Down Expand Up @@ -133,6 +134,18 @@ def get_result(self) -> dict:
def clear_user_links(self) -> None:
self._scene.clear_user_links()

def unwired_required_start_outputs(self) -> list:
"""Return the start-node output ports that are required but unwired."""
return self._scene.unwired_required_start_outputs()

def required_start_outputs_with_bad_links(self) -> list:
"""Return required start outputs that are unwired or wired to a missing target."""
return self._scene.required_start_outputs_with_bad_links()

def apply_start_output_errors(self) -> list:
"""Paint required ports with bad links red; clear the rest. Returns errored ports."""
return self._scene.apply_start_output_errors()

def _build_nodes(self, graph: WorkflowGraph) -> None:
for node in graph.nodes:
item = self._make_node_item(node, port_filter=self._config.port_filter)
Expand Down Expand Up @@ -251,14 +264,15 @@ def __init__(
layout.addWidget(self._editor.view())

buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.accepted.connect(self._on_ok)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
spacing = layout.spacing()
layout.setContentsMargins(0, 0, spacing, spacing)

if initial_result is not None:
self._apply_initial_result(initial_result)
self._editor.apply_start_output_errors()

@classmethod
def from_api_workflow(
Expand All @@ -281,6 +295,21 @@ def editor_result(self) -> dict | None:
return None
return self._editor.get_result()

def _on_ok(self) -> None:
self._editor.apply_start_output_errors()
errored = self._editor.required_start_outputs_with_bad_links()
if errored:
names = ", ".join(sorted(p.ref.name for p in errored))
QMessageBox.warning(
self,
"Missing required inputs",
"The following required start-node outputs are not wired to a valid target:\n\n"
f" {names}\n\n"
"Connect them to a target before accepting.",
)
return
self.accept()

def _apply_initial_result(self, initial: dict) -> None:
if not isinstance(initial, dict):
return
Expand Down
24 changes: 20 additions & 4 deletions src/comfy_graph_bind/port_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,16 @@ class PortRef:
is_input: bool
is_widget: bool = False
is_start: bool = False
required: bool = False


def _port_color(is_input: bool, is_start: bool) -> QColor:
_START_OUTPUT_COLOR = QColor("#3fb950")
_START_OUTPUT_ERROR_COLOR = QColor("#d63a3a")


def _port_color(is_input: bool, is_start: bool, has_error: bool = False) -> QColor:
if is_start and not is_input:
return QColor("#3fb950")
return _START_OUTPUT_ERROR_COLOR if has_error else _START_OUTPUT_COLOR
return QColor("#4a9eff") if not is_input else QColor("#ff9a4a")


Expand All @@ -49,7 +54,8 @@ def __init__(
self._ref = ref
self._radius = radius
self._hovered = False
color = _port_color(ref.is_input, ref.is_start)
self._has_error = False
color = _port_color(ref.is_input, ref.is_start, self._has_error)
self.setBrush(QBrush(color))
self.setPen(QPen(color.darker(140), 1.2))
self.setAcceptHoverEvents(True)
Expand All @@ -62,6 +68,16 @@ def __init__(
def ref(self) -> PortRef:
return self._ref

def set_error(self, has_error: bool) -> None:
"""Mark or clear the port's error state and repaint it."""
if has_error == self._has_error:
return
self._has_error = has_error
color = _port_color(self._ref.is_input, self._ref.is_start, self._has_error)
self.setBrush(QBrush(color))
self.setPen(QPen(color.darker(160), 1.4))
self.update()

def scene_center(self) -> QPointF:
return self.scenePos()

Expand All @@ -84,7 +100,7 @@ def paint( # noqa: D401 - Qt API
) -> None: # type: ignore[override]
del option, widget
painter.setRenderHint(painter.Antialiasing, True)
color = _port_color(self._ref.is_input, self._ref.is_start)
color = _port_color(self._ref.is_input, self._ref.is_start, self._has_error)
r = PORT_HOVER_RADIUS if self._hovered else self._radius
painter.setBrush(QBrush(color))
painter.setPen(QPen(color.darker(160), 1.4))
Expand Down
60 changes: 58 additions & 2 deletions src/comfy_graph_bind/scene.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,18 +98,69 @@ def create_link(self, source: PortRef, target: PortRef) -> LinkItem | None:
self._links[link_id] = link
self.addItem(link)
self._refresh_link(link)
if source.is_start:
self._refresh_required_errors()
return link

def remove_link(self, link_id: int) -> None:
link = self._links.pop(link_id, None)
if link is not None:
self.removeItem(link)
if link is None:
return
was_start = link.source.is_start
self.removeItem(link)
if was_start:
self._refresh_required_errors()

def clear_user_links(self) -> None:
had_start = any(link.source.is_start for link in self._links.values())
for link in list(self._links.values()):
if link.source.is_start:
self.removeItem(link)
self._links.pop(link.link_id, None)
if had_start:
self._refresh_required_errors()

def required_start_outputs_with_bad_links(self) -> list[PortItem]:
"""Required start outputs that are unwired or wired to a missing target."""
required_names = self._start_node.required_output_names
if not required_names:
return []
bad: list[PortItem] = []
for port in self._start_node.output_ports():
if port.ref.name not in required_names:
continue
link = self._find_link_for_start_output(port.ref)
if link is None or not self._target_input_resolves(link):
bad.append(port)
return bad

def unwired_required_start_outputs(self) -> list[PortItem]:
"""Return required start outputs that have no outgoing link at all."""
return [
p
for p in self.required_start_outputs_with_bad_links()
if self._find_link_for_start_output(p.ref) is None
]

def apply_start_output_errors(self) -> list[PortItem]:
"""Paint all required start outputs with bad links red; clear the rest."""
bad = self.required_start_outputs_with_bad_links()
bad_set = {id(p) for p in bad}
for port in self._start_node.output_ports():
port.set_error(id(port) in bad_set)
return bad

def _target_input_resolves(self, link: LinkItem) -> bool:
node = self._node_items.get(link.target.node_id)
if node is None:
return False
return any(p.ref.name == link.target.name for p in node.input_ports())

def _refresh_required_errors(self) -> None:
bad = self.required_start_outputs_with_bad_links()
bad_set = {id(p) for p in bad}
for port in self._start_node.output_ports():
port.set_error(id(port) in bad_set)

def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None:
view = next(iter(self.views()), None)
Expand All @@ -133,14 +184,19 @@ def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None:
view = next(iter(self.views()), None)
transform = view.transform() if view is not None else QTransform()
target_item = self.itemAt(event.scenePos(), transform)
touched_start = False
if isinstance(target_item, PortItem) and target_item.ref.is_input:
self.create_link(self._drag_source.ref, target_item.ref)
touched_start = True
elif self._drag_source.ref.is_start:
existing = self._find_link_for_start_output(self._drag_source.ref)
if existing is not None:
self.removeItem(existing)
self._links.pop(existing.link_id, None)
touched_start = True
self._end_link_drag()
if touched_start:
self._refresh_required_errors()
event.accept()
return
super().mouseReleaseEvent(event)
Expand Down
53 changes: 48 additions & 5 deletions src/comfy_graph_bind/start_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from collections.abc import Sequence
from dataclasses import dataclass

from PyQt5.QtCore import QPointF
from PyQt5.QtCore import QPointF, Qt
from PyQt5.QtWidgets import QGraphicsScene

from .layout import node_metrics
Expand All @@ -23,9 +23,11 @@ class StartOutputConfig:

name: str
type: str = "*"
required: bool = False

def label(self) -> str:
return f"{self.name} : {self.type}"
suffix = " *" if self.required else ""
return f"{self.name}{suffix} : {self.type}"


class StartNodeItem(GraphNodeItem):
Expand All @@ -35,6 +37,8 @@ class StartNodeItem(GraphNodeItem):
START_POS = QPointF(-260, 0)

def __init__(self, outputs: Sequence[StartOutputConfig], title: str = "Start") -> None:
self._output_names: list[str] = [o.name for o in outputs]
self._required_outputs: set[str] = {o.name for o in outputs if o.required}
inputs: list[tuple[int, str, str, bool]] = []
output_specs: list[tuple[int, str, str, bool]] = [
(i, o.name, o.type, False) for i, o in enumerate(outputs)
Expand All @@ -49,12 +53,20 @@ def __init__(self, outputs: Sequence[StartOutputConfig], title: str = "Start") -
outputs=output_specs,
is_start=True,
)
self._output_names: list[str] = [o.name for o in outputs]
self._propagate_required_to_ports()
self._refresh_output_labels()

@property
def output_names(self) -> list[str]:
return list(self._output_names)

@property
def required_output_names(self) -> set[str]:
return set(self._required_outputs)

def required_output_label(self, name: str) -> str:
return f"{name} *" if name in self._required_outputs else name

def rebuild_outputs(self, outputs: Sequence[StartOutputConfig]) -> None:
"""Replace the current outputs and reposition the ports."""
from .scene import GraphScene
Expand All @@ -64,11 +76,13 @@ def rebuild_outputs(self, outputs: Sequence[StartOutputConfig]) -> None:
scene.detach_links_for_node(self)
self._port_items.clear()
self._output_names = [o.name for o in outputs]
self._inputs = []
self._required_outputs = {o.name for o in outputs if o.required}

stub = _StubNode("Start", self.title, outputs)
self._outputs = build_node_outputs(stub)
output_labels = [o.name for o in outputs]
output_labels = [
self.required_output_label(o.name) if o.required else o.name for o in outputs
]
self._metrics = node_metrics(
stub,
input_count=0,
Expand All @@ -84,6 +98,7 @@ def rebuild_outputs(self, outputs: Sequence[StartOutputConfig]) -> None:
is_input=False,
is_widget=False,
is_start=True,
required=o.required,
)
port = PortItem(ref)
port.setParentItem(self)
Expand All @@ -93,6 +108,34 @@ def rebuild_outputs(self, outputs: Sequence[StartOutputConfig]) -> None:
if isinstance(scene, QGraphicsScene):
scene.update()

def _propagate_required_to_ports(self) -> None:
for port in self._port_items:
ref = port.ref
if ref.is_input:
continue
if ref.name in self._required_outputs and not ref.required:
port.setData(
Qt.UserRole,
PortRef(
node_id=ref.node_id,
slot_index=ref.slot_index,
name=ref.name,
type=ref.type,
is_input=ref.is_input,
is_widget=ref.is_widget,
is_start=ref.is_start,
required=True,
),
)

def _refresh_output_labels(self) -> None:
for i, (_idx, _name, _type, _is_widget) in enumerate(self._outputs):
if i < len(self._output_names):
name = self._output_names[i]
decorated = self.required_output_label(name)
if decorated != _name:
self._outputs[i] = (i, decorated, _type, _is_widget)


class _StubNode:
def __init__(self, type_name: str, title: str, outputs: Sequence[StartOutputConfig]) -> None:
Expand Down
Loading
Loading