Re-implemented Backdrop Node

This commit is contained in:
Nicole Rappe 2025-02-12 23:25:02 -07:00
parent ca5152b89a
commit 9c2e287b72
19 changed files with 784 additions and 35 deletions

View File

@ -0,0 +1,192 @@
import signal
from qtpy import QtWidgets
from OdenGraphQt import BaseNode, NodeGraph
from OdenGraphQt.constants import PortTypeEnum
from OdenGraphQt.qgraphics.node_base import NodeItem
class PublishWriteNodeItem(NodeItem):
def _align_widgets_horizontal(self, v_offset: int):
if not self._widgets:
return
rect = self.boundingRect()
y = rect.y() + v_offset
for widget in self._widgets.values():
if not widget.isVisible():
continue
widget_rect = widget.boundingRect()
x = rect.center().x() - (widget_rect.width() / 2)
widget.widget().setTitleAlign('center')
widget.setPos(x, y)
y += widget_rect.height()
class PrevNextNode(BaseNode):
__identifier__ = "action"
NODE_NAME = "Action Node"
def __init__(self):
super().__init__()
# create an input port.
input_port = self.add_input("_prev", color=(180, 80, 0), multi_input=False)
# create an output port.
output_port = self.add_output("_next", multi_output=False)
input_port.port_item.set_allow_partial_match_constraint(True)
input_port.port_item.set_accept_constraint(
port_name=output_port.name(),
port_type=PortTypeEnum.OUT.value,
node_identifier=self.__identifier__,
)
output_port.port_item.set_allow_partial_match_constraint(True)
output_port.port_item.set_accept_constraint(
port_name=input_port.name(),
port_type=PortTypeEnum.IN.value,
node_identifier=self.__identifier__,
)
class IngredientNode(BaseNode):
__identifier__ = "ingredient"
class SpamNode(IngredientNode):
__identifier__ = "spam"
NODE_NAME = "Spam"
def __init__(self):
super().__init__()
spam_port = self.add_output(
"spam",
color=(50, 150, 222),
)
class EggNode(IngredientNode):
__identifier__ = "egg"
NODE_NAME = "Egg"
def __init__(self):
super().__init__()
egg_port = self.add_output(
"egg",
color=(50, 150, 222),
)
class MealNode(BaseNode):
NODE_NAME = "Meal"
def __init__(self):
super().__init__()
spam_port = self.add_input("spam", color=(222, 15, 0), multi_input=False)
spam_port.port_item.set_reject_constraint(
port_name="egg",
port_type=PortTypeEnum.OUT.value,
node_identifier="egg",
)
egg_port = self.add_input("egg", color=(222, 15, 0), multi_input=False)
egg_port.port_item.set_reject_constraint(
port_name="spam",
port_type=PortTypeEnum.OUT.value,
node_identifier="spam",
)
class BasePublishNode(PrevNextNode):
__identifier__ = "publish"
allow_multiple_write = False
def __init__(self):
super().__init__()
port = self.add_output(
"write",
color=(184, 150, 0),
multi_output=self.allow_multiple_write,
)
port.port_item.set_accept_constraint(
port_name="src",
port_type=PortTypeEnum.IN.value,
node_identifier="publish",
)
class PubNode(PrevNextNode):
__identifier__ = "pub"
NODE_NAME = "Not Tavern"
class PublishFileActionNode(BasePublishNode):
NODE_NAME = "Publish File"
allow_multiple_write = False
class PublishFileToManyActionNode(BasePublishNode):
NODE_NAME = "Publish File to Many"
allow_multiple_write = True
class PublishWriteNode(BaseNode):
__identifier__ = "publish"
NODE_NAME = "Publish Write"
def __init__(self):
super().__init__(qgraphics_item=PublishWriteNodeItem)
self.set_color(164, 130, 0)
self.add_text_input("write", "Path:")
port = self.add_input("src", multi_input=False)
port.port_item.set_accept_constraint(
port_name="write",
port_type=PortTypeEnum.OUT.value,
node_identifier="publish",
)
if __name__ == '__main__':
# handle SIGINT to make the app terminate on CTRL+C
signal.signal(signal.SIGINT, signal.SIG_DFL)
app = QtWidgets.QApplication([])
# create graph controller.
graph = NodeGraph()
# set up context menu for the node graph.
graph.set_context_menu_from_file('../examples/hotkeys/hotkeys.json')
# registered example nodes.
graph.register_nodes([
SpamNode,
EggNode,
MealNode,
PubNode,
PublishFileActionNode,
PublishFileToManyActionNode,
PublishWriteNode,
])
# add nodes
graph.add_node(SpamNode())
graph.add_node(EggNode())
graph.add_node(MealNode())
graph.add_node(PubNode())
graph.add_node(PublishFileToManyActionNode())
graph.add_node(PublishFileActionNode())
graph.add_node(PublishWriteNode())
graph.auto_layout_nodes()
graph.clear_selection()
# show the node graph widget.
graph_widget = graph.widget
graph_widget.resize(1100, 800)
graph_widget.show()
app.exec_()

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

184
Nodes/backdrop_node.py Normal file
View File

@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""
Enhanced Backdrop Node (Inherited from BaseNode)
Features:
- Inherits from `BaseNode` so it can be discovered in your node scanning.
- Custom context menu to rename (set title) or pick a new color.
- Forces geometry updates to reduce "ghosting" or partial redraws.
"""
from Qt import QtWidgets, QtGui
from OdenGraphQt import BaseNode
from OdenGraphQt.constants import NodePropWidgetEnum
from OdenGraphQt.qgraphics.node_backdrop import BackdropNodeItem
class BackdropNode(BaseNode):
"""
Backdrop Node:
- Allows grouping or annotating other nodes by resizing a large rectangle.
- Provides a custom context menu for renaming and recoloring (via on_context_menu).
"""
__identifier__ = 'bunny-lab.io.backdrop'
NODE_NAME = 'Backdrop'
def __init__(self):
# Use BackdropNodeItem for the specialized QGraphicsItem.
super(BackdropNode, self).__init__(qgraphics_item=BackdropNodeItem)
# Default color (teal).
self.model.color = (5, 129, 138, 255)
# Multi-line text property for storing the backdrop text.
self.create_property(
'backdrop_text',
'',
widget_type=NodePropWidgetEnum.QTEXT_EDIT.value,
tab='Backdrop'
)
# --------------------------------------------------------------------------
# Resizing / Geometry
# --------------------------------------------------------------------------
def on_backdrop_updated(self, update_prop, value=None):
"""
Triggered when the user resizes or double-clicks the backdrop sizer handle.
"""
if not self.graph:
return
if update_prop == 'sizer_mouse_release':
# User finished dragging the resize handle
self.view.prepareGeometryChange()
self.graph.begin_undo(f'resized "{self.name()}"')
self.set_property('width', value['width'])
self.set_property('height', value['height'])
self.set_pos(*value['pos'])
self.graph.end_undo()
self.view.update()
elif update_prop == 'sizer_double_clicked':
# User double-clicked the resize handle (auto-resize)
self.view.prepareGeometryChange()
self.graph.begin_undo(f'"{self.name()}" auto resize')
self.set_property('width', value['width'])
self.set_property('height', value['height'])
self.set_pos(*value['pos'])
self.graph.end_undo()
self.view.update()
def auto_size(self):
"""
Auto-resize the backdrop to fit around intersecting nodes.
"""
if not self.graph:
return
self.view.prepareGeometryChange()
self.graph.begin_undo(f'"{self.name()}" auto resize')
size = self.view.calc_backdrop_size()
self.set_property('width', size['width'])
self.set_property('height', size['height'])
self.set_pos(*size['pos'])
self.graph.end_undo()
self.view.update()
def wrap_nodes(self, nodes):
"""
Fit the backdrop around the specified nodes.
"""
if not self.graph or not nodes:
return
self.view.prepareGeometryChange()
self.graph.begin_undo(f'"{self.name()}" wrap nodes')
size = self.view.calc_backdrop_size([n.view for n in nodes])
self.set_property('width', size['width'])
self.set_property('height', size['height'])
self.set_pos(*size['pos'])
self.graph.end_undo()
self.view.update()
def nodes(self):
"""
Return a list of nodes wrapped by this backdrop.
"""
node_ids = [n.id for n in self.view.get_nodes()]
return [self.graph.get_node_by_id(nid) for nid in node_ids]
def set_text(self, text=''):
"""
Set the multi-line text in the backdrop.
"""
self.set_property('backdrop_text', text)
def text(self):
"""
Return the text content in the backdrop.
"""
return self.get_property('backdrop_text')
def set_size(self, width, height):
"""
Manually set the backdrop size.
"""
if self.graph:
self.view.prepareGeometryChange()
self.graph.begin_undo('backdrop size')
self.set_property('width', width)
self.set_property('height', height)
self.graph.end_undo()
self.view.update()
else:
self.view.width, self.view.height = width, height
self.model.width, self.model.height = width, height
def size(self):
"""
Return (width, height) of the backdrop.
"""
self.model.width = self.view.width
self.model.height = self.view.height
return self.model.width, self.model.height
# No ports for a backdrop:
def inputs(self):
return
def outputs(self):
return
# --------------------------------------------------------------------------
# Custom Context Menu
# --------------------------------------------------------------------------
def on_context_menu(self, menu):
"""
Called manually by the node context menu callback in older NodeGraphQt versions.
"""
rename_action = menu.addAction("Set Title...")
rename_action.triggered.connect(self._change_title)
color_action = menu.addAction("Set Color...")
color_action.triggered.connect(self._change_color)
def _change_title(self):
"""
Prompt for a new backdrop title (header).
"""
new_title, ok = QtWidgets.QInputDialog.getText(
None, "Backdrop Title", "Enter new backdrop title:"
)
if ok and new_title:
self.set_name(new_title)
def _change_color(self):
"""
Prompt for a new backdrop color via QColorDialog.
"""
current_color = QtGui.QColor(*self.model.color)
color = QtWidgets.QColorDialog.getColor(
current_color, None, "Select Backdrop Color"
)
if color.isValid():
self.model.color = (color.red(), color.green(), color.blue(), color.alpha())
self.view.update()

86
Nodes/basic_nodes.py Normal file
View File

@ -0,0 +1,86 @@
from OdenGraphQt import BaseNode, BaseNodeCircle
class BasicNodeA(BaseNode):
"""
A node class with 2 inputs and 2 outputs.
"""
# unique node identifier.
__identifier__ = 'nodes.basic'
# initial default node name.
NODE_NAME = 'node A'
def __init__(self):
super(BasicNodeA, self).__init__()
# create node inputs.
self.add_input('in A')
self.add_input('in B')
# create node outputs.
self.add_output('out A')
self.add_output('out B')
class BasicNodeB(BaseNode):
"""
A node class with 3 inputs and 3 outputs.
The last input and last output can take in multiple pipes.
"""
# unique node identifier.
__identifier__ = 'nodes.basic'
# initial default node name.
NODE_NAME = 'node B'
def __init__(self):
super(BasicNodeB, self).__init__()
# create node inputs
self.add_input('single 1')
self.add_input('single 2')
self.add_input('multi in', multi_input=True)
# create node outputs
self.add_output('single 1', multi_output=False)
self.add_output('single 2', multi_output=False)
self.add_output('multi out')
class CircleNode(BaseNodeCircle):
"""
A node class with 3 inputs and 3 outputs.
This node is a circular design.
"""
# unique node identifier.
__identifier__ = 'nodes.basic'
# initial default node name.
NODE_NAME = 'Circle Node'
def __init__(self):
super(CircleNode, self).__init__()
self.set_color(10, 24, 38)
# create node inputs
p = self.add_input('in 1')
p.add_accept_port_type(
port_name='single 1',
port_type='out',
node_type='nodes.basic.BasicNodeB'
)
self.add_input('in 2')
self.add_input('in 3', multi_input=True)
self.add_input('in 4', display_name=False)
self.add_input('in 5', display_name=False)
# create node outputs
self.add_output('out 1')
self.add_output('out 2', multi_output=False)
self.add_output('out 3', multi_output=True, display_name=False)
self.add_output('out 4', multi_output=True, display_name=False)

121
Nodes/custom_ports_node.py Normal file
View File

@ -0,0 +1,121 @@
#!/usr/bin/python
from qtpy import QtCore, QtGui
from OdenGraphQt import BaseNode
def draw_triangle_port(painter, rect, info):
"""
Custom paint function for drawing a Triangle shaped port.
Args:
painter (QtGui.QPainter): painter object.
rect (QtCore.QRectF): port rect used to describe parameters
needed to draw.
info (dict): information describing the ports current state.
{
'port_type': 'in',
'color': (0, 0, 0),
'border_color': (255, 255, 255),
'multi_connection': False,
'connected': False,
'hovered': False,
}
"""
painter.save()
size = int(rect.height() / 2)
triangle = QtGui.QPolygonF()
triangle.append(QtCore.QPointF(-size, size))
triangle.append(QtCore.QPointF(0.0, -size))
triangle.append(QtCore.QPointF(size, size))
transform = QtGui.QTransform()
transform.translate(rect.center().x(), rect.center().y())
port_poly = transform.map(triangle)
# mouse over port color.
if info['hovered']:
color = QtGui.QColor(14, 45, 59)
border_color = QtGui.QColor(136, 255, 35)
# port connected color.
elif info['connected']:
color = QtGui.QColor(195, 60, 60)
border_color = QtGui.QColor(200, 130, 70)
# default port color
else:
color = QtGui.QColor(*info['color'])
border_color = QtGui.QColor(*info['border_color'])
pen = QtGui.QPen(border_color, 1.8)
pen.setJoinStyle(QtCore.Qt.PenJoinStyle.MiterJoin)
painter.setPen(pen)
painter.setBrush(color)
painter.drawPolygon(port_poly)
painter.restore()
def draw_square_port(painter, rect, info):
"""
Custom paint function for drawing a Square shaped port.
Args:
painter (QtGui.QPainter): painter object.
rect (QtCore.QRectF): port rect used to describe parameters
needed to draw.
info (dict): information describing the ports current state.
{
'port_type': 'in',
'color': (0, 0, 0),
'border_color': (255, 255, 255),
'multi_connection': False,
'connected': False,
'hovered': False,
}
"""
painter.save()
# mouse over port color.
if info['hovered']:
color = QtGui.QColor(14, 45, 59)
border_color = QtGui.QColor(136, 255, 35, 255)
# port connected color.
elif info['connected']:
color = QtGui.QColor(195, 60, 60)
border_color = QtGui.QColor(200, 130, 70)
# default port color
else:
color = QtGui.QColor(*info['color'])
border_color = QtGui.QColor(*info['border_color'])
pen = QtGui.QPen(border_color, 1.8)
pen.setJoinStyle(QtCore.Qt.PenJoinStyle.MiterJoin)
painter.setPen(pen)
painter.setBrush(color)
painter.drawRect(rect)
painter.restore()
class CustomPortsNode(BaseNode):
"""
example test node with custom shaped ports.
"""
# set a unique node identifier.
__identifier__ = 'nodes.custom.ports'
# set the initial default node name.
NODE_NAME = 'node'
def __init__(self):
super(CustomPortsNode, self).__init__()
# create input and output port.
self.add_input('in', color=(200, 10, 0))
self.add_output('default')
self.add_output('square', painter_func=draw_square_port)
self.add_output('triangle', painter_func=draw_triangle_port)

View File

@ -1,13 +1,4 @@
#!/usr/bin/env python3
"""
Character Status Node
This node represents the character's status. It has seven output ports:
- HP: Current, HP: Total, MP: Current, MP: Total, FP: Current, FP: Total, EXP.
It polls an API endpoint (http://127.0.0.1:5000/data) every 500 ms to update its values.
If the API call is successful, the node's title is set to "Character Status (API Connected)".
If the API is down or returns an error, the title is set to "Character Status (API Disconnected)".
"""
from OdenGraphQt import BaseNode
from Qt import QtCore, QtGui
@ -44,7 +35,7 @@ def get_draw_stat_port(color, border_color=None, alpha=127):
return painter_func
class CharacterStatusNode(BaseNode):
__identifier__ = 'bunny-lab.io.status_node'
__identifier__ = 'bunny-lab.io.flyff_character_status_node'
NODE_NAME = 'Character Status'
def __init__(self):

21
Nodes/group_node.py Normal file
View File

@ -0,0 +1,21 @@
from OdenGraphQt import GroupNode
class MyGroupNode(GroupNode):
"""
example test group node with a in port and out port.
"""
# set a unique node identifier.
__identifier__ = 'nodes.group'
# set the initial default node name.
NODE_NAME = 'group node'
def __init__(self):
super(MyGroupNode, self).__init__()
self.set_color(50, 8, 25)
# create input and output port.
self.add_input('in')
self.add_output('out')

155
Nodes/widget_node.py Normal file
View File

@ -0,0 +1,155 @@
from OdenGraphQt import BaseNode
from OdenGraphQt.constants import NodePropWidgetEnum
from OdenGraphQt.widgets.node_widgets import NodeLineEditValidatorCheckBox
class DropdownMenuNode(BaseNode):
"""
An example node with a embedded added QCombobox menu.
"""
# unique node identifier.
__identifier__ = 'nodes.widget'
# initial default node name.
NODE_NAME = 'menu'
def __init__(self):
super(DropdownMenuNode, self).__init__()
# create input & output ports
self.add_input('in 1')
self.add_output('out 1')
self.add_output('out 2')
# create the QComboBox menu.
items = ["item 1", "item 2", "item 3"]
self.add_combo_menu(
"my_menu",
"Menu Test",
items=items,
tooltip="example custom tooltip",
)
class TextInputNode(BaseNode):
"""
An example of a node with a embedded QLineEdit.
"""
# unique node identifier.
__identifier__ = 'nodes.widget'
# initial default node name.
NODE_NAME = 'text'
def __init__(self):
super().__init__()
pattern = r"^[A-Za-z0-9]*$"
placeholder = ""
tooltip = "Valid characters: A-Z a-z 0-9"
is_case_sensitive = True
checkbox_label = "Use Parser?"
# create input & output ports
self.add_input('in')
self.add_output('out')
# create QLineEdit text input widget.
self.add_text_input('my_input', 'Text Input', tab='widgets')
tool_btn_kwargs = {
"func": self._callback,
"tooltip": "Awesome"
}
kwargs = {
"validator": {
"pattern": pattern,
"placeholder": placeholder,
"tooltip": tooltip,
"is_case_insensitive": is_case_sensitive,
"checkbox_visible": True,
"tool_btn_visible": True,
},
"checkbox_label": checkbox_label,
"tool_btn": tool_btn_kwargs,
}
node_widget = NodeLineEditValidatorCheckBox(
"src_path",
pattern,
placeholder,
tooltip,
is_case_sensitive,
checkbox_label,
checkbox_visible=True,
tool_btn_visible=True,
widget_label="src_path",
parent=self.view,
)
node_widget.get_custom_widget().set_tool_btn(**tool_btn_kwargs)
self.add_custom_widget(
node_widget,
NodePropWidgetEnum.LINEEDIT_VALIDATOR_CHECKBOX.value,
"widgets",
**kwargs,
)
kwargs2 = {
"validator": {
"pattern": pattern,
"placeholder": placeholder,
"tooltip": tooltip,
"is_case_insensitive": is_case_sensitive,
"checkbox_visible": False,
"tool_btn_visible": False,
},
"checkbox_label": "Check In Luggage?",
"tool_btn": tool_btn_kwargs,
}
node_widget2 = NodeLineEditValidatorCheckBox(
"dst_path",
pattern,
placeholder,
tooltip,
is_case_sensitive,
"Check In Luggage?",
checkbox_visible=False,
tool_btn_visible=False,
widget_label="dst_path",
parent=self.view,
)
node_widget2.get_custom_widget().set_tool_btn(**tool_btn_kwargs)
node_widget2.set_checkbox_visible(False)
node_widget2.set_tool_btn_visible(False)
self.add_custom_widget(
node_widget2,
NodePropWidgetEnum.LINEEDIT_VALIDATOR_CHECKBOX.value,
"widgets",
**kwargs2,
)
def _callback(self):
print(f"YOU HAVE CLICKED ON '{self.NODE_NAME}'")
class CheckboxNode(BaseNode):
"""
An example of a node with 2 embedded QCheckBox widgets.
"""
# set a unique node identifier.
__identifier__ = 'nodes.widget'
# set the initial default node name.
NODE_NAME = 'checkbox'
def __init__(self):
super(CheckboxNode, self).__init__()
# create the checkboxes.
self.add_checkbox('cb_1', '', 'Checkbox 1', True)
self.add_checkbox('cb_2', '', 'Checkbox 2', False)
# create input and output port.
self.add_input('in', color=(200, 100, 0))
self.add_output('out', color=(0, 100, 200))

View File

@ -1,27 +1,27 @@
#!/usr/bin/env python3
# --- Patch QGraphicsScene.setSelectionArea to handle selection arguments ---
from Qt import QtWidgets, QtCore, QtGui
_original_setSelectionArea = QtWidgets.QGraphicsScene.setSelectionArea
def _patched_setSelectionArea(self, painterPath, second_arg, *args, **kwargs):
try:
# Try calling the original method with the provided arguments.
return _original_setSelectionArea(self, painterPath, second_arg, *args, **kwargs)
except TypeError as e:
# If a TypeError is raised, assume the call was made with only a QPainterPath
# and an ItemSelectionMode, and patch it by supplying defaults.
# Default operation: ReplaceSelection, default transform: QTransform()
return _original_setSelectionArea(self, painterPath,
QtCore.Qt.ReplaceSelection,
second_arg,
QtGui.QTransform())
# Monkey-patch the setSelectionArea method.
QtWidgets.QGraphicsScene.setSelectionArea = _patched_setSelectionArea
# --- End of patch section ---
## --- Patch QGraphicsScene.setSelectionArea to handle selection arguments ---
#from Qt import QtWidgets, QtCore, QtGui
#
#_original_setSelectionArea = QtWidgets.QGraphicsScene.setSelectionArea
#
#def _patched_setSelectionArea(self, painterPath, second_arg, *args, **kwargs):
# try:
# # Try calling the original method with the provided arguments.
# return _original_setSelectionArea(self, painterPath, second_arg, *args, **kwargs)
# except TypeError as e:
# # If a TypeError is raised, assume the call was made with only a QPainterPath
# # and an ItemSelectionMode, and patch it by supplying defaults.
# # Default operation: ReplaceSelection, default transform: QTransform()
# return _original_setSelectionArea(self, painterPath,
# QtCore.Qt.ReplaceSelection,
# second_arg,
# QtGui.QTransform())
#
## Monkey-patch the setSelectionArea method.
#QtWidgets.QGraphicsScene.setSelectionArea = _patched_setSelectionArea
#
## --- End of patch section ---
import sys
import pkgutil

View File

@ -188,7 +188,6 @@ def collect_ocr_data():
# Run OCR
text = pytesseract.image_to_string(processed, config='--psm 4 --oem 1')
print("OCR Output:", text) # Debugging
stats = parse_all_stats(text.strip())
hp_cur, hp_max = stats["hp"]
@ -207,7 +206,8 @@ def collect_ocr_data():
"exp": exp_val
}
print(f"OCR Updated: HP: {hp_cur}/{hp_max}, MP: {mp_cur}/{mp_max}, FP: {fp_cur}/{fp_max}, EXP: {exp_val}") # Debug
# DEBUG OUTPUT
print(f"Flyff - Character Status: HP: {hp_cur}/{hp_max}, MP: {mp_cur}/{mp_max}, FP: {fp_cur}/{fp_max}, EXP: {exp_val}%")
time.sleep(0.5)
@ -286,7 +286,6 @@ class OverlayCanvas(QWidget):
self.region["y"] = event.y() - self.drag_offset.y()
region_lock.unlock()
print(f"Region Moved: x={self.region['x']}, y={self.region['y']}, w={self.region['w']}, h={self.region['h']}") # Debugging
self.update()
def mouseReleaseEvent(self, event):

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 13 KiB