Restructured project and implemented virtual python environments to isolate application. Added launch scripts too.
This commit is contained in:
440
Data/borealis.py
Normal file
440
Data/borealis.py
Normal file
@ -0,0 +1,440 @@
|
||||
# -*- 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
|
||||
|
||||
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'" (KEEP AROUND FOR LEGACY DOCUMENTATION)
|
||||
#try:
|
||||
# from qtpy.QtWidgets import QUndoStack
|
||||
# import qtpy
|
||||
# qtpy.QtGui.QUndoStack = QUndoStack
|
||||
#except ImportError:
|
||||
# print("WARNING: Could not monkey-patch QUndoStack.")
|
||||
|
||||
# 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)
|
||||
|
||||
# Recursively apply the stylesheet to all submenus
|
||||
def apply_styles_to_submenus(menu):
|
||||
""" Recursively applies the stylesheet to all submenus in the menu. """
|
||||
menu.setStyleSheet(menu_stylesheet)
|
||||
for action in menu.actions():
|
||||
if action.menu(): # Check if action has a submenu
|
||||
apply_styles_to_submenus(action.menu())
|
||||
|
||||
# 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;
|
||||
}
|
||||
"""
|
||||
|
||||
# Create categorized context menu
|
||||
graph_context_menu = graph.get_context_menu("graph")
|
||||
add_node_menu = graph_context_menu.add_menu("Add Node")
|
||||
|
||||
for category, node_classes in custom_nodes_by_category.items():
|
||||
category_menu = add_node_menu.add_menu(category) # Create submenu
|
||||
category_menu.qmenu.setStyleSheet(menu_stylesheet) # Apply to submenu
|
||||
|
||||
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"{node_name}", make_node_command(graph, node_type))
|
||||
|
||||
# Ensure styles are propagated across all dynamically created submenus
|
||||
apply_styles_to_submenus(graph_context_menu.qmenu)
|
||||
|
||||
# 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 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())
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# SECTION: Status Bar Enhancement - Dynamic Status Display
|
||||
# Add a QLabel to the status bar that shows:
|
||||
# - The number of nodes in the graph.
|
||||
# - A fixed update rate (500ms).
|
||||
# - A clickable hyperlink to the Flask API server.
|
||||
status_bar = main_window.statusBar()
|
||||
|
||||
status_label = QtWidgets.QLabel()
|
||||
status_label.setTextFormat(QtCore.Qt.RichText) # Enable rich text for clickable links.
|
||||
status_label.setStyleSheet("color: white;") # Set default text color to white.
|
||||
status_label.setOpenExternalLinks(True) # Allow hyperlinks to be clickable.
|
||||
status_bar.setSizeGripEnabled(False) # Disable resizing via the size grip.
|
||||
status_bar.addWidget(status_label)
|
||||
status_bar.setStyleSheet("""
|
||||
QStatusBar::item {
|
||||
border: none; /* remove the line around items */
|
||||
}
|
||||
""")
|
||||
|
||||
def update_status():
|
||||
node_count = len(graph.all_nodes())
|
||||
api_link = (
|
||||
'<a href="http://127.0.0.1:5000/data" '
|
||||
'style="color: rgb(60, 120, 180); text-decoration: none;">'
|
||||
'http://127.0.0.1:5000/data</a>'
|
||||
)
|
||||
status_label.setText(
|
||||
f'Nodes: {node_count} | Update Rate: 500ms | Flask API Server: {api_link}'
|
||||
)
|
||||
|
||||
# Create the timer, pass the main_window as parent, and store the reference.
|
||||
status_timer = QtCore.QTimer(main_window)
|
||||
status_timer.timeout.connect(update_status)
|
||||
status_timer.start(500)
|
||||
|
||||
main_window._status_timer = status_timer # Keep a reference so it's not GCed
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
# 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
|
||||
splitter.setStretchFactor(0, 2) # Split of Left Side
|
||||
splitter.setStretchFactor(1, 3) # Split of Right Side
|
||||
|
||||
# Reduce the Size of the Splitter Handle
|
||||
splitter.setHandleWidth(1)
|
||||
splitter.setStyleSheet("""
|
||||
QSplitter::handle {
|
||||
background: none;
|
||||
}
|
||||
""")
|
||||
|
||||
# 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()
|
||||
|
||||
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_())
|
Reference in New Issue
Block a user