Borealis-Legacy/borealis.py
Nicole Rappe db848211fd Added Status Bar with Flask API Server Link Exposing Variables captured in Borealis for use by other applications / systems
Added a menubar for workflow management (Save/Load/Close), as well as an About section for credits, updating, and finding the Gitea project on the Bunny Lab Gitea repository.
Added automatic workflow centering when you load a workflow, so it spawns the loaded JSON nodes in the center instead of off-screen somewhere at the exact original coordinates.
Changed the context menu and hyperlink colors to a more muted blue color
2025-02-25 17:39:01 -07:00

352 lines
12 KiB
Python

# -*- 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, then centers the view on all loaded nodes.
"""
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)
# After loading, center the viewer on all nodes.
all_nodes = graph.all_nodes()
if all_nodes:
graph.viewer().zoom_to_nodes([node.view for node in all_nodes])
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))
# Remove the resize handle from the status bar:
self.statusBar().setSizeGripEnabled(False)
# Add a permanent clickable link to the right side of the status bar:
self.link_label = QtWidgets.QLabel()
self.link_label.setTextFormat(QtCore.Qt.RichText)
self.link_label.setOpenExternalLinks(True)
# Add a couple spaces after the URL using  
# Also color style to match your "blue-ish" highlight:
self.link_label.setText(
"<a href='http://127.0.0.1:5000/data' "
"style='color: rgb(60,120,180); text-decoration: none;'>"
"Flask API Server: http://127.0.0.1:5000/data&nbsp;&nbsp;</a>"
)
self.statusBar().addPermanentWidget(self.link_label)
# Resize
self.resize(1200, 800)
def _build_graph_context_menu(self, custom_nodes_by_category):
"""
Build context menu and apply custom stylesheet for the 'blue-ish' highlight.
"""
graph_context_menu = self.graph.get_context_menu("graph")
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;
}
"""
if graph_context_menu and graph_context_menu.qmenu:
graph_context_menu.qmenu.setStyleSheet(menu_stylesheet)
add_nodes_menu = graph_context_menu.add_menu("Add Nodes")
if add_nodes_menu and add_nodes_menu.qmenu:
add_nodes_menu.qmenu.setStyleSheet(menu_stylesheet)
for category, node_classes in custom_nodes_by_category.items():
category_menu = add_nodes_menu.add_menu(category)
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)
)
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
)
def _build_menubar(self):
menubar = self.menuBar()
# Workflows menu
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)
# About menu
about_menu = menubar.addMenu("About")
gitea_action = QtWidgets.QAction("Gitea Project", self)
gitea_action.triggered.connect(self._open_gitea_project)
about_menu.addAction(gitea_action)
credits_action = QtWidgets.QAction("Credits", self)
credits_action.triggered.connect(self._show_credits_popup)
about_menu.addAction(credits_action)
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 main():
app = QtWidgets.QApplication(sys.argv)
window = BorealisWindow()
window.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()