# -*- coding: utf-8 -*- #!/usr/bin/env python3 import sys import pkgutil import importlib import inspect import os from Qt import QtWidgets, QtCore, QtGui # -------------------------------------------------------# # MONKEY PATCHES - MODIFICATIONS TO OdenGraphQT BEHAVIOR # # -------------------------------------------------------# # PATCH: Override the color of interconnection pipes between nodes try: from OdenGraphQt.qgraphics.pipe import PipeItem from OdenGraphQt.qgraphics.node_base import NodeItem from qtpy.QtGui import QPen, QColor from qtpy import QtCore # If you want the original paint logic, capture it first: _orig_paint_pipe = PipeItem.paint _orig_paint_node = NodeItem.paint # Custom pipe painting function def _custom_paint_pipe(self, painter, option, widget=None): painter.save() my_pen = QPen(QColor(0, 161, 115, 255)) # Match desired RGBA my_pen.setWidthF(2.0) painter.setPen(my_pen) _orig_paint_pipe(self, painter, option, widget) painter.restore() # Custom node painting function def _custom_paint_node(self, painter, option, widget=None): painter.save() _orig_paint_node(self, painter, option, widget) # Call original method if self.isSelected(): pen = QPen(QColor(0, 161, 115, 255)) # Set selected border color pen.setWidth(3) painter.setPen(pen) painter.drawRect(self.boundingRect()) painter.restore() # Apply the patches PipeItem.paint = _custom_paint_pipe NodeItem.paint = _custom_paint_node print("Patched PipeItem.paint and NodeItem.paint to override colors.") except ImportError as e: print(f"WARNING: Could not patch PipeItem or NodeItem: {e}") except Exception as e: print(f"Patch for PipeItem or NodeItem override failed: {e}") # PATCH: 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.") # PATCH: 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`.") # PATCH: 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 # ----------------------------------------------------------------------------------------------------- # # Import data_manager so we can start the Flask server from Modules import data_manager 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 and centers it within the graph. """ 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}") # Center the workflow within the graph nodes = graph.all_nodes() if nodes: graph.center_on(nodes) else: print("No nodes found in the loaded workflow.") 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("Borealis - Workflow Automation Tool") # 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 categorized context menu graph_context_menu = graph.get_context_menu("graph") for category, node_classes in custom_nodes_by_category.items(): category_menu = graph_context_menu.add_menu(category) # Create submenu for 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( f"Create: {node_name}", make_node_command(graph, node_type) ) # Add a "Remove Selected Node" command 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 ) # ------------------------------# # WRAPPER: QMainWindow Integration with Additional UI Elements # ------------------------------# # SECTION: Enhanced Graph Wrapper for QMainWindow # This section wraps the NodeGraph widget in a QMainWindow with: # - A menu bar at the top (named "Workflows" menu) # - A blank status bar at the bottom # - A central QSplitter dividing the window horizontally: # * Left side (2/3): the NodeGraph widget # * Right side (1/3): an empty text box for future use _original_show = graph.widget.show # Save original method def _wrapped_show(): """ Wrap the NodeGraph widget inside a QMainWindow with a "Workflows" menu, a status bar, and a central splitter for layout. """ # Create a new QMainWindow instance main_window = QtWidgets.QMainWindow() # Create a menu bar and add a "Workflows" menu menu_bar = main_window.menuBar() workflows_menu = menu_bar.addMenu("Workflows") # Add "Open" action open_action = QtWidgets.QAction("Open", main_window) open_action.triggered.connect(lambda: load_workflow(graph)) workflows_menu.addAction(open_action) # Add "Save" action save_action = QtWidgets.QAction("Save", main_window) save_action.triggered.connect(lambda: save_workflow(graph)) workflows_menu.addAction(save_action) # Add "Close" action close_action = QtWidgets.QAction("Close", main_window) close_action.triggered.connect(lambda: close_workflow(graph)) workflows_menu.addAction(close_action) # Create and set a blank status bar at the bottom. main_window.setStatusBar(QtWidgets.QStatusBar()) # Create a QSplitter for horizontal division. splitter = QtWidgets.QSplitter(QtCore.Qt.Horizontal) # SECTION: Left Pane - Graph Widget splitter.addWidget(graph.widget) # SECTION: Right Pane - Empty Text Box text_edit = QtWidgets.QTextEdit() splitter.addWidget(text_edit) # Set stretch factors: left pane gets 2/3, right pane gets 1/3. splitter.setStretchFactor(0, 2) splitter.setStretchFactor(1, 1) # Set the splitter as the central widget of the main window. main_window.setCentralWidget(splitter) # Transfer the window title from the graph widget to the main window. main_window.setWindowTitle(graph.widget.windowTitle()) # Resize the main window using the size set for the graph widget. main_window.resize(graph.widget.size()) # Store a reference to the main window to prevent it from being garbage collected. graph.widget._main_window = main_window # Show the main window instead of the standalone graph widget. main_window.show() # Monkey-patch the show method of the graph widget. graph.widget.show = _wrapped_show # 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 (which now triggers the QMainWindow wrapper) graph.widget.resize(1600, 900) graph.widget.show() # Override the Color of the Context Menu to Blue 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: rgba(60, 120, 180, 150); } QMenu::separator { height: 1px; background: rgba(255, 255, 255, 50); margin: 4px 8px; } """ graph_context_menu.qmenu.setStyleSheet(menu_stylesheet) # 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_())