276 lines
10 KiB
Python
276 lines
10 KiB
Python
# -*- 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_())
|