# -*- coding: utf-8 -*- #!/usr/bin/env python3 import sys import pkgutil import importlib import inspect import os from Qt import QtWidgets, QtCore, QtGui # ------------------------------------------------------------------ # MONKEY-PATCH to fix "module 'qtpy.QtGui' has no attribute 'QUndoStack'" try: from qtpy.QtWidgets import QUndoStack import qtpy qtpy.QtGui.QUndoStack = QUndoStack except ImportError: print("WARNING: Could not monkey-patch QUndoStack. You may see an error if OdenGraphQt needs it.") # ------------------------------------------------------------------ # ------------------------------------------------------------------ # MONKEY-PATCH to fix "'BackdropNodeItem' object has no attribute 'widgets'" # by giving BackdropNodeItem a trivial widgets dictionary. try: from OdenGraphQt.nodes.backdrop_node import BackdropNodeItem if not hasattr(BackdropNodeItem, "widgets"): BackdropNodeItem.widgets = {} except ImportError: print("WARNING: Could not monkey-patch BackdropNodeItem to add `widgets`.") # ------------------------------------------------------------------ # Import your data_manager so we can start the Flask server from Modules import data_manager # --- BEGIN ROBUST PATCH FOR QGraphicsScene.setSelectionArea --- _original_setSelectionArea = QtWidgets.QGraphicsScene.setSelectionArea def _patched_setSelectionArea(self, *args, **kwargs): """ A robust patch that handles various call signatures for QGraphicsScene.setSelectionArea(). """ try: return _original_setSelectionArea(self, *args, **kwargs) except TypeError: if not args: raise painterPath = args[0] selection_op = QtCore.Qt.ReplaceSelection selection_mode = QtCore.Qt.IntersectsItemShape transform = QtGui.QTransform() return _original_setSelectionArea(self, painterPath, selection_op, selection_mode, transform) QtWidgets.QGraphicsScene.setSelectionArea = _patched_setSelectionArea # --- END PATCH --- from OdenGraphQt import NodeGraph, BaseNode from OdenGraphQt.widgets.dialogs import FileDialog def import_nodes_from_folder(package_name): """ Recursively import all modules from the given package. Returns a dictionary where keys are subfolder names, and values are lists of BaseNode subclasses. """ nodes_by_category = {} package = importlib.import_module(package_name) package_path = package.__path__[0] for root, _, files in os.walk(package_path): rel_path = os.path.relpath(root, package_path).replace(os.sep, '.') module_prefix = f"{package_name}.{rel_path}" if rel_path != '.' else package_name category_name = os.path.basename(root) for file in files: if file.endswith(".py") and file != "__init__.py": module_name = f"{module_prefix}.{file[:-3]}" try: module = importlib.import_module(module_name) for name, obj in inspect.getmembers(module, inspect.isclass): if issubclass(obj, BaseNode) and obj.__module__ == module.__name__: if category_name not in nodes_by_category: nodes_by_category[category_name] = [] nodes_by_category[category_name].append(obj) except Exception as e: print(f"Failed to import {module_name}: {e}") return nodes_by_category def make_node_command(graph, node_type_str): """ Return a function that creates a node of the given type at the current cursor position. Ensures that only one FlyffCharacterStatusNode exists. """ def real_create(): if node_type_str.startswith("bunny-lab.io.flyff_character_status_node"): for node in graph.all_nodes(): if node.__class__.__name__ == "FlyffCharacterStatusNode": QtWidgets.QMessageBox.critical( None, "Error", "Only one Flyff Character Status Collector node is allowed." ) return try: pos = graph.cursor_pos() graph.create_node(node_type_str, pos=pos) except Exception as e: QtWidgets.QMessageBox.critical(None, "Error", str(e)) def command(): if QtWidgets.QApplication.instance(): real_create() else: QtCore.QTimer.singleShot(0, real_create) return command def ensure_workflows_folder(): """ Ensures a 'Workflows' subfolder exists. """ if not os.path.exists("Workflows"): os.makedirs("Workflows") def close_workflow(graph: NodeGraph): """ Closes the current workflow (removes all nodes and connections). """ graph.clear_session() def save_workflow(graph: NodeGraph): """ Saves the current workflow (including custom names, positions, wires, etc.) into a JSON file in the 'Workflows' subfolder. """ ensure_workflows_folder() file_filter = "JSON Files (*.json);;All Files (*.*)" dlg = FileDialog.getSaveFileName(None, "Save Workflow", os.path.join("Workflows", ""), file_filter) file_path = dlg[0] if not file_path: return # User canceled if not file_path.lower().endswith(".json"): file_path += ".json" try: graph.save_session(file_path) print(f"Workflow saved to {file_path}") except Exception as e: QtWidgets.QMessageBox.critical(None, "Error Saving Workflow", str(e)) def load_workflow(graph: NodeGraph): """ Loads a workflow (including node values, connections, positions, etc.) from a specified JSON file. """ ensure_workflows_folder() file_filter = "JSON Files (*.json);;All Files (*.*)" dlg = FileDialog.getOpenFileName(None, "Load Workflow", os.path.join("Workflows", ""), file_filter) file_path = dlg[0] if not file_path: return # User canceled try: graph.load_session(file_path) print(f"Workflow loaded from {file_path}") except Exception as e: QtWidgets.QMessageBox.critical(None, "Error Loading Workflow", str(e)) if __name__ == "__main__": app = QtWidgets.QApplication([]) # Start Flask API Server data_manager.start_api_server() # Create the NodeGraph graph = NodeGraph() graph.widget.setWindowTitle("Project Borealis - Workflow Automation System") # Dynamically import custom node classes from the 'Nodes' package. custom_nodes_by_category = import_nodes_from_folder("Nodes") # Register each node in its category for category, node_classes in custom_nodes_by_category.items(): for node_class in node_classes: graph.register_node(node_class) # Create root context menu graph_context_menu = graph.get_context_menu("graph") # Create a top-level "Add Nodes" folder in the context menu add_nodes_menu = graph_context_menu.add_menu("Add Nodes") # Insert each category as a submenu under "Add Nodes" for category, node_classes in custom_nodes_by_category.items(): category_menu = add_nodes_menu.add_menu(category) for node_class in node_classes: node_type = f"{node_class.__identifier__}.{node_class.__name__}" node_name = node_class.NODE_NAME category_menu.add_command( node_name, make_node_command(graph, node_type) ) # Provide a way to remove selected nodes from the root menu (not in Add Nodes) graph_context_menu.add_command( "Remove Selected Node", lambda: [graph.remove_node(node) for node in graph.selected_nodes()] if graph.selected_nodes() else None ) # Add a top-level "Workflows" menu for saving/loading/closing workflow_menu = graph_context_menu.add_menu("Workflows") workflow_menu.add_command("Load Workflow", lambda: load_workflow(graph)) workflow_menu.add_command("Save Workflow", lambda: save_workflow(graph)) workflow_menu.add_command("Close Workflow", lambda: close_workflow(graph)) # ------------------------------------------------------------------ # Custom stylesheet to control highlight color & overall QMenu look: # ------------------------------------------------------------------ menu_stylesheet = """ QMenu { background-color: rgb(30, 30, 30); border: 1px solid rgba(200, 200, 200, 60); } QMenu::item { padding: 5px 18px 2px; background-color: transparent; } QMenu::item:selected { color: rgb(255, 255, 255); background-color: rgb(60, 120, 180); /* set your highlight color here */ } QMenu::separator { height: 1px; background: rgba(255,255,255,20); margin: 4px 8px; } """ # Apply to each top-level menu. Submenus inherit from the parent menu. graph_context_menu.qmenu.setStyleSheet(menu_stylesheet) add_nodes_menu.qmenu.setStyleSheet(menu_stylesheet) workflow_menu.qmenu.setStyleSheet(menu_stylesheet) # ------------------------------------------------------------------ # Grid styling changes graph.set_background_color(20, 20, 20) # Dark gray graph.set_grid_color(60, 60, 60) # Gray grid lines # Add gradient background scene = graph.scene() gradient = QtGui.QLinearGradient(0, 0, 0, 1) gradient.setCoordinateMode(QtGui.QGradient.ObjectBoundingMode) gradient.setColorAt(0.0, QtGui.QColor(9, 44, 68)) gradient.setColorAt(0.3, QtGui.QColor(30, 30, 30)) gradient.setColorAt(0.7, QtGui.QColor(30, 30, 30)) gradient.setColorAt(1.0, QtGui.QColor(9, 44, 68)) scene.setBackgroundBrush(QtGui.QBrush(gradient)) # Resize and show the graph widget graph.widget.resize(1600, 900) graph.widget.show() # Global update function def global_update(): for node in graph.all_nodes(): if hasattr(node, "process_input"): try: node.process_input() except Exception as e: print("Error updating node", node, e) timer = QtCore.QTimer() timer.timeout.connect(global_update) timer.start(500) sys.exit(app.exec_())