# -*- coding: utf-8 -*- #!/usr/bin/env python3 import sys import os import inspect import importlib 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'" 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 data_manager.start_api_server() # --- 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 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)) 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 if that's the node being created. """ 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 class BorealisWindow(QtWidgets.QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Project Borealis - Workflow Automation System") # Create NodeGraph and widget self.graph = NodeGraph() # Grid styling changes self.graph.set_background_color(20, 20, 20) # Dark gray self.graph.set_grid_color(60, 60, 60) # Gray grid lines # Add gradient background scene = self.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)) # Load custom nodes from "Nodes" folder custom_nodes_by_category = import_nodes_from_folder("Nodes") for category, node_classes in custom_nodes_by_category.items(): for node_class in node_classes: self.graph.register_node(node_class) # Build the right-click context menu for the graph self._build_graph_context_menu(custom_nodes_by_category) # Setup the central widget to show the NodeGraph self.setCentralWidget(self.graph.widget) # Build the top menu bar self._build_menubar() # Create a status bar self.setStatusBar(QtWidgets.QStatusBar(self)) # Default status message self.update_status_bar("Flask Server: http://0.0.0.0:5000/data") # Resize self.resize(1200, 800) def _build_graph_context_menu(self, custom_nodes_by_category): """ Build context menu and re-apply the custom stylesheet for a 'blue-ish' highlight, removing the pink/purple highlight. """ # Grab the node graph's context menu graph_context_menu = self.graph.get_context_menu("graph") # We can define a custom style for the QMenu objects: 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; } """ # Apply the custom style if graph_context_menu and graph_context_menu.qmenu: graph_context_menu.qmenu.setStyleSheet(menu_stylesheet) # Top-level "Add Nodes" folder in the context menu add_nodes_menu = graph_context_menu.add_menu("Add Nodes") # If you want the same style for "Add Nodes" submenus: if add_nodes_menu and add_nodes_menu.qmenu: add_nodes_menu.qmenu.setStyleSheet(menu_stylesheet) # For each category, build a submenu under "Add Nodes" for category, node_classes in custom_nodes_by_category.items(): category_menu = add_nodes_menu.add_menu(category) # Also reapply style for each new sub-menu: if category_menu and category_menu.qmenu: category_menu.qmenu.setStyleSheet(menu_stylesheet) 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(self.graph, node_type) ) # Provide a way to remove selected nodes graph_context_menu.add_command( "Remove Selected Node", lambda: [self.graph.remove_node(node) for node in self.graph.selected_nodes()] if self.graph.selected_nodes() else None ) # No 'Workflows' portion here because we moved it into the top menubar. def _build_menubar(self): menubar = self.menuBar() # 1) Workflows menu in menubar workflows_menu = menubar.addMenu("Workflows") load_action = QtWidgets.QAction("Load Workflow", self) load_action.triggered.connect(lambda: load_workflow(self.graph)) workflows_menu.addAction(load_action) save_action = QtWidgets.QAction("Save Workflow", self) save_action.triggered.connect(lambda: save_workflow(self.graph)) workflows_menu.addAction(save_action) close_action = QtWidgets.QAction("Close Workflow", self) close_action.triggered.connect(lambda: close_workflow(self.graph)) workflows_menu.addAction(close_action) # 2) About menu about_menu = menubar.addMenu("About") # "Gitea Project" option gitea_action = QtWidgets.QAction("Gitea Project", self) gitea_action.triggered.connect(self._open_gitea_project) about_menu.addAction(gitea_action) # "Credits" option credits_action = QtWidgets.QAction("Credits", self) credits_action.triggered.connect(self._show_credits_popup) about_menu.addAction(credits_action) # "Check for Updates" option updates_action = QtWidgets.QAction("Check for Updates", self) updates_action.triggered.connect(self._show_updates_popup) about_menu.addAction(updates_action) def _open_gitea_project(self): url = QtCore.QUrl("https://git.bunny-lab.io/Scripts/Project_Borealis") QtGui.QDesktopServices.openUrl(url) def _show_credits_popup(self): QtWidgets.QMessageBox.information( self, "Credits", "Created by Nicole Rappe" ) def _show_updates_popup(self): QtWidgets.QMessageBox.information( self, "Check for Updates", "Built-in update functionality has not been built yet, but it's on the roadmap. Stay tuned." ) def update_status_bar(self, data: str): """ Flattens multi-line data into a single line (using ' | ' to replace newlines) and shows it in the status bar. """ flattened = data.replace("\n", " | ") self.statusBar().showMessage(flattened) def main(): app = QtWidgets.QApplication(sys.argv) window = BorealisWindow() window.show() sys.exit(app.exec_()) if __name__ == "__main__": main()