diff --git a/docs/specifications/flow_spec.md b/docs/specifications/flow_spec.md index 9dae4c2..e5abb86 100644 --- a/docs/specifications/flow_spec.md +++ b/docs/specifications/flow_spec.md @@ -291,7 +291,70 @@ def set_initial_state(widgets, state): - The function's return values are passed to `set_values()` for display - Widget state is automatically saved to `gui_state` in the node's metadata -### 3.5 Connections Section +### 3.5 Groups Section (Optional) + +Files MAY contain a Groups section for organizing nodes visually: + +```markdown +## Groups +```json +[ + { + "uuid": "group-1", + "name": "Data Processing", + "description": "Processes input data through multiple stages", + "member_node_uuids": ["node1", "node2", "node3"], + "position": {"x": 150, "y": 200}, + "size": {"width": 400, "height": 300}, + "padding": 20, + "is_expanded": true, + "colors": { + "background": {"r": 45, "g": 45, "b": 55, "a": 120}, + "border": {"r": 100, "g": 150, "b": 200, "a": 180}, + "title_bg": {"r": 60, "g": 60, "b": 70, "a": 200}, + "title_text": {"r": 220, "g": 220, "b": 220, "a": 255}, + "selection": {"r": 255, "g": 165, "b": 0, "a": 100} + } + } +] +``` + +**Group Properties:** + +**Required Fields:** +- `uuid`: Unique identifier for the group (string) +- `name`: Human-readable group name (string) +- `member_node_uuids`: Array of UUIDs for nodes contained in this group + +**Optional Fields:** +- `description`: Group description (string, default: "") +- `position`: Group position as {x, y} coordinates (object, default: {x: 0, y: 0}) +- `size`: Group dimensions as {width, height} (object, default: {width: 200, height: 150}) +- `padding`: Internal padding around member nodes (number, default: 20) +- `is_expanded`: Whether group is visually expanded (boolean, default: true) +- `colors`: Visual appearance colors with RGBA values (object) + - `background`: Semi-transparent group background color + - `border`: Group border outline color + - `title_bg`: Title bar background color + - `title_text`: Title text color + - `selection`: Selection highlight color when group is selected + +**Color Format:** +Each color in the `colors` object uses RGBA format: +```json +{"r": 255, "g": 165, "b": 0, "a": 100} +``` +Where r, g, b are 0-255 and a (alpha/transparency) is 0-255 (0 = fully transparent, 255 = fully opaque). + +**Group Behavior:** +- Groups are organizational containers that visually group related nodes +- Member nodes move when the group is moved +- Groups can be resized, automatically updating membership based on contained nodes +- Groups support transparency for better visual layering +- Groups maintain their own undo/redo history for property changes +- Groups can be collapsed/expanded to manage visual complexity + +### 3.6 Connections Section The file MUST contain exactly one Connections section: @@ -337,7 +400,7 @@ The file MUST contain exactly one Connections section: ] ``` -### 3.6 GUI Integration & Data Flow +### 3.7 GUI Integration & Data Flow When a node has both GUI components and pin connections, the data flows as follows: @@ -392,7 +455,7 @@ This state is: - Restored when the graph is loaded via `set_initial_state()` - Updated whenever widget values change -### 3.7 Reroute Nodes +### 3.8 Reroute Nodes Reroute nodes are special organizational nodes that help manage connection routing and graph layout without affecting data flow. @@ -442,7 +505,7 @@ Reroute nodes are special organizational nodes that help manage connection routi ] ``` -### 3.8 Execution Modes +### 3.9 Execution Modes PyFlowGraph supports two distinct execution modes that determine how the graph processes data: @@ -476,7 +539,7 @@ PyFlowGraph supports two distinct execution modes that determine how the graph p - GUI buttons in nodes are inactive in batch mode - Live mode enables event handlers in node GUIs -### 3.9 Virtual Environments +### 3.10 Virtual Environments PyFlowGraph uses isolated Python virtual environments to manage dependencies for each graph: @@ -506,7 +569,7 @@ PyFlowGraph/ - Flexibility: Different graphs can use different package versions - Portability: Environments can be recreated from requirements -### 3.10 Subprocess Data Transfer +### 3.11 Subprocess Data Transfer PyFlowGraph executes each node in an isolated subprocess for security and stability. Understanding how data flows between these processes is crucial for working with the system. @@ -618,7 +681,7 @@ The execution system maintains a `pin_values` dictionary that: - Data is duplicated, not shared - Large datasets consume memory in both processes -### 3.11 Error Handling +### 3.12 Error Handling The system provides comprehensive error handling during graph execution: @@ -842,6 +905,30 @@ def set_initial_state(widgets, state): widgets['operation'].setCurrentText(state.get('operation', 'add')) ``` +## Groups + +```json +[ + { + "uuid": "calc-group", + "name": "Calculator Components", + "description": "All calculator-related functionality", + "member_node_uuids": ["calc-node"], + "position": {"x": 150, "y": 150}, + "size": {"width": 350, "height": 300}, + "padding": 25, + "is_expanded": true, + "colors": { + "background": {"r": 45, "g": 45, "b": 55, "a": 120}, + "border": {"r": 100, "g": 150, "b": 200, "a": 180}, + "title_bg": {"r": 60, "g": 60, "b": 70, "a": 200}, + "title_text": {"r": 220, "g": 220, "b": 220, "a": 255}, + "selection": {"r": 255, "g": 165, "b": 0, "a": 100} + } + } +] +``` + ## Connections ```json @@ -858,7 +945,7 @@ A parser should use markdown-it-py to tokenize the document: 2. **State Machine**: Track current node and component being parsed 3. **Section Detection**: - `h1`: Graph title - - `h2`: Node header (regex: `Node: (.*) \(ID: (.*)\)`) or "Connections" + - `h2`: Node header (regex: `Node: (.*) \(ID: (.*)\)`), "Groups", or "Connections" - `h3`: Component type (Metadata, Logic, etc.) 4. **Data Extraction**: Extract `content` from `fence` tokens based on `info` language tag 5. **@node_entry Function Identification**: @@ -886,7 +973,10 @@ A parser should use markdown-it-py to tokenize the document: - The `@node_entry` function must have valid Python syntax - Type hints on the `@node_entry` function should be valid for pin generation - Connections section is required -- JSON must be valid in metadata and connections +- Groups section is optional; if present, must contain valid JSON +- JSON must be valid in metadata, groups, and connections +- Group UUIDs must be unique across all groups +- Group member_node_uuids must reference existing nodes **GUI-Specific Rules (when GUI components are present):** - GUI Definition must be valid Python code that creates PySide6 widgets @@ -941,6 +1031,7 @@ Both formats represent identical graph information: | ### Logic | "code" field | Execution code | | ### GUI Definition | "gui_code" field | Widget creation | | ### GUI State Handler | "gui_get_values_code" | State management | +| ## Groups | "groups" array | Group definitions | | ## Connections | "connections" array | Graph edges | ### 7.3 Use Cases diff --git a/examples/password_generator_tool_group.md b/examples/password_generator_tool_group.md new file mode 100644 index 0000000..2f6410b --- /dev/null +++ b/examples/password_generator_tool_group.md @@ -0,0 +1,517 @@ +# Password Generator Tool + +Password generation workflow with configurable parameters, random character selection, strength scoring algorithm, and GUI output display. Implements user-defined character set selection, random.choice() generation, regex-based strength analysis, and formatted result presentation. + +## Node: Password Configuration (ID: config-input) + +Collects password generation parameters through QSpinBox (length 4-128) and QCheckBox widgets for character set selection. Returns Tuple[int, bool, bool, bool, bool] containing length and boolean flags for uppercase, lowercase, numbers, and symbols inclusion. + +GUI state management handles default values: length=12, uppercase=True, lowercase=True, numbers=True, symbols=False. Uses standard get_values() and set_initial_state() functions for parameter persistence and retrieval. + +### Metadata + +```json +{ + "uuid": "config-input", + "title": "Password Configuration", + "pos": [ + -114.11883349999994, + 118.0365416250001 + ], + "size": [ + 296.7499999999999, + 412 + ], + "colors": { + "title": "#007bff", + "body": "#0056b3" + }, + "gui_state": { + "length": 12, + "include_uppercase": true, + "include_lowercase": true, + "include_numbers": true, + "include_symbols": false + } +} +``` + +### Logic + +```python +from typing import Tuple + +@node_entry +def configure_password(length: int, include_uppercase: bool, include_lowercase: bool, include_numbers: bool, include_symbols: bool) -> Tuple[int, bool, bool, bool, bool]: + print(f"Password config: {length} chars, Upper: {include_uppercase}, Lower: {include_lowercase}, Numbers: {include_numbers}, Symbols: {include_symbols}") + return length, include_uppercase, include_lowercase, include_numbers, include_symbols +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QSpinBox, QCheckBox, QPushButton + +layout.addWidget(QLabel('Password Length:', parent)) +widgets['length'] = QSpinBox(parent) +widgets['length'].setRange(4, 128) +widgets['length'].setValue(12) +layout.addWidget(widgets['length']) + +widgets['uppercase'] = QCheckBox('Include Uppercase (A-Z)', parent) +widgets['uppercase'].setChecked(True) +layout.addWidget(widgets['uppercase']) + +widgets['lowercase'] = QCheckBox('Include Lowercase (a-z)', parent) +widgets['lowercase'].setChecked(True) +layout.addWidget(widgets['lowercase']) + +widgets['numbers'] = QCheckBox('Include Numbers (0-9)', parent) +widgets['numbers'].setChecked(True) +layout.addWidget(widgets['numbers']) + +widgets['symbols'] = QCheckBox('Include Symbols (!@#$%)', parent) +widgets['symbols'].setChecked(False) +layout.addWidget(widgets['symbols']) + +widgets['generate_btn'] = QPushButton('Generate Password', parent) +layout.addWidget(widgets['generate_btn']) +``` + +### GUI State Handler + +```python +def get_values(widgets): + return { + 'length': widgets['length'].value(), + 'include_uppercase': widgets['uppercase'].isChecked(), + 'include_lowercase': widgets['lowercase'].isChecked(), + 'include_numbers': widgets['numbers'].isChecked(), + 'include_symbols': widgets['symbols'].isChecked() + } + +def set_values(widgets, outputs): + # Config node doesn't need to display outputs + pass + +def set_initial_state(widgets, state): + widgets['length'].setValue(state.get('length', 12)) + widgets['uppercase'].setChecked(state.get('include_uppercase', True)) + widgets['lowercase'].setChecked(state.get('include_lowercase', True)) + widgets['numbers'].setChecked(state.get('include_numbers', True)) + widgets['symbols'].setChecked(state.get('include_symbols', False)) +``` + + +## Node: Password Generator Engine (ID: password-generator) + +Constructs character set by concatenating string.ascii_uppercase, string.ascii_lowercase, string.digits, and custom symbol string based on boolean input flags. Uses random.choice() with list comprehension to generate password of specified length. + +Includes error handling for empty character sets, returning "Error: No character types selected!" when no character categories are enabled. Character set construction is conditional based on input parameters, symbols include '!@#$%^&*()_+-=[]{}|;:,.<>?' set. + +### Metadata + +```json +{ + "uuid": "password-generator", + "title": "Password Generator Engine", + "pos": [ + 259.43116650000024, + 147.1315416250001 + ], + "size": [ + 264.40499999999975, + 242 + ], + "colors": { + "title": "#28a745", + "body": "#1e7e34" + }, + "gui_state": {} +} +``` + +### Logic + +```python +import random +import string + +@node_entry +def generate_password(length: int, include_uppercase: bool, include_lowercase: bool, include_numbers: bool, include_symbols: bool) -> str: + charset = '' + + if include_uppercase: + charset += string.ascii_uppercase + if include_lowercase: + charset += string.ascii_lowercase + if include_numbers: + charset += string.digits + if include_symbols: + charset += '!@#$%^&*()_+-=[]{}|;:,.<>?' + + if not charset: + return "Error: No character types selected!" + + password = ''.join(random.choice(charset) for _ in range(length)) + print(f"Generated password: {password}") + return password +``` + + +## Node: Password Strength Analyzer (ID: strength-analyzer) + +Analyzes password strength using regex pattern matching and point-based scoring system. Length scoring: 25 points for >=12 chars, 15 points for >=8 chars. Character variety scoring: 20 points each for uppercase (A-Z), lowercase (a-z), numbers (0-9), 15 points for symbols. + +Uses re.search() with specific patterns to detect character categories. Score thresholds: >=80 Very Strong, >=60 Strong, >=40 Moderate, >=20 Weak, <20 Very Weak. Returns Tuple[str, int, str] containing strength label, numerical score, and feedback text. + +Feedback generation uses list accumulation for missing elements, joined with semicolons. Provides specific recommendations for improving password complexity based on detected deficiencies. + +### Metadata + +```json +{ + "uuid": "strength-analyzer", + "title": "Password Strength Analyzer", + "pos": [ + 844.8725, + 304.73249999999996 + ], + "size": [ + 250, + 192 + ], + "colors": { + "title": "#fd7e14", + "body": "#e8590c" + }, + "gui_state": {} +} +``` + +### Logic + +```python +import re +from typing import Tuple + +@node_entry +def analyze_strength(password: str) -> Tuple[str, int, str]: + score = 0 + feedback = [] + + # Length check + if len(password) >= 12: + score += 25 + elif len(password) >= 8: + score += 15 + feedback.append("Consider using 12+ characters") + else: + feedback.append("Password too short (8+ recommended)") + + # Character variety + if re.search(r'[A-Z]', password): + score += 20 + else: + feedback.append("Add uppercase letters") + + if re.search(r'[a-z]', password): + score += 20 + else: + feedback.append("Add lowercase letters") + + if re.search(r'[0-9]', password): + score += 20 + else: + feedback.append("Add numbers") + + if re.search(r'[!@#$%^&*()_+=\[\]{}|;:,.<>?-]', password): + score += 15 + else: + feedback.append("Add symbols for extra security") + + # Determine strength level + if score >= 80: + strength = "Very Strong" + elif score >= 60: + strength = "Strong" + elif score >= 40: + strength = "Moderate" + elif score >= 20: + strength = "Weak" + else: + strength = "Very Weak" + + feedback_text = "; ".join(feedback) if feedback else "Excellent password!" + + print(f"Password strength: {strength} (Score: {score}/100)") + print(f"Feedback: {feedback_text}") + + return strength, score, feedback_text +``` + + +## Node: Password Output & Copy (ID: output-display) + +Formats password generation results into display string combining password, strength rating, score, and feedback. Uses string concatenation to create structured output: "Generated Password: {password}\nStrength: {strength} ({score}/100)\nFeedback: {feedback}". + +GUI implementation includes QLineEdit for password display (read-only), QTextEdit for strength analysis, and QPushButton components for copy and regeneration actions. String parsing in set_values() extracts password from formatted result using string.split() and string replacement operations. + +Handles multiple input parameters (password, strength, score, feedback) and consolidates them into single formatted output string for display and further processing. + +### Metadata + +```json +{ + "uuid": "output-display", + "title": "Password Output & Copy", + "pos": [ + 1182.5525, + 137.84249999999997 + ], + "size": [ + 340.9674999999995, + 513 + ], + "colors": { + "title": "#6c757d", + "body": "#545b62" + }, + "gui_state": {} +} +``` + +### Logic + +```python +@node_entry +def display_result(password: str, strength: str, score: int, feedback: str) -> str: + result = f"Generated Password: {password}\n" + result += f"Strength: {strength} ({score}/100)\n" + result += f"Feedback: {feedback}" + print("\n=== PASSWORD GENERATION COMPLETE ===") + print(result) + return result +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QTextEdit, QPushButton, QLineEdit +from PySide6.QtCore import Qt +from PySide6.QtGui import QFont + +title_label = QLabel('Generated Password', parent) +title_font = QFont() +title_font.setPointSize(14) +title_font.setBold(True) +title_label.setFont(title_font) +layout.addWidget(title_label) + +widgets['password_field'] = QLineEdit(parent) +widgets['password_field'].setReadOnly(True) +widgets['password_field'].setPlaceholderText('Password will appear here...') +layout.addWidget(widgets['password_field']) + +widgets['copy_btn'] = QPushButton('Copy to Clipboard', parent) +layout.addWidget(widgets['copy_btn']) + +widgets['strength_display'] = QTextEdit(parent) +widgets['strength_display'].setMinimumHeight(120) +widgets['strength_display'].setReadOnly(True) +widgets['strength_display'].setPlainText('Generate a password to see strength analysis...') +layout.addWidget(widgets['strength_display']) +``` + +### GUI State Handler + +```python +def get_values(widgets): + return {} + +def set_values(widgets, outputs): + # Extract password from the result string + result = outputs.get('output_1', '') + lines = result.split('\n') + if lines: + password_line = lines[0] + if 'Generated Password: ' in password_line: + password = password_line.replace('Generated Password: ', '') + widgets['password_field'].setText(password) + + widgets['strength_display'].setPlainText(result) + +def set_initial_state(widgets, state): + # Output display node doesn't have saved state to restore + pass +``` + + +## Groups + +```json +[ + { + "uuid": "a52f24cd-6e3a-4d96-998f-efd17e01fce9", + "name": "Group (Password Configuration, Password Generator Engine)", + "description": "This is a description of this node. \n\nIt should be able to handle multi lines, paragraphss. etc.", + "member_node_uuids": [ + "config-input", + "password-generator" + ], + "is_expanded": true, + "position": { + "x": -223.71077007142847, + "y": 16.375099107143 + }, + "size": { + "width": 880.0, + "height": 616.0 + }, + "padding": 20, + "colors": { + "background": { + "r": 255, + "g": 85, + "b": 0, + "a": 73 + }, + "border": { + "r": 100, + "g": 150, + "b": 200, + "a": 180 + }, + "title_bg": { + "r": 60, + "g": 60, + "b": 70, + "a": 200 + }, + "title_text": { + "r": 220, + "g": 220, + "b": 220, + "a": 255 + }, + "selection": { + "r": 255, + "g": 165, + "b": 0, + "a": 100 + } + } + } +] +``` + +## Connections + +```json +[ + { + "start_node_uuid": "config-input", + "start_pin_uuid": "2bdbb436-faa3-4345-809e-55ac394cebff", + "start_pin_name": "exec_out", + "end_node_uuid": "password-generator", + "end_pin_uuid": "8dc5c082-5132-47ca-b851-dcb68d791600", + "end_pin_name": "exec_in" + }, + { + "start_node_uuid": "config-input", + "start_pin_uuid": "068f30af-1550-429e-a3e9-fbd276aa4ac3", + "start_pin_name": "output_1", + "end_node_uuid": "password-generator", + "end_pin_uuid": "da985b28-1c20-43c0-af67-b3786a3b46d5", + "end_pin_name": "length" + }, + { + "start_node_uuid": "config-input", + "start_pin_uuid": "953d5acc-5421-4ef4-a935-d14d2cb5b81f", + "start_pin_name": "output_2", + "end_node_uuid": "password-generator", + "end_pin_uuid": "6a8833b4-4636-4716-9b00-7ce21176a1c6", + "end_pin_name": "include_uppercase" + }, + { + "start_node_uuid": "config-input", + "start_pin_uuid": "5d09e8a2-97e8-4e80-9444-5a8860bf1c95", + "start_pin_name": "output_3", + "end_node_uuid": "password-generator", + "end_pin_uuid": "eb8fe9b6-ca05-4cf6-9284-de16329def38", + "end_pin_name": "include_lowercase" + }, + { + "start_node_uuid": "config-input", + "start_pin_uuid": "18896049-0d85-46dd-bfc0-503e1ef83299", + "start_pin_name": "output_4", + "end_node_uuid": "password-generator", + "end_pin_uuid": "dbfc734f-a9a1-41e9-9da3-b324f2624079", + "end_pin_name": "include_numbers" + }, + { + "start_node_uuid": "config-input", + "start_pin_uuid": "ad1fa1bb-6392-4842-b7e0-05749ed76d49", + "start_pin_name": "output_5", + "end_node_uuid": "password-generator", + "end_pin_uuid": "a780ba73-ba81-47d9-8a2c-218ff79da04d", + "end_pin_name": "include_symbols" + }, + { + "start_node_uuid": "password-generator", + "start_pin_uuid": "47289249-0f60-4a7d-9ef3-5b8e8af9eefe", + "start_pin_name": "exec_out", + "end_node_uuid": "strength-analyzer", + "end_pin_uuid": "27382879-d157-45f8-818c-a6f2dc248053", + "end_pin_name": "exec_in" + }, + { + "start_node_uuid": "password-generator", + "start_pin_uuid": "dd57640e-4343-4edc-a02b-bd26b36b11bd", + "start_pin_name": "output_1", + "end_node_uuid": "strength-analyzer", + "end_pin_uuid": "b46b0699-a4cc-4105-b5fd-669f7dfada7b", + "end_pin_name": "password" + }, + { + "start_node_uuid": "strength-analyzer", + "start_pin_uuid": "a13241b5-b88b-4fa0-aa84-bab1263de0d0", + "start_pin_name": "exec_out", + "end_node_uuid": "output-display", + "end_pin_uuid": "f418584f-1c44-4581-b2ab-8ff7cb4c2eb1", + "end_pin_name": "exec_in" + }, + { + "start_node_uuid": "password-generator", + "start_pin_uuid": "dd57640e-4343-4edc-a02b-bd26b36b11bd", + "start_pin_name": "output_1", + "end_node_uuid": "output-display", + "end_pin_uuid": "4f69f7c3-90a5-407f-92b5-db6294f491ec", + "end_pin_name": "password" + }, + { + "start_node_uuid": "strength-analyzer", + "start_pin_uuid": "d010fde2-6fc4-4986-be10-f7180769adc0", + "start_pin_name": "output_1", + "end_node_uuid": "output-display", + "end_pin_uuid": "4d9aa2a1-b0fa-4c18-8368-6fcb0dbaa82a", + "end_pin_name": "strength" + }, + { + "start_node_uuid": "strength-analyzer", + "start_pin_uuid": "cf968aa5-a239-4ad7-9f2c-126304e980dd", + "start_pin_name": "output_2", + "end_node_uuid": "output-display", + "end_pin_uuid": "b3f945ee-c1d7-42b3-8562-1d3aadeaaac3", + "end_pin_name": "score" + }, + { + "start_node_uuid": "strength-analyzer", + "start_pin_uuid": "16240242-f015-41b5-9bfd-9111facbefdb", + "start_pin_name": "output_3", + "end_node_uuid": "output-display", + "end_pin_uuid": "6d0907a2-cf91-4071-9f4d-0a9627aa3728", + "end_pin_name": "feedback" + } +] +``` diff --git a/src/commands/__init__.py b/src/commands/__init__.py index 623793a..3813f00 100644 --- a/src/commands/__init__.py +++ b/src/commands/__init__.py @@ -17,6 +17,7 @@ ) from .create_group_command import CreateGroupCommand from .resize_group_command import ResizeGroupCommand +from .delete_group_command import DeleteGroupCommand __all__ = [ 'CommandBase', 'CompositeCommand', 'CommandHistory', @@ -24,5 +25,5 @@ 'PropertyChangeCommand', 'CodeChangeCommand', 'PasteNodesCommand', 'MoveMultipleCommand', 'DeleteMultipleCommand', 'CreateConnectionCommand', 'DeleteConnectionCommand', 'CreateRerouteNodeCommand', - 'CreateGroupCommand', 'ResizeGroupCommand' + 'CreateGroupCommand', 'ResizeGroupCommand', 'DeleteGroupCommand' ] \ No newline at end of file diff --git a/src/commands/create_group_command.py b/src/commands/create_group_command.py index ecf00d0..a609791 100644 --- a/src/commands/create_group_command.py +++ b/src/commands/create_group_command.py @@ -76,10 +76,13 @@ def execute(self) -> bool: # Add to graph first (needed for connection analysis) self.node_graph.addItem(self.created_group) - # Store reference in node graph (groups list will be added to NodeGraph) + # Store reference in node graph groups list if not hasattr(self.node_graph, 'groups'): self.node_graph.groups = [] - self.node_graph.groups.append(self.created_group) + + # Check if group is already in the list to prevent duplicates + if self.created_group not in self.node_graph.groups: + self.node_graph.groups.append(self.created_group) # Groups no longer generate interface pins - they keep original connections @@ -124,6 +127,8 @@ def redo(self) -> bool: # Add back to groups list if not hasattr(self.node_graph, 'groups'): self.node_graph.groups = [] + + # Check if group is already in the list to prevent duplicates if self.created_group not in self.node_graph.groups: self.node_graph.groups.append(self.created_group) diff --git a/src/commands/delete_group_command.py b/src/commands/delete_group_command.py new file mode 100644 index 0000000..f902577 --- /dev/null +++ b/src/commands/delete_group_command.py @@ -0,0 +1,171 @@ +""" +Command for deleting groups with full state preservation and undo support. + +Handles the deletion and restoration of group objects including their visual state, +member relationships, and scene integration. +""" + +import sys +import os +from typing import Dict, Any, List + +# Add project root to path for cross-package imports +project_root = os.path.dirname(os.path.dirname(__file__)) +if project_root not in sys.path: + sys.path.insert(0, project_root) + +from .command_base import CommandBase + + +class DeleteGroupCommand(CommandBase): + """Command for deleting groups with complete state preservation.""" + + def __init__(self, node_graph, group): + """ + Initialize delete group command. + + Args: + node_graph: The NodeGraph instance + group: Group to delete + """ + super().__init__(f"Delete group '{group.name}'") + self.node_graph = node_graph + self.group = group + self.group_state = None + self.group_index = None + + def execute(self) -> bool: + """Delete group after preserving complete state.""" + try: + # Find group in the graph's groups list + found_in_list = False + for i, group in enumerate(getattr(self.node_graph, 'groups', [])): + if group is self.group: # Same object reference + found_in_list = True + self.group_index = i + break + elif hasattr(group, 'uuid') and hasattr(self.group, 'uuid') and group.uuid == self.group.uuid: + # Use the group that's actually in the list (UUID synchronization fix) + self.group = group + found_in_list = True + self.group_index = i + break + + if not found_in_list: + print(f"Warning: Group '{getattr(self.group, 'name', 'Unknown')}' not found in graph groups list") + # Still try to remove from scene if it exists there + + # Preserve complete group state for restoration + self.group_state = { + 'uuid': self.group.uuid, + 'name': self.group.name, + 'description': getattr(self.group, 'description', ''), + 'member_node_uuids': self.group.member_node_uuids.copy(), + 'position': self.group.pos(), + 'width': self.group.width, + 'height': self.group.height, + 'padding': getattr(self.group, 'padding', 20.0), + 'creation_timestamp': getattr(self.group, 'creation_timestamp', ''), + 'is_expanded': getattr(self.group, 'is_expanded', True), + # Preserve colors + 'color_background': getattr(self.group, 'color_background', None), + 'color_border': getattr(self.group, 'color_border', None), + 'color_title_bg': getattr(self.group, 'color_title_bg', None), + 'color_title_text': getattr(self.group, 'color_title_text', None), + 'color_selection': getattr(self.group, 'color_selection', None) + } + + # Remove from groups list if it exists + if hasattr(self.node_graph, 'groups') and self.group in self.node_graph.groups: + self.node_graph.groups.remove(self.group) + + # Remove from scene if it's still there + if self.group.scene() == self.node_graph: + self.node_graph.removeItem(self.group) + + self._mark_executed() + return True + + except Exception as e: + print(f"Error: Failed to delete group: {e}") + return False + + def undo(self) -> bool: + """Restore group with complete state.""" + if not self.group_state: + print(f"Error: No group state to restore") + return False + + try: + # Restore all properties directly to the existing group object + self.group.uuid = self.group_state['uuid'] + self.group.name = self.group_state['name'] + self.group.description = self.group_state['description'] + self.group.member_node_uuids = self.group_state['member_node_uuids'].copy() + self.group.setPos(self.group_state['position']) + self.group.width = self.group_state['width'] + self.group.height = self.group_state['height'] + self.group.padding = self.group_state['padding'] + self.group.creation_timestamp = self.group_state['creation_timestamp'] + self.group.is_expanded = self.group_state['is_expanded'] + + # Set the rect geometry + self.group.setRect(0, 0, self.group.width, self.group.height) + + # Restore colors + if self.group_state['color_background']: + self.group.color_background = self.group_state['color_background'] + from PySide6.QtGui import QBrush + self.group.brush_background = QBrush(self.group.color_background) + + if self.group_state['color_border']: + self.group.color_border = self.group_state['color_border'] + from PySide6.QtGui import QPen + self.group.pen_border = QPen(self.group.color_border, 2.0) + + if self.group_state['color_title_bg']: + self.group.color_title_bg = self.group_state['color_title_bg'] + from PySide6.QtGui import QBrush + self.group.brush_title = QBrush(self.group.color_title_bg) + + if self.group_state['color_title_text']: + self.group.color_title_text = self.group_state['color_title_text'] + + if self.group_state['color_selection']: + self.group.color_selection = self.group_state['color_selection'] + from PySide6.QtGui import QPen + self.group.pen_selected = QPen(self.group.color_selection, 3.0) + + # Add back to scene + self.node_graph.addItem(self.group) + + # Add back to groups list at original position + if not hasattr(self.node_graph, 'groups'): + self.node_graph.groups = [] + + if self.group_index is not None and self.group_index <= len(self.node_graph.groups): + self.node_graph.groups.insert(self.group_index, self.group) + else: + self.node_graph.groups.append(self.group) + + # Update group visual representation + self.group.update() + + self._mark_undone() + return True + + except Exception as e: + print(f"Error: Failed to undo group deletion: {e}") + return False + + def get_memory_usage(self) -> int: + """Estimate memory usage of this command.""" + if not self.group_state: + return 512 + + base_size = 1024 + name_size = len(self.group_state.get('name', '')) * 2 + description_size = len(self.group_state.get('description', '')) * 2 + members_size = len(self.group_state.get('member_node_uuids', [])) * 40 # UUID strings + + return base_size + name_size + description_size + members_size \ No newline at end of file diff --git a/src/commands/group_property_command.py b/src/commands/group_property_command.py new file mode 100644 index 0000000..ed06633 --- /dev/null +++ b/src/commands/group_property_command.py @@ -0,0 +1,222 @@ +""" +Group Property Change Command + +Provides undoable commands for changing group properties including name, +description, colors, and size. Supports batch property changes in a single command. +""" + +import sys +import os +from typing import Dict, Any, Optional +from PySide6.QtGui import QColor + +# Add project root to path for cross-package imports +project_root = os.path.dirname(os.path.dirname(__file__)) +if project_root not in sys.path: + sys.path.insert(0, project_root) + +from .command_base import CommandBase + + +class GroupPropertyChangeCommand(CommandBase): + """Command for changing group properties with full undo/redo support.""" + + def __init__(self, node_graph, group, property_changes: Dict[str, Any]): + """ + Initialize group property change command. + + Args: + node_graph: The NodeGraph instance + group: Group whose properties are changing + property_changes: Dictionary of property names to new values + """ + # Create description based on what's being changed + if len(property_changes) == 1: + prop_name = list(property_changes.keys())[0] + super().__init__(f"Change {prop_name} of group '{group.name}'") + else: + super().__init__(f"Change properties of group '{group.name}'") + + self.node_graph = node_graph + self.group = group + self.property_changes = property_changes.copy() + self.old_values = {} + + # Store original values for undo + for prop_name in property_changes.keys(): + if hasattr(group, prop_name): + old_value = getattr(group, prop_name) + # Handle QColor objects specially + if isinstance(old_value, QColor): + self.old_values[prop_name] = QColor(old_value) # Create copy + else: + self.old_values[prop_name] = old_value + + def execute(self) -> bool: + """Apply the property changes.""" + try: + for prop_name, new_value in self.property_changes.items(): + if hasattr(self.group, prop_name): + # Handle QColor objects + if isinstance(new_value, QColor): + setattr(self.group, prop_name, QColor(new_value)) + else: + setattr(self.group, prop_name, new_value) + + # Special handling for certain properties + self._handle_property_side_effects(prop_name, new_value) + + # Update visual representation + self._update_group_visuals() + + self._mark_executed() + return True + except Exception as e: + print(f"Failed to change group properties: {e}") + return False + + def undo(self) -> bool: + """Revert the property changes.""" + try: + for prop_name, old_value in self.old_values.items(): + if hasattr(self.group, prop_name): + # Handle QColor objects + if isinstance(old_value, QColor): + setattr(self.group, prop_name, QColor(old_value)) + else: + setattr(self.group, prop_name, old_value) + + # Special handling for certain properties + self._handle_property_side_effects(prop_name, old_value) + + # Update visual representation + self._update_group_visuals() + + self._mark_undone() + return True + except Exception as e: + print(f"Failed to undo group property changes: {e}") + return False + + + def redo(self) -> bool: + """Re-apply the property changes (same as execute).""" + return self.execute() + + def _handle_property_side_effects(self, prop_name: str, value: Any): + """Handle side effects of changing specific properties.""" + if prop_name in ['color_background', 'color_border', 'color_title_bg', + 'color_title_text', 'color_selection']: + # Update related brushes and pens when colors change + self._update_color_related_objects(prop_name, value) + elif prop_name in ['width', 'height']: + # Ensure size doesn't go below minimums + if prop_name == 'width': + self.group.width = max(value, self.group.min_width) + elif prop_name == 'height': + self.group.height = max(value, self.group.min_height) + + # Update group rectangle + self.group.setRect(0, 0, self.group.width, self.group.height) + elif prop_name == 'padding': + # Padding changes might affect auto-sizing + if hasattr(self.group, 'calculate_bounds_from_members'): + self.group.calculate_bounds_from_members(self.node_graph) + + def _update_color_related_objects(self, prop_name: str, color: QColor): + """Update brushes and pens when colors change.""" + from PySide6.QtGui import QPen, QBrush + + if prop_name == 'color_background': + self.group.brush_background = QBrush(color) + elif prop_name == 'color_border': + self.group.pen_border = QPen(color, 2.0) + elif prop_name == 'color_title_bg': + self.group.brush_title = QBrush(color) + elif prop_name == 'color_selection': + self.group.pen_selected = QPen(color, 3.0) + + def _update_group_visuals(self): + """Update group visual representation after property changes.""" + # Prepare for geometry changes if size changed + if any(prop in self.property_changes for prop in ['width', 'height', 'padding']): + self.group.prepareGeometryChange() + + # Force visual update + self.group.update() + + # Update scene area if size changed + if any(prop in self.property_changes for prop in ['width', 'height']): + if self.group.scene(): + # Update the scene area where the group might have changed size + expanded_rect = self.group.boundingRect() + scene_rect = self.group.mapRectToScene(expanded_rect) + self.group.scene().update(scene_rect) + + def can_merge_with(self, other_command) -> bool: + """ + Check if this command can be merged with another group property change. + + Allow merging if: + - Same group + - Same command type + - Recent enough (within 2 seconds) + """ + return (isinstance(other_command, GroupPropertyChangeCommand) and + other_command.group == self.group and + abs(other_command.timestamp - self.timestamp) < 2.0) + + def merge_with(self, other_command) -> Optional['CommandBase']: + """Merge with another group property change command.""" + if not self.can_merge_with(other_command): + return None + + # Create merged property changes + merged_changes = self.property_changes.copy() + merged_changes.update(other_command.property_changes) + + # Create new command with merged changes + merged_command = GroupPropertyChangeCommand( + self.node_graph, + self.group, + merged_changes + ) + + # Use the original old values from the first command + merged_command.old_values = self.old_values.copy() + + # For properties that weren't in the original command, + # use old values from the other command + for prop_name, new_value in other_command.property_changes.items(): + if prop_name not in self.property_changes: + merged_command.old_values[prop_name] = other_command.old_values.get(prop_name) + + return merged_command + + def get_memory_usage(self) -> int: + """Estimate memory usage of this command.""" + base_size = 512 + + # Estimate size based on number of properties and their types + for prop_name, value in self.property_changes.items(): + if isinstance(value, str): + base_size += len(value) * 2 # Unicode characters + elif isinstance(value, QColor): + base_size += 16 # RGBA values + else: + base_size += 8 # Basic numeric types + + # Same estimation for old values + for prop_name, value in self.old_values.items(): + if isinstance(value, str): + base_size += len(value) * 2 + elif isinstance(value, QColor): + base_size += 16 + else: + base_size += 8 + + return base_size + + def get_affected_items(self): + """Return list of items affected by this command.""" + return [self.group] \ No newline at end of file diff --git a/src/commands/node/__init__.py b/src/commands/node/__init__.py new file mode 100644 index 0000000..12332dd --- /dev/null +++ b/src/commands/node/__init__.py @@ -0,0 +1,22 @@ +""" +Node command modules for PyFlowGraph. + +This package contains all node-related command implementations split into +logical modules for better maintainability. +""" + +# Import all node commands for backward compatibility +from .basic_operations import CreateNodeCommand, DeleteNodeCommand, MoveNodeCommand +from .property_changes import PropertyChangeCommand, CodeChangeCommand +from .batch_operations import PasteNodesCommand, MoveMultipleCommand, DeleteMultipleCommand + +__all__ = [ + 'CreateNodeCommand', + 'DeleteNodeCommand', + 'MoveNodeCommand', + 'PropertyChangeCommand', + 'CodeChangeCommand', + 'PasteNodesCommand', + 'MoveMultipleCommand', + 'DeleteMultipleCommand' +] \ No newline at end of file diff --git a/src/commands/node/basic_operations.py b/src/commands/node/basic_operations.py new file mode 100644 index 0000000..09d7735 --- /dev/null +++ b/src/commands/node/basic_operations.py @@ -0,0 +1,672 @@ +""" +Basic node operations: create, delete, and move commands. + +These are the fundamental node operations that all other node commands build upon. +""" + +import uuid +import sys +import os +from typing import Optional +from PySide6.QtCore import QPointF + +# Add project root to path for cross-package imports +project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) +if project_root not in sys.path: + sys.path.insert(0, project_root) + +from commands.command_base import CommandBase + +# Debug configuration +DEBUG_NODE_COMMANDS = False + + +class CreateNodeCommand(CommandBase): + """Command for creating nodes with full state preservation.""" + + def __init__(self, node_graph, title: str, position: QPointF, + node_id: str = None, code: str = "", description: str = "", + size: list = None, colors: dict = None, gui_state: dict = None, + gui_code: str = "", gui_get_values_code: str = "", is_reroute: bool = False): + """ + Initialize create node command. + + Args: + node_graph: The NodeGraph instance + title: Node title/type + position: Position to create node at + node_id: Optional specific node ID (for undo consistency) + code: Initial code for the node + description: Node description + size: Node size as [width, height] + colors: Node colors as {"title": "#color", "body": "#color"} + gui_state: GUI widget states + gui_code: GUI definition code + gui_get_values_code: GUI state handler code + is_reroute: Whether this is a reroute node + """ + super().__init__(f"Create '{title}' node") + self.node_graph = node_graph + self.title = title + self.position = position + self.node_id = node_id or str(uuid.uuid4()) + self.code = code + self.node_description = description + self.size = size or [200, 150] + self.colors = colors or {} + self.gui_state = gui_state or {} + self.gui_code = gui_code + self.gui_get_values_code = gui_get_values_code + self.is_reroute = is_reroute + self.created_node = None + + def execute(self) -> bool: + """Create the node and add to graph.""" + try: + if self.is_reroute: + # Create reroute node + self.created_node = self.node_graph.create_node("", pos=(self.position.x(), self.position.y()), is_reroute=True, use_command=False) + self.created_node.uuid = self.node_id + else: + # Import here to avoid circular imports + from core.node import Node + + # Create the node + self.created_node = Node(self.title) + self.created_node.uuid = self.node_id + self.created_node.description = self.node_description + self.created_node.setPos(self.position) + + # Set code first so pins are generated + if self.code: + self.created_node.code = self.code + self.created_node.update_pins_from_code() + + # Set GUI code + if self.gui_code: + self.created_node.set_gui_code(self.gui_code) + if self.gui_get_values_code: + self.created_node.set_gui_get_values_code(self.gui_get_values_code) + + # Apply size - use minimum size calculation for validation + if self.size and len(self.size) >= 2: + # Get minimum size for this node + min_width, min_height = self.created_node.calculate_absolute_minimum_size() + + # Apply size with minimum validation + self.created_node.width = max(self.size[0], min_width) + self.created_node.height = max(self.size[1], min_height) + + # Apply colors + if self.colors: + from PySide6.QtGui import QColor + if "title" in self.colors: + self.created_node.color_title_bar = QColor(self.colors["title"]) + if "body" in self.colors: + self.created_node.color_body = QColor(self.colors["body"]) + + # Update visual representation + self.created_node.update() + + # Apply GUI state after GUI is created + if self.gui_state: + self.created_node.apply_gui_state(self.gui_state) + + # Add to graph + self.node_graph.addItem(self.created_node) + self.node_graph.nodes.append(self.created_node) + + self._mark_executed() + return True + + except Exception as e: + print(f"Failed to create node: {e}") + return False + + def undo(self) -> bool: + """Remove the created node.""" + if not self.created_node or self.created_node not in self.node_graph.nodes: + return False + + try: + # Remove all connections to this node first + connections_to_remove = [] + for connection in list(self.node_graph.connections): + if (hasattr(connection, 'start_pin') and connection.start_pin.node == self.created_node or + hasattr(connection, 'end_pin') and connection.end_pin.node == self.created_node): + connections_to_remove.append(connection) + + for connection in connections_to_remove: + # Remove from connections list first + if connection in self.node_graph.connections: + self.node_graph.connections.remove(connection) + # Remove from scene if it's still there + if connection.scene() == self.node_graph: + self.node_graph.removeItem(connection) + + # Remove node from graph + if self.created_node in self.node_graph.nodes: + self.node_graph.nodes.remove(self.created_node) + if self.created_node.scene() == self.node_graph: + self.node_graph.removeItem(self.created_node) + + self._mark_undone() + return True + + except Exception as e: + print(f"Failed to undo node creation: {e}") + return False + + def get_memory_usage(self) -> int: + """Estimate memory usage of this command.""" + base_size = 512 + title_size = len(self.title) * 2 + code_size = len(self.code) * 2 + description_size = len(self.node_description) * 2 + return base_size + title_size + code_size + description_size + + +class DeleteNodeCommand(CommandBase): + """Command for deleting nodes with complete state preservation.""" + + def __init__(self, node_graph, node): + """ + Initialize delete node command. + + Args: + node_graph: The NodeGraph instance + node: Node to delete + """ + super().__init__(f"Delete '{node.title}' node") + self.node_graph = node_graph + self.node = node + self.node_state = None + self.affected_connections = [] + self.node_index = None + + def execute(self) -> bool: + """Delete node after preserving complete state.""" + try: + # Check if this node object is actually in the nodes list + found_in_list = False + node_in_list = None + for i, node in enumerate(self.node_graph.nodes): + if node is self.node: # Same object reference + found_in_list = True + node_in_list = node + self.node_index = i + break + elif hasattr(node, 'uuid') and hasattr(self.node, 'uuid') and node.uuid == self.node.uuid: + # Use the node that's actually in the list (UUID synchronization fix) + self.node = node + found_in_list = True + node_in_list = node + self.node_index = i + break + + if not found_in_list: + print(f"Error: Node '{getattr(self.node, 'title', 'Unknown')}' not found in graph") + return False + + # Preserve complete node state including colors and size + self.node_state = { + 'id': self.node.uuid, + 'title': self.node.title, + 'description': getattr(self.node, 'description', ''), + 'position': self.node.pos(), + 'code': getattr(self.node, 'code', ''), + 'gui_code': getattr(self.node, 'gui_code', ''), + 'gui_get_values_code': getattr(self.node, 'gui_get_values_code', ''), + 'function_name': getattr(self.node, 'function_name', None), + 'width': getattr(self.node, 'width', 150), + 'height': getattr(self.node, 'height', 150), + 'base_width': getattr(self.node, 'base_width', 150), + 'is_reroute': getattr(self.node, 'is_reroute', False), + # Preserve colors + 'color_title_bar': getattr(self.node, 'color_title_bar', None), + 'color_body': getattr(self.node, 'color_body', None), + 'color_title_text': getattr(self.node, 'color_title_text', None), + # Preserve GUI state + 'gui_state': {} + } + + # Try to capture GUI state if possible + try: + if hasattr(self.node, 'gui_widgets') and self.node.gui_widgets and self.node.gui_get_values_code: + scope = {"widgets": self.node.gui_widgets} + exec(self.node.gui_get_values_code, scope) + get_values_func = scope.get("get_values") + if callable(get_values_func): + self.node_state['gui_state'] = get_values_func(self.node.gui_widgets) + if DEBUG_NODE_COMMANDS: + print(f"DEBUG: Captured GUI state: {self.node_state['gui_state']}") + except Exception as e: + if DEBUG_NODE_COMMANDS: + print(f"DEBUG: Could not capture GUI state: {e}") + + # Check connections before removal + connections_to_node = [] + for connection in list(self.node_graph.connections): + if (hasattr(connection, 'start_pin') and connection.start_pin.node == self.node or + hasattr(connection, 'end_pin') and connection.end_pin.node == self.node): + connections_to_node.append(connection) + + # Preserve affected connections + self.affected_connections = [] + for connection in connections_to_node: + + # Handle different pin structures for regular nodes vs RerouteNodes + start_node = connection.start_pin.node + end_node = connection.end_pin.node + + # Get pin indices and names for more robust restoration + if hasattr(start_node, 'is_reroute') and start_node.is_reroute: + # RerouteNode - use single pins + output_pin_index = 0 if connection.start_pin == start_node.output_pin else -1 + output_pin_name = "output" + else: + # Regular Node - use pin lists and store both index and name + output_pin_index = self._get_pin_index(start_node.output_pins, connection.start_pin) + output_pin_name = connection.start_pin.name + + if hasattr(end_node, 'is_reroute') and end_node.is_reroute: + # RerouteNode - use single pins + input_pin_index = 0 if connection.end_pin == end_node.input_pin else -1 + input_pin_name = "input" + else: + # Regular Node - use pin lists and store both index and name + input_pin_index = self._get_pin_index(end_node.input_pins, connection.end_pin) + input_pin_name = connection.end_pin.name + + conn_data = { + 'connection': connection, + 'output_node_id': start_node.uuid, + 'output_pin_index': output_pin_index, + 'output_pin_name': output_pin_name, + 'input_node_id': end_node.uuid, + 'input_pin_index': input_pin_index, + 'input_pin_name': input_pin_name + } + self.affected_connections.append(conn_data) + + # Remove connection safely + if connection in self.node_graph.connections: + self.node_graph.connections.remove(connection) + + if connection.scene() == self.node_graph: + self.node_graph.removeItem(connection) + + # Remove node from graph safely + if self.node in self.node_graph.nodes: + self.node_graph.nodes.remove(self.node) + else: + print(f"Error: Node not in nodes list during removal") + return False + + if self.node.scene() == self.node_graph: + self.node_graph.removeItem(self.node) + else: + print(f"Error: Node not in scene or scene mismatch") + return False + + self._mark_executed() + return True + + except Exception as e: + print(f"Error: Failed to delete node: {e}") + return False + + def undo(self) -> bool: + """Restore node with complete state and reconnections.""" + if not self.node_state: + print(f"Error: No node state to restore") + return False + + try: + # Import here to avoid circular imports + from core.node import Node + from PySide6.QtGui import QColor + + # Use local debug configuration + debug_enabled = DEBUG_NODE_COMMANDS + + # Recreate node with preserved state - check if it was a RerouteNode + if self.node_state.get('is_reroute', False): + # Recreate as RerouteNode + from core.reroute_node import RerouteNode + restored_node = RerouteNode() + restored_node.uuid = self.node_state['id'] + restored_node.setPos(self.node_state['position']) + # RerouteNodes don't have most of the properties that regular nodes have + else: + # Recreate as regular Node + restored_node = Node(self.node_state['title']) + restored_node.uuid = self.node_state['id'] + restored_node.description = self.node_state['description'] + restored_node.setPos(self.node_state['position']) + # Set code which will trigger pin updates + restored_node.set_code(self.node_state['code']) + # Set GUI code which will trigger GUI rebuild + restored_node.set_gui_code(self.node_state['gui_code']) + restored_node.set_gui_get_values_code(self.node_state['gui_get_values_code']) + restored_node.function_name = self.node_state['function_name'] + + # Only apply regular node properties if it's not a RerouteNode + if not self.node_state.get('is_reroute', False): + if debug_enabled: + print(f"DEBUG: Restoring regular node properties for '{self.node_state['title']}'") + print(f"DEBUG: Original size: {self.node_state['width']}x{self.node_state['height']}") + # Restore size BEFORE updating pins (important for layout) + restored_node.width = self.node_state['width'] + restored_node.height = self.node_state['height'] + restored_node.base_width = self.node_state['base_width'] + + # Restore colors + if self.node_state['color_title_bar']: + if isinstance(self.node_state['color_title_bar'], str): + restored_node.color_title_bar = QColor(self.node_state['color_title_bar']) + else: + restored_node.color_title_bar = self.node_state['color_title_bar'] + + if self.node_state['color_body']: + if isinstance(self.node_state['color_body'], str): + restored_node.color_body = QColor(self.node_state['color_body']) + else: + restored_node.color_body = self.node_state['color_body'] + + if self.node_state['color_title_text']: + if isinstance(self.node_state['color_title_text'], str): + restored_node.color_title_text = QColor(self.node_state['color_title_text']) + else: + restored_node.color_title_text = self.node_state['color_title_text'] + + # Pins were already updated by set_code() above + if debug_enabled: + print(f"DEBUG: Pins already updated by set_code()") + + # Calculate minimum size requirements for validation + min_width, min_height = restored_node.calculate_absolute_minimum_size() + + # Validate restored size against minimum requirements + original_width = self.node_state['width'] + original_height = self.node_state['height'] + corrected_width = max(original_width, min_width) + corrected_height = max(original_height, min_height) + + if debug_enabled and (corrected_width != original_width or corrected_height != original_height): + print(f"DEBUG: Node restoration size corrected from " + f"{original_width}x{original_height} to {corrected_width}x{corrected_height}") + + # Apply validated size + restored_node.width = corrected_width + restored_node.height = corrected_height + restored_node.base_width = self.node_state['base_width'] + + if debug_enabled: + print(f"DEBUG: Node size set to {restored_node.width}x{restored_node.height}") + + # Force visual update with correct colors and size + restored_node.update() + + # Add back to graph at original position + if self.node_index is not None and self.node_index <= len(self.node_graph.nodes): + self.node_graph.nodes.insert(self.node_index, restored_node) + else: + self.node_graph.nodes.append(restored_node) + + self.node_graph.addItem(restored_node) + + # Apply GUI state AFTER GUI widgets are created + if self.node_state.get('gui_state') and not self.node_state.get('is_reroute', False): + try: + if debug_enabled: + print(f"DEBUG: Applying GUI state: {self.node_state['gui_state']}") + print(f"DEBUG: GUI widgets available: {bool(restored_node.gui_widgets)}") + print(f"DEBUG: GUI widgets count: {len(restored_node.gui_widgets) if restored_node.gui_widgets else 0}") + restored_node.apply_gui_state(self.node_state['gui_state']) + if debug_enabled: + print(f"DEBUG: GUI state applied successfully") + except Exception as e: + if debug_enabled: + print(f"DEBUG: GUI state restoration failed: {e}") + elif debug_enabled: + if not self.node_state.get('gui_state'): + print(f"DEBUG: No GUI state to restore") + elif self.node_state.get('is_reroute', False): + print(f"DEBUG: Skipping GUI state for reroute node") + + # Restore connections with improved error handling + restored_connections = 0 + failed_connections = 0 + for conn_data in self.affected_connections: + try: + # Find nodes by ID + output_node = self._find_node_by_id(conn_data['output_node_id']) + input_node = self._find_node_by_id(conn_data['input_node_id']) + + if not output_node or not input_node: + if debug_enabled: + print(f"DEBUG: Connection restoration failed - nodes not found (output: {output_node is not None}, input: {input_node is not None})") + failed_connections += 1 + continue + + # Get pins by index based on node type with proper validation + output_pin = None + input_pin = None + + # Handle output pin with robust fallback + output_pin = None + if hasattr(output_node, 'is_reroute') and output_node.is_reroute: + # RerouteNode - use single output pin + output_pin = output_node.output_pin + else: + # Regular Node - try pin index first, then fallback to name search + output_pin_index = conn_data['output_pin_index'] + output_pin_name = conn_data.get('output_pin_name', 'exec_out') + + if (hasattr(output_node, 'output_pins') and + output_node.output_pins and + 0 <= output_pin_index < len(output_node.output_pins)): + output_pin = output_node.output_pins[output_pin_index] + if debug_enabled: + print(f"DEBUG: Found output pin by index {output_pin_index}: '{output_pin.name}' on '{output_node.title}'") + else: + # Fallback: search by name + if debug_enabled: + print(f"DEBUG: Output pin index {output_pin_index} failed, searching by name '{output_pin_name}' on '{output_node.title}'") + output_pin = output_node.get_pin_by_name(output_pin_name) + if output_pin and debug_enabled: + print(f"DEBUG: Found output pin by name: '{output_pin.name}' on '{output_node.title}'") + + if not output_pin and debug_enabled: + print(f"DEBUG: Output pin not found by index {output_pin_index} or name '{output_pin_name}' on node {output_node.title}") + print(f"DEBUG: Available output pins: {[p.name for p in output_node.output_pins] if hasattr(output_node, 'output_pins') and output_node.output_pins else []}") + + # Handle input pin with robust fallback + input_pin = None + if hasattr(input_node, 'is_reroute') and input_node.is_reroute: + # RerouteNode - use single input pin + input_pin = input_node.input_pin + else: + # Regular Node - try pin index first, then fallback to name search + input_pin_index = conn_data['input_pin_index'] + input_pin_name = conn_data.get('input_pin_name', 'exec_in') + + if (hasattr(input_node, 'input_pins') and + input_node.input_pins and + 0 <= input_pin_index < len(input_node.input_pins)): + input_pin = input_node.input_pins[input_pin_index] + if debug_enabled: + print(f"DEBUG: Found input pin by index {input_pin_index}: '{input_pin.name}' on '{input_node.title}'") + else: + # Fallback: search by name + if debug_enabled: + print(f"DEBUG: Input pin index {input_pin_index} failed, searching by name '{input_pin_name}' on '{input_node.title}'") + input_pin = input_node.get_pin_by_name(input_pin_name) + if input_pin and debug_enabled: + print(f"DEBUG: Found input pin by name: '{input_pin.name}' on '{input_node.title}'") + + if not input_pin and debug_enabled: + print(f"DEBUG: Input pin not found by index {input_pin_index} or name '{input_pin_name}' on node {input_node.title}") + print(f"DEBUG: Available input pins: {[p.name for p in input_node.input_pins] if hasattr(input_node, 'input_pins') and input_node.input_pins else []}") + + # Validate pins exist + if not output_pin or not input_pin: + if debug_enabled: + print(f"DEBUG: Connection restoration failed - pins not found (output: {output_pin is not None}, input: {input_pin is not None})") + failed_connections += 1 + continue + + # Check if connection already exists to avoid duplicates + connection_exists = False + for existing_conn in self.node_graph.connections: + if (hasattr(existing_conn, 'start_pin') and existing_conn.start_pin == output_pin and + hasattr(existing_conn, 'end_pin') and existing_conn.end_pin == input_pin): + connection_exists = True + break + + if connection_exists: + if debug_enabled: + print(f"DEBUG: Connection already exists, skipping restoration") + continue + + # Recreate connection + from core.connection import Connection + new_connection = Connection(output_pin, input_pin) + self.node_graph.addItem(new_connection) + self.node_graph.connections.append(new_connection) + + # Note: Connection constructor automatically adds itself to pin connection lists + # No need to manually call add_connection as it would create duplicates + + restored_connections += 1 + + if debug_enabled: + print(f"DEBUG: Connection restored successfully between {output_node.title}.{output_pin.name} and {input_node.title}.{input_pin.name}") + print(f"DEBUG: Pin details - Output pin category: {output_pin.pin_category}, Input pin category: {input_pin.pin_category}") + print(f"DEBUG: Connection added to graph connections (total: {len(self.node_graph.connections)})") + print(f"DEBUG: Output pin connections: {len(output_pin.connections)}, Input pin connections: {len(input_pin.connections)}") + + except Exception as e: + if debug_enabled: + print(f"DEBUG: Connection restoration failed with exception: {e}") + failed_connections += 1 + continue + + if debug_enabled: + print(f"DEBUG: Connection restoration summary: {restored_connections} restored, {failed_connections} failed") + + # Final layout update sequence (only for regular nodes) + if not self.node_state.get('is_reroute', False): + if debug_enabled: + print(f"DEBUG: Final layout update sequence") + + # Force layout update to ensure pins are positioned correctly + restored_node._update_layout() + + # Ensure size still meets minimum requirements after GUI state + restored_node.fit_size_to_content() + + if debug_enabled: + print(f"DEBUG: Final node size: {restored_node.width}x{restored_node.height}") + + # Final visual refresh + restored_node.update() + + # Update node reference + self.node = restored_node + + if debug_enabled: + print(f"DEBUG: Node restoration completed successfully") + self._mark_undone() + return True + + except Exception as e: + print(f"Error: Failed to undo node deletion: {e}") + return False + + def _find_node_by_id(self, node_id: str): + """Find node in graph by UUID.""" + for node in self.node_graph.nodes: + if node.uuid == node_id: + return node + return None + + def _get_pin_index(self, pin_list, pin): + """Safely get pin index.""" + try: + return pin_list.index(pin) + except (ValueError, AttributeError): + return 0 + + def get_memory_usage(self) -> int: + """Estimate memory usage of this command.""" + if not self.node_state: + return 512 + + base_size = 1024 + code_size = len(self.node_state.get('code', '')) * 2 + gui_code_size = len(self.node_state.get('gui_code', '')) * 2 + title_size = len(self.node_state.get('title', '')) * 2 + connections_size = len(self.affected_connections) * 200 + + return base_size + code_size + gui_code_size + title_size + connections_size + + +class MoveNodeCommand(CommandBase): + """Command for moving nodes with position tracking.""" + + def __init__(self, node_graph, node, old_position: QPointF, new_position: QPointF): + """ + Initialize move node command. + + Args: + node_graph: The NodeGraph instance + node: Node to move + old_position: Original position + new_position: New position + """ + super().__init__(f"Move '{node.title}' node") + self.node_graph = node_graph + self.node = node + self.old_position = old_position + self.new_position = new_position + + def execute(self) -> bool: + """Move node to new position.""" + try: + self.node.setPos(self.new_position) + self._mark_executed() + return True + except Exception as e: + print(f"Failed to move node: {e}") + return False + + def undo(self) -> bool: + """Move node back to original position.""" + try: + self.node.setPos(self.old_position) + self._mark_undone() + return True + except Exception as e: + print(f"Failed to undo node move: {e}") + return False + + def can_merge_with(self, other: CommandBase) -> bool: + """Check if this move can be merged with another move.""" + return (isinstance(other, MoveNodeCommand) and + other.node == self.node and + abs(other.timestamp - self.timestamp) < 1.0) # Within 1 second + + def merge_with(self, other: CommandBase) -> Optional[CommandBase]: + """Merge with another move command.""" + if not self.can_merge_with(other): + return None + + # Create merged command using original start position and latest end position + return MoveNodeCommand( + self.node_graph, + self.node, + self.old_position, + other.new_position + ) \ No newline at end of file diff --git a/src/commands/node/batch_operations.py b/src/commands/node/batch_operations.py new file mode 100644 index 0000000..ceeb796 --- /dev/null +++ b/src/commands/node/batch_operations.py @@ -0,0 +1,443 @@ +""" +Batch node operations: paste, move multiple, delete multiple commands. + +Handles operations that affect multiple nodes or complex multi-step operations. +""" + +import uuid +import sys +import os +from typing import Dict, Any, List +from PySide6.QtCore import QPointF + +# Add project root to path for cross-package imports +project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) +if project_root not in sys.path: + sys.path.insert(0, project_root) + +from commands.command_base import CompositeCommand +from .basic_operations import CreateNodeCommand, DeleteNodeCommand, MoveNodeCommand + + +class PasteNodesCommand(CompositeCommand): + """Command for pasting nodes and connections as a single undo unit.""" + + def __init__(self, node_graph, clipboard_data: Dict[str, Any], paste_position: QPointF): + """ + Initialize paste nodes command. + + Args: + node_graph: The NodeGraph instance + clipboard_data: Data from clipboard containing nodes, groups, and connections + paste_position: Position to paste nodes at + """ + # Parse clipboard data to determine operation description + node_count = len(clipboard_data.get('nodes', [])) + group_count = len(clipboard_data.get('groups', [])) + connection_count = len(clipboard_data.get('connections', [])) + + if node_count == 1 and group_count == 0 and connection_count == 0: + description = f"Paste '{clipboard_data['nodes'][0].get('title', 'node')}'" + elif node_count > 1 and group_count == 0 and connection_count == 0: + description = f"Paste {node_count} nodes" + elif node_count == 0 and group_count == 1 and connection_count == 0: + description = f"Paste group '{clipboard_data['groups'][0].get('name', 'Group')}'" + elif node_count > 0 and group_count > 0: + description = f"Paste {node_count} nodes and {group_count} groups" + elif group_count > 1: + description = f"Paste {group_count} groups" + else: + total_items = node_count + group_count + description = f"Paste {total_items} items with {connection_count} connections" + + # Store data for execute method to handle connection and group creation + self.node_graph = node_graph + self.clipboard_data = clipboard_data + self.paste_position = paste_position + self.uuid_mapping = {} # Map old UUIDs to new UUIDs + self.created_nodes = [] + self.created_groups = [] + + # Create only node creation commands initially + commands = [] + nodes_data = clipboard_data.get('nodes', []) + groups_data = clipboard_data.get('groups', []) + + # Check if we're pasting groups with nodes - use different positioning logic + has_groups = len(groups_data) > 0 + + for i, node_data in enumerate(nodes_data): + # Generate new UUID for this node + old_uuid = node_data.get('id', str(uuid.uuid4())) + new_uuid = str(uuid.uuid4()) + self.uuid_mapping[old_uuid] = new_uuid + + if has_groups: + # When pasting groups, preserve original absolute positions + # The group will handle positioning itself and its members + original_pos = node_data.get('pos', [0, 0]) + node_position = QPointF(original_pos[0], original_pos[1]) + else: + # When pasting nodes only, arrange in grid pattern + offset_x = (i % 3) * 200 # Arrange in grid pattern + offset_y = (i // 3) * 150 + node_position = QPointF( + paste_position.x() + offset_x, + paste_position.y() + offset_y + ) + + # Create node command with all properties + create_cmd = CreateNodeCommand( + node_graph=node_graph, + title=node_data.get('title', 'Pasted Node'), + position=node_position, + node_id=new_uuid, + code=node_data.get('code', ''), + description=node_data.get('description', ''), + size=node_data.get('size', [200, 150]), + colors=node_data.get('colors', {}), + gui_state=node_data.get('gui_state', {}), + gui_code=node_data.get('gui_code', ''), + gui_get_values_code=node_data.get('gui_get_values_code', ''), + is_reroute=node_data.get('is_reroute', False) + ) + commands.append(create_cmd) + self.created_nodes.append((create_cmd, node_data)) + + super().__init__(description, commands) + + def execute(self) -> bool: + """Execute node creation first, then create connections and groups.""" + # First execute node creation commands + if not super().execute(): + return False + + # Now create connections using actual pin objects + connections_data = self.clipboard_data.get('connections', []) + connection_commands = [] + + for conn_data in connections_data: + # Map old UUIDs to new UUIDs + old_output_node_id = conn_data.get('output_node_id', '') + old_input_node_id = conn_data.get('input_node_id', '') + + new_output_node_id = self.uuid_mapping.get(old_output_node_id) + new_input_node_id = self.uuid_mapping.get(old_input_node_id) + + # Only create connection if both nodes are being pasted + if new_output_node_id and new_input_node_id: + # Find the actual created nodes + output_node = self._find_node_by_id(new_output_node_id) + input_node = self._find_node_by_id(new_input_node_id) + + if output_node and input_node: + # Find pins by name + output_pin_name = conn_data.get('output_pin_name', '') + input_pin_name = conn_data.get('input_pin_name', '') + + output_pin = output_node.get_pin_by_name(output_pin_name) + input_pin = input_node.get_pin_by_name(input_pin_name) + + if output_pin and input_pin: + # Import here to avoid circular imports + from commands.connection_commands import CreateConnectionCommand + + conn_cmd = CreateConnectionCommand( + node_graph=self.node_graph, + output_pin=output_pin, + input_pin=input_pin + ) + connection_commands.append(conn_cmd) + + # Execute connection commands + for conn_cmd in connection_commands: + result = conn_cmd.execute() + if result: + conn_cmd._mark_executed() + self.commands.append(conn_cmd) + self.executed_commands.append(conn_cmd) + else: + print(f"Failed to create connection: {conn_cmd.get_description()}") + + # DEBUG: Check node positions before group processing + print("DEBUG: Node positions after creation, before group processing:") + for cmd, node_data in self.created_nodes: + if cmd.created_node: + pos = cmd.created_node.pos() + print(f" {cmd.created_node.title}: ({pos.x()}, {pos.y()})") + + # Create groups after nodes and connections are established + groups_data = self.clipboard_data.get('groups', []) + group_commands = [] + + for group_data in groups_data: + # Generate new UUID for the group + old_group_uuid = group_data.get('uuid', str(uuid.uuid4())) + new_group_uuid = str(uuid.uuid4()) + + # Update member node UUIDs to use new UUIDs + old_member_uuids = group_data.get('member_node_uuids', []) + new_member_uuids = [] + for old_member_uuid in old_member_uuids: + new_member_uuid = self.uuid_mapping.get(old_member_uuid) + if new_member_uuid: # Only include nodes that were pasted + new_member_uuids.append(new_member_uuid) + + # Only create group if it has member nodes that were pasted + if new_member_uuids: + # Calculate transformation needed to move group to paste position + group_position = group_data.get('position', {'x': 0, 'y': 0}) + original_group_pos = QPointF(group_position['x'], group_position['y']) + transform_offset = self.paste_position - original_group_pos + + # Position group at paste position + offset_position = self.paste_position + + # Transform all member nodes by the same offset to maintain relative positions + print(f"DEBUG: Applying transform offset ({transform_offset.x()}, {transform_offset.y()}) to nodes") + for node_uuid in new_member_uuids: + found_node = None + for cmd, _ in self.created_nodes: + if hasattr(cmd, 'created_node') and cmd.created_node and cmd.created_node.uuid == node_uuid: + found_node = cmd.created_node + break + if found_node: + current_pos = found_node.pos() + new_pos = current_pos + transform_offset + print(f" {found_node.title}: ({current_pos.x()}, {current_pos.y()}) -> ({new_pos.x()}, {new_pos.y()})") + found_node.setPos(new_pos) + + # Create modified group properties for the command + group_properties = { + 'name': group_data.get('name', 'Pasted Group'), + 'description': group_data.get('description', ''), + 'member_node_uuids': new_member_uuids, + 'auto_size': False, # Keep original size + 'padding': group_data.get('padding', 20) + } + + # Import here to avoid circular imports + from commands.create_group_command import CreateGroupCommand + + group_cmd = CreateGroupCommand(self.node_graph, group_properties) + # Override the UUID to use our new one + group_cmd.group_uuid = new_group_uuid + + result = group_cmd.execute() + if result: + group_cmd._mark_executed() + self.commands.append(group_cmd) + self.executed_commands.append(group_cmd) + + # Apply additional properties (position, size, colors) + created_group = group_cmd.created_group + if created_group: + created_group.setPos(offset_position) + + # Use original group size since we preserved relative layout + size = group_data.get('size', {'width': 200, 'height': 150}) + created_group.width = size['width'] + created_group.height = size['height'] + + created_group.setRect(0, 0, created_group.width, created_group.height) + + # Restore colors if present + colors = group_data.get('colors', {}) + if colors: + from PySide6.QtGui import QColor, QPen, QBrush + + if 'background' in colors: + bg = colors['background'] + created_group.color_background = QColor(bg['r'], bg['g'], bg['b'], bg['a']) + created_group.brush_background = QBrush(created_group.color_background) + + if 'border' in colors: + border = colors['border'] + created_group.color_border = QColor(border['r'], border['g'], border['b'], border['a']) + created_group.pen_border = QPen(created_group.color_border, 2.0) + + if 'title_bg' in colors: + title_bg = colors['title_bg'] + created_group.color_title_bg = QColor(title_bg['r'], title_bg['g'], title_bg['b'], title_bg['a']) + created_group.brush_title = QBrush(created_group.color_title_bg) + + if 'title_text' in colors: + title_text = colors['title_text'] + created_group.color_title_text = QColor(title_text['r'], title_text['g'], title_text['b'], title_text['a']) + + if 'selection' in colors: + selection = colors['selection'] + created_group.color_selection = QColor(selection['r'], selection['g'], selection['b'], selection['a']) + created_group.pen_selected = QPen(created_group.color_selection, 3.0) + + created_group.update() + + self.created_groups.append(group_cmd) + print(f"Pasted group '{group_properties['name']}' with {len(new_member_uuids)} members") + else: + print(f"Failed to create group: {group_properties['name']}") + + # Schedule deferred GUI update for pasted nodes - similar to file loading + # This ensures GUI widgets refresh properly for nodes with GUI code + nodes_to_update = [] + for cmd, _ in self.created_nodes: + if cmd.created_node: + # Check if it's a reroute node by checking for is_reroute attribute + is_reroute = hasattr(cmd.created_node, 'is_reroute') and cmd.created_node.is_reroute + if not is_reroute: + # Only update regular nodes that might have GUI widgets + nodes_to_update.append(cmd.created_node) + + if nodes_to_update: + from PySide6.QtCore import QTimer + QTimer.singleShot(0, lambda: self._final_paste_update(nodes_to_update)) + + return True + + def _find_node_by_id(self, node_id: str): + """Find node in graph by UUID.""" + for node in self.node_graph.nodes: + if hasattr(node, 'uuid') and node.uuid == node_id: + return node + return None + + def get_memory_usage(self) -> int: + """Estimate memory usage for paste operation.""" + base_size = 1024 + data_size = len(str(self.clipboard_data)) * 2 + mapping_size = len(self.uuid_mapping) * 100 + return base_size + data_size + mapping_size + + def _final_paste_update(self, nodes_to_update): + """Final update pass for pasted nodes - ensures GUI widgets refresh properly.""" + for node in nodes_to_update: + try: + if node.scene() is None: + continue # Node has been removed from scene + + # Force complete layout rebuild like file loading does + node._update_layout() + + # Update all pin connections + for pin in node.pins: + pin.update_connections() + + # Force node visual update + node.update() + except RuntimeError: + # Node has been deleted, skip + continue + + # Force scene update + self.node_graph.update() + + +class MoveMultipleCommand(CompositeCommand): + """Command for moving multiple nodes as a single undo unit.""" + + def __init__(self, node_graph, nodes_and_positions: List[tuple]): + """ + Initialize move multiple command. + + Args: + node_graph: The NodeGraph instance + nodes_and_positions: List of (node, old_pos, new_pos) tuples + """ + # Create individual move commands + commands = [] + node_count = len(nodes_and_positions) + + if node_count == 1: + node = nodes_and_positions[0][0] + description = f"Move '{node.title}'" + else: + description = f"Move {node_count} nodes" + + for node, old_pos, new_pos in nodes_and_positions: + move_cmd = MoveNodeCommand(node_graph, node, old_pos, new_pos) + commands.append(move_cmd) + + super().__init__(description, commands) + + def get_memory_usage(self) -> int: + """Estimate memory usage for move operation.""" + base_size = 256 + return base_size + super().get_memory_usage() + + +class DeleteMultipleCommand(CompositeCommand): + """Command for deleting multiple items as a single undo unit.""" + + def __init__(self, node_graph, selected_items: List): + """ + Initialize delete multiple command. + + Args: + node_graph: The NodeGraph instance + selected_items: List of items (nodes and connections) to delete + """ + # Import here to avoid circular imports + from core.node import Node + from core.reroute_node import RerouteNode + from core.connection import Connection + from core.group import Group + + # Create individual delete commands + commands = [] + node_count = 0 + connection_count = 0 + group_count = 0 + + for item in selected_items: + if isinstance(item, (Node, RerouteNode)): + commands.append(DeleteNodeCommand(node_graph, item)) + node_count += 1 + elif isinstance(item, Connection): + from commands.connection_commands import DeleteConnectionCommand + commands.append(DeleteConnectionCommand(node_graph, item)) + connection_count += 1 + elif isinstance(item, Group): + from commands.delete_group_command import DeleteGroupCommand + commands.append(DeleteGroupCommand(node_graph, item)) + group_count += 1 + + # Generate description based on what's being deleted + description_parts = [] + if node_count > 0: + if node_count == 1: + # Try to get the node title for single node deletion + node_item = None + for item in selected_items: + if isinstance(item, (Node, RerouteNode)): + node_item = item + break + node_title = getattr(node_item, 'title', 'node') + description_parts.append(f"'{node_title}'") + else: + description_parts.append(f"{node_count} nodes") + + if group_count > 0: + if group_count == 1 and len(selected_items) == 1: + # Single group deletion + group_name = getattr(selected_items[0], 'name', 'group') + description_parts.append(f"group '{group_name}'") + else: + description_parts.append(f"{group_count} groups") + + if connection_count > 0: + if connection_count == 1 and len(selected_items) == 1: + description_parts.append("connection") + else: + description_parts.append(f"{connection_count} connections") + + if description_parts: + description = f"Delete {' and '.join(description_parts)}" + else: + description = f"Delete {len(selected_items)} items" + + super().__init__(description, commands) + + def get_memory_usage(self) -> int: + """Estimate memory usage for delete operation.""" + base_size = 512 + return base_size + super().get_memory_usage() \ No newline at end of file diff --git a/src/commands/node/property_changes.py b/src/commands/node/property_changes.py new file mode 100644 index 0000000..2efbc71 --- /dev/null +++ b/src/commands/node/property_changes.py @@ -0,0 +1,126 @@ +""" +Node property change commands: property and code modifications. + +Handles undo/redo for changes to node properties like code, size, colors, etc. +""" + +import sys +import os +from typing import Any + +# Add project root to path for cross-package imports +project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) +if project_root not in sys.path: + sys.path.insert(0, project_root) + +from commands.command_base import CommandBase + + +class PropertyChangeCommand(CommandBase): + """Command for node property changes.""" + + def __init__(self, node_graph, node, property_name: str, old_value: Any, new_value: Any): + """ + Initialize property change command. + + Args: + node_graph: The NodeGraph instance + node: Node whose property is changing + property_name: Name of the property + old_value: Original value + new_value: New value + """ + super().__init__(f"Change '{property_name}' of '{node.title}'") + self.node_graph = node_graph + self.node = node + self.property_name = property_name + self.old_value = old_value + self.new_value = new_value + + def execute(self) -> bool: + """Apply the property change.""" + try: + setattr(self.node, self.property_name, self.new_value) + + # Special handling for certain properties + if self.property_name in ['code', 'gui_code']: + self.node.update_pins_from_code() + elif self.property_name in ['width', 'height']: + self.node.fit_size_to_content() + + self._mark_executed() + return True + except Exception as e: + print(f"Failed to change property: {e}") + return False + + def undo(self) -> bool: + """Revert the property change.""" + try: + setattr(self.node, self.property_name, self.old_value) + + # Special handling for certain properties + if self.property_name in ['code', 'gui_code']: + self.node.update_pins_from_code() + elif self.property_name in ['width', 'height']: + self.node.fit_size_to_content() + + self._mark_undone() + return True + except Exception as e: + print(f"Failed to undo property change: {e}") + return False + + def get_memory_usage(self) -> int: + """Estimate memory usage of this command.""" + base_size = 256 + old_size = len(str(self.old_value)) * 2 if self.old_value else 0 + new_size = len(str(self.new_value)) * 2 if self.new_value else 0 + return base_size + old_size + new_size + + +class CodeChangeCommand(CommandBase): + """Command for tracking code changes in nodes.""" + + def __init__(self, node_graph, node, old_code: str, new_code: str): + """ + Initialize code change command. + + Args: + node_graph: The NodeGraph instance + node: Node whose code is changing + old_code: Original code + new_code: New code + """ + super().__init__(f"Change code in '{node.title}'") + self.node_graph = node_graph + self.node = node + self.old_code = old_code + self.new_code = new_code + + def execute(self) -> bool: + """Apply the code change.""" + try: + self.node.set_code(self.new_code) + self._mark_executed() + return True + except Exception as e: + print(f"Failed to change code: {e}") + return False + + def undo(self) -> bool: + """Revert the code change.""" + try: + self.node.set_code(self.old_code) + self._mark_undone() + return True + except Exception as e: + print(f"Failed to undo code change: {e}") + return False + + def get_memory_usage(self) -> int: + """Estimate memory usage for code changes.""" + base_size = 512 + old_code_size = len(self.old_code) * 2 + new_code_size = len(self.new_code) * 2 + return base_size + old_code_size + new_code_size \ No newline at end of file diff --git a/src/commands/node_commands.py b/src/commands/node_commands.py index efc29c4..9e9593c 100644 --- a/src/commands/node_commands.py +++ b/src/commands/node_commands.py @@ -1,951 +1,21 @@ """ Command implementations for node operations in PyFlowGraph. -Provides undoable commands for all node-related operations including -creation, deletion, movement, and property changes. +This module has been refactored into a package structure for better maintainability. +All original functionality is preserved through re-exports for backward compatibility. """ -import uuid -import sys -import os -from typing import Dict, Any, List, Optional -from PySide6.QtCore import QPointF - -# Add project root to path for cross-package imports -project_root = os.path.dirname(os.path.dirname(__file__)) -if project_root not in sys.path: - sys.path.insert(0, project_root) - -from .command_base import CommandBase, CompositeCommand - -# Debug configuration -# Set to True to enable detailed node command and connection restoration debugging -DEBUG_NODE_COMMANDS = False - - -class CreateNodeCommand(CommandBase): - """Command for creating nodes with full state preservation.""" - - def __init__(self, node_graph, title: str, position: QPointF, - node_id: str = None, code: str = "", description: str = ""): - """ - Initialize create node command. - - Args: - node_graph: The NodeGraph instance - title: Node title/type - position: Position to create node at - node_id: Optional specific node ID (for undo consistency) - code: Initial code for the node - description: Node description - """ - super().__init__(f"Create '{title}' node") - self.node_graph = node_graph - self.title = title - self.position = position - self.node_id = node_id or str(uuid.uuid4()) - self.code = code - self.node_description = description - self.created_node = None - - def execute(self) -> bool: - """Create the node and add to graph.""" - try: - # Import here to avoid circular imports - from core.node import Node - - # Create the node - self.created_node = Node(self.title) - self.created_node.uuid = self.node_id - self.created_node.description = self.node_description - self.created_node.setPos(self.position) - - if self.code: - self.created_node.code = self.code - self.created_node.update_pins_from_code() - - # Add to graph - self.node_graph.addItem(self.created_node) - self.node_graph.nodes.append(self.created_node) - - self._mark_executed() - return True - - except Exception as e: - print(f"Failed to create node: {e}") - return False - - def undo(self) -> bool: - """Remove the created node.""" - if not self.created_node or self.created_node not in self.node_graph.nodes: - return False - - try: - # Remove all connections to this node first - connections_to_remove = [] - for connection in list(self.node_graph.connections): - if (hasattr(connection, 'start_pin') and connection.start_pin.node == self.created_node or - hasattr(connection, 'end_pin') and connection.end_pin.node == self.created_node): - connections_to_remove.append(connection) - - for connection in connections_to_remove: - # Remove from connections list first - if connection in self.node_graph.connections: - self.node_graph.connections.remove(connection) - # Remove from scene if it's still there - if connection.scene() == self.node_graph: - self.node_graph.removeItem(connection) - - # Remove node from graph - if self.created_node in self.node_graph.nodes: - self.node_graph.nodes.remove(self.created_node) - if self.created_node.scene() == self.node_graph: - self.node_graph.removeItem(self.created_node) - - self._mark_undone() - return True - - except Exception as e: - print(f"Failed to undo node creation: {e}") - return False - - def get_memory_usage(self) -> int: - """Estimate memory usage of this command.""" - base_size = 512 - title_size = len(self.title) * 2 - code_size = len(self.code) * 2 - description_size = len(self.node_description) * 2 - return base_size + title_size + code_size + description_size - - -class DeleteNodeCommand(CommandBase): - """Command for deleting nodes with complete state preservation.""" - - def __init__(self, node_graph, node): - """ - Initialize delete node command. - - Args: - node_graph: The NodeGraph instance - node: Node to delete - """ - super().__init__(f"Delete '{node.title}' node") - self.node_graph = node_graph - self.node = node - self.node_state = None - self.affected_connections = [] - self.node_index = None - - def execute(self) -> bool: - """Delete node after preserving complete state.""" - try: - # Check if this node object is actually in the nodes list - found_in_list = False - node_in_list = None - for i, node in enumerate(self.node_graph.nodes): - if node is self.node: # Same object reference - found_in_list = True - node_in_list = node - self.node_index = i - break - elif hasattr(node, 'uuid') and hasattr(self.node, 'uuid') and node.uuid == self.node.uuid: - # Use the node that's actually in the list (UUID synchronization fix) - self.node = node - found_in_list = True - node_in_list = node - self.node_index = i - break - - if not found_in_list: - print(f"Error: Node '{getattr(self.node, 'title', 'Unknown')}' not found in graph") - return False - - # Preserve complete node state including colors and size - self.node_state = { - 'id': self.node.uuid, - 'title': self.node.title, - 'description': getattr(self.node, 'description', ''), - 'position': self.node.pos(), - 'code': getattr(self.node, 'code', ''), - 'gui_code': getattr(self.node, 'gui_code', ''), - 'gui_get_values_code': getattr(self.node, 'gui_get_values_code', ''), - 'function_name': getattr(self.node, 'function_name', None), - 'width': getattr(self.node, 'width', 150), - 'height': getattr(self.node, 'height', 150), - 'base_width': getattr(self.node, 'base_width', 150), - 'is_reroute': getattr(self.node, 'is_reroute', False), - # Preserve colors - 'color_title_bar': getattr(self.node, 'color_title_bar', None), - 'color_body': getattr(self.node, 'color_body', None), - 'color_title_text': getattr(self.node, 'color_title_text', None), - # Preserve GUI state - 'gui_state': {} - } - - # Try to capture GUI state if possible - try: - if hasattr(self.node, 'gui_widgets') and self.node.gui_widgets and self.node.gui_get_values_code: - scope = {"widgets": self.node.gui_widgets} - exec(self.node.gui_get_values_code, scope) - get_values_func = scope.get("get_values") - if callable(get_values_func): - self.node_state['gui_state'] = get_values_func(self.node.gui_widgets) - if DEBUG_NODE_COMMANDS: - print(f"DEBUG: Captured GUI state: {self.node_state['gui_state']}") - except Exception as e: - if DEBUG_NODE_COMMANDS: - print(f"DEBUG: Could not capture GUI state: {e}") - - # Check connections before removal - connections_to_node = [] - for connection in list(self.node_graph.connections): - if (hasattr(connection, 'start_pin') and connection.start_pin.node == self.node or - hasattr(connection, 'end_pin') and connection.end_pin.node == self.node): - connections_to_node.append(connection) - - # Preserve affected connections - self.affected_connections = [] - for connection in connections_to_node: - - # Handle different pin structures for regular nodes vs RerouteNodes - start_node = connection.start_pin.node - end_node = connection.end_pin.node - - # Get pin indices and names for more robust restoration - if hasattr(start_node, 'is_reroute') and start_node.is_reroute: - # RerouteNode - use single pins - output_pin_index = 0 if connection.start_pin == start_node.output_pin else -1 - output_pin_name = "output" - else: - # Regular Node - use pin lists and store both index and name - output_pin_index = self._get_pin_index(start_node.output_pins, connection.start_pin) - output_pin_name = connection.start_pin.name - - if hasattr(end_node, 'is_reroute') and end_node.is_reroute: - # RerouteNode - use single pins - input_pin_index = 0 if connection.end_pin == end_node.input_pin else -1 - input_pin_name = "input" - else: - # Regular Node - use pin lists and store both index and name - input_pin_index = self._get_pin_index(end_node.input_pins, connection.end_pin) - input_pin_name = connection.end_pin.name - - conn_data = { - 'connection': connection, - 'output_node_id': start_node.uuid, - 'output_pin_index': output_pin_index, - 'output_pin_name': output_pin_name, - 'input_node_id': end_node.uuid, - 'input_pin_index': input_pin_index, - 'input_pin_name': input_pin_name - } - self.affected_connections.append(conn_data) - - # Remove connection safely - if connection in self.node_graph.connections: - self.node_graph.connections.remove(connection) - - if connection.scene() == self.node_graph: - self.node_graph.removeItem(connection) - - # Remove node from graph safely - if self.node in self.node_graph.nodes: - self.node_graph.nodes.remove(self.node) - else: - print(f"Error: Node not in nodes list during removal") - return False - - if self.node.scene() == self.node_graph: - self.node_graph.removeItem(self.node) - else: - print(f"Error: Node not in scene or scene mismatch") - return False - - self._mark_executed() - return True - - except Exception as e: - print(f"Error: Failed to delete node: {e}") - return False - - def undo(self) -> bool: - """Restore node with complete state and reconnections.""" - if not self.node_state: - print(f"Error: No node state to restore") - return False - - try: - # Import here to avoid circular imports - from core.node import Node - from PySide6.QtGui import QColor - - # Use local debug configuration - debug_enabled = DEBUG_NODE_COMMANDS - - # Recreate node with preserved state - check if it was a RerouteNode - if self.node_state.get('is_reroute', False): - # Recreate as RerouteNode - from core.reroute_node import RerouteNode - restored_node = RerouteNode() - restored_node.uuid = self.node_state['id'] - restored_node.setPos(self.node_state['position']) - # RerouteNodes don't have most of the properties that regular nodes have - else: - # Recreate as regular Node - restored_node = Node(self.node_state['title']) - restored_node.uuid = self.node_state['id'] - restored_node.description = self.node_state['description'] - restored_node.setPos(self.node_state['position']) - # Set code which will trigger pin updates - restored_node.set_code(self.node_state['code']) - # Set GUI code which will trigger GUI rebuild - restored_node.set_gui_code(self.node_state['gui_code']) - restored_node.set_gui_get_values_code(self.node_state['gui_get_values_code']) - restored_node.function_name = self.node_state['function_name'] - - # Only apply regular node properties if it's not a RerouteNode - if not self.node_state.get('is_reroute', False): - if debug_enabled: - print(f"DEBUG: Restoring regular node properties for '{self.node_state['title']}'") - print(f"DEBUG: Original size: {self.node_state['width']}x{self.node_state['height']}") - # Restore size BEFORE updating pins (important for layout) - restored_node.width = self.node_state['width'] - restored_node.height = self.node_state['height'] - restored_node.base_width = self.node_state['base_width'] - - # Restore colors - if self.node_state['color_title_bar']: - if isinstance(self.node_state['color_title_bar'], str): - restored_node.color_title_bar = QColor(self.node_state['color_title_bar']) - else: - restored_node.color_title_bar = self.node_state['color_title_bar'] - - if self.node_state['color_body']: - if isinstance(self.node_state['color_body'], str): - restored_node.color_body = QColor(self.node_state['color_body']) - else: - restored_node.color_body = self.node_state['color_body'] - - if self.node_state['color_title_text']: - if isinstance(self.node_state['color_title_text'], str): - restored_node.color_title_text = QColor(self.node_state['color_title_text']) - else: - restored_node.color_title_text = self.node_state['color_title_text'] - - # Pins were already updated by set_code() above - if debug_enabled: - print(f"DEBUG: Pins already updated by set_code()") - - # Calculate minimum size requirements for validation - min_width, min_height = restored_node.calculate_absolute_minimum_size() - - # Validate restored size against minimum requirements - original_width = self.node_state['width'] - original_height = self.node_state['height'] - corrected_width = max(original_width, min_width) - corrected_height = max(original_height, min_height) - - if debug_enabled and (corrected_width != original_width or corrected_height != original_height): - print(f"DEBUG: Node restoration size corrected from " - f"{original_width}x{original_height} to {corrected_width}x{corrected_height}") - - # Apply validated size - restored_node.width = corrected_width - restored_node.height = corrected_height - restored_node.base_width = self.node_state['base_width'] - - if debug_enabled: - print(f"DEBUG: Node size set to {restored_node.width}x{restored_node.height}") - - # Force visual update with correct colors and size - restored_node.update() - - # Add back to graph at original position - if self.node_index is not None and self.node_index <= len(self.node_graph.nodes): - self.node_graph.nodes.insert(self.node_index, restored_node) - else: - self.node_graph.nodes.append(restored_node) - - self.node_graph.addItem(restored_node) - - # Apply GUI state AFTER GUI widgets are created - if self.node_state.get('gui_state') and not self.node_state.get('is_reroute', False): - try: - if debug_enabled: - print(f"DEBUG: Applying GUI state: {self.node_state['gui_state']}") - print(f"DEBUG: GUI widgets available: {bool(restored_node.gui_widgets)}") - print(f"DEBUG: GUI widgets count: {len(restored_node.gui_widgets) if restored_node.gui_widgets else 0}") - restored_node.apply_gui_state(self.node_state['gui_state']) - if debug_enabled: - print(f"DEBUG: GUI state applied successfully") - except Exception as e: - if debug_enabled: - print(f"DEBUG: GUI state restoration failed: {e}") - elif debug_enabled: - if not self.node_state.get('gui_state'): - print(f"DEBUG: No GUI state to restore") - elif self.node_state.get('is_reroute', False): - print(f"DEBUG: Skipping GUI state for reroute node") - - # Restore connections with improved error handling - restored_connections = 0 - failed_connections = 0 - for conn_data in self.affected_connections: - try: - # Find nodes by ID - output_node = self._find_node_by_id(conn_data['output_node_id']) - input_node = self._find_node_by_id(conn_data['input_node_id']) - - if not output_node or not input_node: - if debug_enabled: - print(f"DEBUG: Connection restoration failed - nodes not found (output: {output_node is not None}, input: {input_node is not None})") - failed_connections += 1 - continue - - # Get pins by index based on node type with proper validation - output_pin = None - input_pin = None - - # Handle output pin with robust fallback - output_pin = None - if hasattr(output_node, 'is_reroute') and output_node.is_reroute: - # RerouteNode - use single output pin - output_pin = output_node.output_pin - else: - # Regular Node - try pin index first, then fallback to name search - output_pin_index = conn_data['output_pin_index'] - output_pin_name = conn_data.get('output_pin_name', 'exec_out') - - if (hasattr(output_node, 'output_pins') and - output_node.output_pins and - 0 <= output_pin_index < len(output_node.output_pins)): - output_pin = output_node.output_pins[output_pin_index] - if debug_enabled: - print(f"DEBUG: Found output pin by index {output_pin_index}: '{output_pin.name}' on '{output_node.title}'") - else: - # Fallback: search by name - if debug_enabled: - print(f"DEBUG: Output pin index {output_pin_index} failed, searching by name '{output_pin_name}' on '{output_node.title}'") - output_pin = output_node.get_pin_by_name(output_pin_name) - if output_pin and debug_enabled: - print(f"DEBUG: Found output pin by name: '{output_pin.name}' on '{output_node.title}'") - - if not output_pin and debug_enabled: - print(f"DEBUG: Output pin not found by index {output_pin_index} or name '{output_pin_name}' on node {output_node.title}") - print(f"DEBUG: Available output pins: {[p.name for p in output_node.output_pins] if hasattr(output_node, 'output_pins') and output_node.output_pins else []}") - - # Handle input pin with robust fallback - input_pin = None - if hasattr(input_node, 'is_reroute') and input_node.is_reroute: - # RerouteNode - use single input pin - input_pin = input_node.input_pin - else: - # Regular Node - try pin index first, then fallback to name search - input_pin_index = conn_data['input_pin_index'] - input_pin_name = conn_data.get('input_pin_name', 'exec_in') - - if (hasattr(input_node, 'input_pins') and - input_node.input_pins and - 0 <= input_pin_index < len(input_node.input_pins)): - input_pin = input_node.input_pins[input_pin_index] - if debug_enabled: - print(f"DEBUG: Found input pin by index {input_pin_index}: '{input_pin.name}' on '{input_node.title}'") - else: - # Fallback: search by name - if debug_enabled: - print(f"DEBUG: Input pin index {input_pin_index} failed, searching by name '{input_pin_name}' on '{input_node.title}'") - input_pin = input_node.get_pin_by_name(input_pin_name) - if input_pin and debug_enabled: - print(f"DEBUG: Found input pin by name: '{input_pin.name}' on '{input_node.title}'") - - if not input_pin and debug_enabled: - print(f"DEBUG: Input pin not found by index {input_pin_index} or name '{input_pin_name}' on node {input_node.title}") - print(f"DEBUG: Available input pins: {[p.name for p in input_node.input_pins] if hasattr(input_node, 'input_pins') and input_node.input_pins else []}") - - # Validate pins exist - if not output_pin or not input_pin: - if debug_enabled: - print(f"DEBUG: Connection restoration failed - pins not found (output: {output_pin is not None}, input: {input_pin is not None})") - failed_connections += 1 - continue - - # Check if connection already exists to avoid duplicates - connection_exists = False - for existing_conn in self.node_graph.connections: - if (hasattr(existing_conn, 'start_pin') and existing_conn.start_pin == output_pin and - hasattr(existing_conn, 'end_pin') and existing_conn.end_pin == input_pin): - connection_exists = True - break - - if connection_exists: - if debug_enabled: - print(f"DEBUG: Connection already exists, skipping restoration") - continue - - # Recreate connection - from core.connection import Connection - new_connection = Connection(output_pin, input_pin) - self.node_graph.addItem(new_connection) - self.node_graph.connections.append(new_connection) - - # Note: Connection constructor automatically adds itself to pin connection lists - # No need to manually call add_connection as it would create duplicates - - restored_connections += 1 - - if debug_enabled: - print(f"DEBUG: Connection restored successfully between {output_node.title}.{output_pin.name} and {input_node.title}.{input_pin.name}") - print(f"DEBUG: Pin details - Output pin category: {output_pin.pin_category}, Input pin category: {input_pin.pin_category}") - print(f"DEBUG: Connection added to graph connections (total: {len(self.node_graph.connections)})") - print(f"DEBUG: Output pin connections: {len(output_pin.connections)}, Input pin connections: {len(input_pin.connections)}") - - except Exception as e: - if debug_enabled: - print(f"DEBUG: Connection restoration failed with exception: {e}") - failed_connections += 1 - continue - - if debug_enabled: - print(f"DEBUG: Connection restoration summary: {restored_connections} restored, {failed_connections} failed") - - # Final layout update sequence (only for regular nodes) - if not self.node_state.get('is_reroute', False): - if debug_enabled: - print(f"DEBUG: Final layout update sequence") - - # Force layout update to ensure pins are positioned correctly - restored_node._update_layout() - - # Ensure size still meets minimum requirements after GUI state - restored_node.fit_size_to_content() - - if debug_enabled: - print(f"DEBUG: Final node size: {restored_node.width}x{restored_node.height}") - - # Final visual refresh - restored_node.update() - - # Update node reference - self.node = restored_node - - if debug_enabled: - print(f"DEBUG: Node restoration completed successfully") - self._mark_undone() - return True - - except Exception as e: - print(f"Error: Failed to undo node deletion: {e}") - return False - - def _find_node_by_id(self, node_id: str): - """Find node in graph by UUID.""" - for node in self.node_graph.nodes: - if node.uuid == node_id: - return node - return None - - def _get_pin_index(self, pin_list, pin): - """Safely get pin index.""" - try: - return pin_list.index(pin) - except (ValueError, AttributeError): - return 0 - - def get_memory_usage(self) -> int: - """Estimate memory usage of this command.""" - if not self.node_state: - return 512 - - base_size = 1024 - code_size = len(self.node_state.get('code', '')) * 2 - gui_code_size = len(self.node_state.get('gui_code', '')) * 2 - title_size = len(self.node_state.get('title', '')) * 2 - connections_size = len(self.affected_connections) * 200 - - return base_size + code_size + gui_code_size + title_size + connections_size - - -class MoveNodeCommand(CommandBase): - """Command for moving nodes with position tracking.""" - - def __init__(self, node_graph, node, old_position: QPointF, new_position: QPointF): - """ - Initialize move node command. - - Args: - node_graph: The NodeGraph instance - node: Node to move - old_position: Original position - new_position: New position - """ - super().__init__(f"Move '{node.title}' node") - self.node_graph = node_graph - self.node = node - self.old_position = old_position - self.new_position = new_position - - def execute(self) -> bool: - """Move node to new position.""" - try: - self.node.setPos(self.new_position) - self._mark_executed() - return True - except Exception as e: - print(f"Failed to move node: {e}") - return False - - def undo(self) -> bool: - """Move node back to original position.""" - try: - self.node.setPos(self.old_position) - self._mark_undone() - return True - except Exception as e: - print(f"Failed to undo node move: {e}") - return False - - def can_merge_with(self, other: CommandBase) -> bool: - """Check if this move can be merged with another move.""" - return (isinstance(other, MoveNodeCommand) and - other.node == self.node and - abs(other.timestamp - self.timestamp) < 1.0) # Within 1 second - - def merge_with(self, other: CommandBase) -> Optional[CommandBase]: - """Merge with another move command.""" - if not self.can_merge_with(other): - return None - - # Create merged command using original start position and latest end position - return MoveNodeCommand( - self.node_graph, - self.node, - self.old_position, - other.new_position - ) - - -class PropertyChangeCommand(CommandBase): - """Command for node property changes.""" - - def __init__(self, node_graph, node, property_name: str, old_value: Any, new_value: Any): - """ - Initialize property change command. - - Args: - node_graph: The NodeGraph instance - node: Node whose property is changing - property_name: Name of the property - old_value: Original value - new_value: New value - """ - super().__init__(f"Change '{property_name}' of '{node.title}'") - self.node_graph = node_graph - self.node = node - self.property_name = property_name - self.old_value = old_value - self.new_value = new_value - - def execute(self) -> bool: - """Apply the property change.""" - try: - setattr(self.node, self.property_name, self.new_value) - - # Special handling for certain properties - if self.property_name in ['code', 'gui_code']: - self.node.update_pins_from_code() - elif self.property_name in ['width', 'height']: - self.node.fit_size_to_content() - - self._mark_executed() - return True - except Exception as e: - print(f"Failed to change property: {e}") - return False - - def undo(self) -> bool: - """Revert the property change.""" - try: - setattr(self.node, self.property_name, self.old_value) - - # Special handling for certain properties - if self.property_name in ['code', 'gui_code']: - self.node.update_pins_from_code() - elif self.property_name in ['width', 'height']: - self.node.fit_size_to_content() - - self._mark_undone() - return True - except Exception as e: - print(f"Failed to undo property change: {e}") - return False - - def get_memory_usage(self) -> int: - """Estimate memory usage of this command.""" - base_size = 256 - old_size = len(str(self.old_value)) * 2 if self.old_value else 0 - new_size = len(str(self.new_value)) * 2 if self.new_value else 0 - return base_size + old_size + new_size - - -class CodeChangeCommand(CommandBase): - """Command for tracking code changes in nodes.""" - - def __init__(self, node_graph, node, old_code: str, new_code: str): - """ - Initialize code change command. - - Args: - node_graph: The NodeGraph instance - node: Node whose code is changing - old_code: Original code - new_code: New code - """ - super().__init__(f"Change code in '{node.title}'") - self.node_graph = node_graph - self.node = node - self.old_code = old_code - self.new_code = new_code - - def execute(self) -> bool: - """Apply the code change.""" - try: - self.node.set_code(self.new_code) - self._mark_executed() - return True - except Exception as e: - print(f"Failed to change code: {e}") - return False - - def undo(self) -> bool: - """Revert the code change.""" - try: - self.node.set_code(self.old_code) - self._mark_undone() - return True - except Exception as e: - print(f"Failed to undo code change: {e}") - return False - - def get_memory_usage(self) -> int: - """Estimate memory usage for code changes.""" - base_size = 512 - old_code_size = len(self.old_code) * 2 - new_code_size = len(self.new_code) * 2 - return base_size + old_code_size + new_code_size - - -class PasteNodesCommand(CompositeCommand): - """Command for pasting nodes and connections as a single undo unit.""" - - def __init__(self, node_graph, clipboard_data: Dict[str, Any], paste_position: QPointF): - """ - Initialize paste nodes command. - - Args: - node_graph: The NodeGraph instance - clipboard_data: Data from clipboard containing nodes and connections - paste_position: Position to paste nodes at - """ - # Parse clipboard data to determine operation description - node_count = len(clipboard_data.get('nodes', [])) - connection_count = len(clipboard_data.get('connections', [])) - - if node_count == 1 and connection_count == 0: - description = f"Paste '{clipboard_data['nodes'][0].get('title', 'node')}'" - elif node_count > 1 and connection_count == 0: - description = f"Paste {node_count} nodes" - elif node_count == 1 and connection_count > 0: - description = f"Paste '{clipboard_data['nodes'][0].get('title', 'node')}' with {connection_count} connections" - else: - description = f"Paste {node_count} nodes with {connection_count} connections" - - # Store data for execute method to handle connection creation - self.node_graph = node_graph - self.clipboard_data = clipboard_data - self.paste_position = paste_position - self.uuid_mapping = {} # Map old UUIDs to new UUIDs - self.created_nodes = [] - - # Create only node creation commands initially - commands = [] - nodes_data = clipboard_data.get('nodes', []) - for i, node_data in enumerate(nodes_data): - # Generate new UUID for this node - old_uuid = node_data.get('id', str(uuid.uuid4())) - new_uuid = str(uuid.uuid4()) - self.uuid_mapping[old_uuid] = new_uuid - - # Calculate offset position for multiple nodes - offset_x = (i % 3) * 200 # Arrange in grid pattern - offset_y = (i // 3) * 150 - node_position = QPointF( - paste_position.x() + offset_x, - paste_position.y() + offset_y - ) - - # Create node command - create_cmd = CreateNodeCommand( - node_graph=node_graph, - title=node_data.get('title', 'Pasted Node'), - position=node_position, - node_id=new_uuid, - code=node_data.get('code', ''), - description=node_data.get('description', '') - ) - commands.append(create_cmd) - self.created_nodes.append((create_cmd, node_data)) - - super().__init__(description, commands) - - def execute(self) -> bool: - """Execute node creation first, then create connections.""" - # First execute node creation commands - if not super().execute(): - return False - - # Now create connections using actual pin objects - connections_data = self.clipboard_data.get('connections', []) - connection_commands = [] - - for conn_data in connections_data: - # Map old UUIDs to new UUIDs - old_output_node_id = conn_data.get('output_node_id', '') - old_input_node_id = conn_data.get('input_node_id', '') - - new_output_node_id = self.uuid_mapping.get(old_output_node_id) - new_input_node_id = self.uuid_mapping.get(old_input_node_id) - - # Only create connection if both nodes are being pasted - if new_output_node_id and new_input_node_id: - # Find the actual created nodes - output_node = self._find_node_by_id(new_output_node_id) - input_node = self._find_node_by_id(new_input_node_id) - - if output_node and input_node: - # Find pins by name - output_pin_name = conn_data.get('output_pin_name', '') - input_pin_name = conn_data.get('input_pin_name', '') - - output_pin = output_node.get_pin_by_name(output_pin_name) - input_pin = input_node.get_pin_by_name(input_pin_name) - - if output_pin and input_pin: - # Import here to avoid circular imports - from .connection_commands import CreateConnectionCommand - - conn_cmd = CreateConnectionCommand( - node_graph=self.node_graph, - output_pin=output_pin, - input_pin=input_pin - ) - connection_commands.append(conn_cmd) - - # Execute connection commands - for conn_cmd in connection_commands: - result = conn_cmd.execute() - if result: - conn_cmd._mark_executed() - self.commands.append(conn_cmd) - self.executed_commands.append(conn_cmd) - else: - print(f"Failed to create connection: {conn_cmd.get_description()}") - - return True - - def _find_node_by_id(self, node_id: str): - """Find node in graph by UUID.""" - for node in self.node_graph.nodes: - if hasattr(node, 'uuid') and node.uuid == node_id: - return node - return None - - def get_memory_usage(self) -> int: - """Estimate memory usage for paste operation.""" - base_size = 1024 - data_size = len(str(self.clipboard_data)) * 2 - mapping_size = len(self.uuid_mapping) * 100 - return base_size + data_size + mapping_size - - -class MoveMultipleCommand(CompositeCommand): - """Command for moving multiple nodes as a single undo unit.""" - - def __init__(self, node_graph, nodes_and_positions: List[tuple]): - """ - Initialize move multiple command. - - Args: - node_graph: The NodeGraph instance - nodes_and_positions: List of (node, old_pos, new_pos) tuples - """ - # Create individual move commands - commands = [] - node_count = len(nodes_and_positions) - - if node_count == 1: - node = nodes_and_positions[0][0] - description = f"Move '{node.title}'" - else: - description = f"Move {node_count} nodes" - - for node, old_pos, new_pos in nodes_and_positions: - move_cmd = MoveNodeCommand(node_graph, node, old_pos, new_pos) - commands.append(move_cmd) - - super().__init__(description, commands) - - def get_memory_usage(self) -> int: - """Estimate memory usage for move operation.""" - base_size = 256 - return base_size + super().get_memory_usage() - - -class DeleteMultipleCommand(CompositeCommand): - """Command for deleting multiple items as a single undo unit.""" - - def __init__(self, node_graph, selected_items: List): - """ - Initialize delete multiple command. - - Args: - node_graph: The NodeGraph instance - selected_items: List of items (nodes and connections) to delete - """ - # Import here to avoid circular imports - from core.node import Node - from core.reroute_node import RerouteNode - from core.connection import Connection - - # Create individual delete commands - commands = [] - node_count = 0 - connection_count = 0 - - for item in selected_items: - if isinstance(item, (Node, RerouteNode)): - commands.append(DeleteNodeCommand(node_graph, item)) - node_count += 1 - elif isinstance(item, Connection): - from .connection_commands import DeleteConnectionCommand - commands.append(DeleteConnectionCommand(node_graph, item)) - connection_count += 1 - - # Generate description - if node_count > 0 and connection_count > 0: - description = f"Delete {node_count} nodes and {connection_count} connections" - elif node_count > 1: - description = f"Delete {node_count} nodes" - elif node_count == 1: - node_title = getattr(selected_items[0], 'title', 'node') - description = f"Delete '{node_title}'" - elif connection_count > 1: - description = f"Delete {connection_count} connections" - else: - description = f"Delete {len(selected_items)} items" - - super().__init__(description, commands) - - def get_memory_usage(self) -> int: - """Estimate memory usage for delete operation.""" - base_size = 512 - return base_size + super().get_memory_usage() \ No newline at end of file +# Re-export all node commands from the new package structure +from .node import * + +# Maintain backward compatibility by keeping the same interface +__all__ = [ + 'CreateNodeCommand', + 'DeleteNodeCommand', + 'MoveNodeCommand', + 'PropertyChangeCommand', + 'CodeChangeCommand', + 'PasteNodesCommand', + 'MoveMultipleCommand', + 'DeleteMultipleCommand' +] \ No newline at end of file diff --git a/src/core/group.py b/src/core/group.py index c63a8df..8e2a926 100644 --- a/src/core/group.py +++ b/src/core/group.py @@ -252,6 +252,10 @@ def _update_membership_after_resize(self): def itemChange(self, change, value): """Handle item changes, particularly position changes to move member nodes.""" if change == QGraphicsItem.ItemPositionChange and self.scene() and not self.is_resizing: + # Skip automatic node movement during paste operations to prevent double transformation + if hasattr(self.scene(), '_is_pasting') and self.scene()._is_pasting: + return super().itemChange(change, value) + # Only move member nodes during group movement, not during resize # Calculate the delta movement old_pos = self.pos() @@ -507,6 +511,55 @@ def _draw_resize_handles(self, painter: QPainter): for x, y in handles: handle_rect = QRectF(x - handle_size/2, y - handle_size/2, handle_size, handle_size) painter.drawRect(handle_rect) + + + def contextMenuEvent(self, event): + """Handle right-click context menu events.""" + try: + from PySide6.QtWidgets import QMenu + + # Create context menu + menu = QMenu() + properties_action = menu.addAction("Properties") + + # Execute menu and handle selection + action = menu.exec(event.screenPos()) + if action == properties_action: + self.show_properties_dialog() + + except Exception as e: + print(f"Error showing group context menu: {e}") + + def show_properties_dialog(self): + """Show the group properties dialog.""" + try: + # Get the main window as parent + parent = None + if self.scene() and self.scene().views(): + parent = self.scene().views()[0].window() + + # Import and show properties dialog + from ui.dialogs.group_properties_dialog import show_group_properties_dialog + + changed_properties = show_group_properties_dialog(self, parent) + + if changed_properties: + # Apply changes using command pattern for undo/redo + if self.scene() and hasattr(self.scene(), 'execute_command'): + from commands.group_property_command import GroupPropertyChangeCommand + + command = GroupPropertyChangeCommand(self.scene(), self, changed_properties) + success = self.scene().execute_command(command) + + if success: + print(f"Updated properties for group '{self.name}'") + else: + print(f"Failed to update properties for group '{self.name}'") + else: + print("Warning: No command system available, changes not applied") + + except Exception as e: + print(f"Error showing group properties dialog: {e}") def serialize(self) -> Dict[str, Any]: """Serialize group data for persistence""" @@ -514,12 +567,43 @@ def serialize(self) -> Dict[str, Any]: "uuid": self.uuid, "name": self.name, "description": self.description, - "creation_timestamp": self.creation_timestamp, "member_node_uuids": self.member_node_uuids, "is_expanded": self.is_expanded, "position": {"x": self.pos().x(), "y": self.pos().y()}, "size": {"width": self.width, "height": self.height}, - "padding": self.padding + "padding": self.padding, + "colors": { + "background": { + "r": self.color_background.red(), + "g": self.color_background.green(), + "b": self.color_background.blue(), + "a": self.color_background.alpha() + }, + "border": { + "r": self.color_border.red(), + "g": self.color_border.green(), + "b": self.color_border.blue(), + "a": self.color_border.alpha() + }, + "title_bg": { + "r": self.color_title_bg.red(), + "g": self.color_title_bg.green(), + "b": self.color_title_bg.blue(), + "a": self.color_title_bg.alpha() + }, + "title_text": { + "r": self.color_title_text.red(), + "g": self.color_title_text.green(), + "b": self.color_title_text.blue(), + "a": self.color_title_text.alpha() + }, + "selection": { + "r": self.color_selection.red(), + "g": self.color_selection.green(), + "b": self.color_selection.blue(), + "a": self.color_selection.alpha() + } + } } @classmethod @@ -533,7 +617,6 @@ def deserialize(cls, data: Dict[str, Any]) -> 'Group': # Restore properties group.uuid = data.get("uuid", str(uuid.uuid4())) group.description = data.get("description", "") - group.creation_timestamp = data.get("creation_timestamp", "") group.is_expanded = data.get("is_expanded", True) # Restore position and size @@ -547,6 +630,33 @@ def deserialize(cls, data: Dict[str, Any]) -> 'Group': group.padding = data.get("padding", 20.0) + # Restore colors if present + colors = data.get("colors", {}) + if colors: + if "background" in colors: + bg = colors["background"] + group.color_background = QColor(bg["r"], bg["g"], bg["b"], bg["a"]) + group.brush_background = QBrush(group.color_background) + + if "border" in colors: + border = colors["border"] + group.color_border = QColor(border["r"], border["g"], border["b"], border["a"]) + group.pen_border = QPen(group.color_border, 2.0) + + if "title_bg" in colors: + title_bg = colors["title_bg"] + group.color_title_bg = QColor(title_bg["r"], title_bg["g"], title_bg["b"], title_bg["a"]) + group.brush_title = QBrush(group.color_title_bg) + + if "title_text" in colors: + title_text = colors["title_text"] + group.color_title_text = QColor(title_text["r"], title_text["g"], title_text["b"], title_text["a"]) + + if "selection" in colors: + selection = colors["selection"] + group.color_selection = QColor(selection["r"], selection["g"], selection["b"], selection["a"]) + group.pen_selected = QPen(group.color_selection, 3.0) + return group diff --git a/src/core/node_graph.py b/src/core/node_graph.py index 32fc9cf..4242d3f 100644 --- a/src/core/node_graph.py +++ b/src/core/node_graph.py @@ -37,13 +37,15 @@ def __init__(self, parent=None): self.setBackgroundBrush(Qt.darkGray) self.setSceneRect(-10000, -10000, 20000, 20000) self.nodes, self.connections = [], [] + self.groups = [] # Initialize groups list self._drag_connection, self._drag_start_pin = None, None self.graph_title = "Untitled Graph" self.graph_description = "" + self._is_pasting = False # Flag to prevent Group.itemChange during paste operations # Command system integration self.command_history = CommandHistory() - self._tracking_moves = {} # Track node movements for command batching + self._tracking_moves = {} # Track node movements for command batching # Track node movements for command batching # Track node movements for command batching def get_node_by_id(self, node_id): """Find node by UUID - helper for command restoration.""" @@ -92,7 +94,7 @@ def get_redo_description(self): return self.command_history.get_redo_description() def clear_graph(self): - """Removes all nodes and connections from the scene.""" + """Removes all nodes, connections, and groups from the scene.""" # Remove all connections first for connection in list(self.connections): self.remove_connection(connection, use_command=False) @@ -101,6 +103,12 @@ def clear_graph(self): for node in list(self.nodes): self.remove_node(node, use_command=False) + # Remove all groups + if hasattr(self, 'groups'): + for group in list(self.groups): + self.removeItem(group) + self.groups.clear() + self.update() def keyPressEvent(self, event: QKeyEvent): @@ -137,14 +145,13 @@ def keyPressEvent(self, event: QKeyEvent): if selected_items: commands = [] - # Create delete commands for selected items - for item in selected_items: - if isinstance(item, (Node, RerouteNode)): - print(f"DEBUG: Creating DeleteNodeCommand for {getattr(item, 'title', 'Unknown')} (ID: {id(item)})") - commands.append(DeleteNodeCommand(self, item)) - elif isinstance(item, Connection): - print(f"DEBUG: Creating DeleteConnectionCommand for connection {id(item)}") - commands.append(DeleteConnectionCommand(self, item)) + # Use the improved DeleteMultipleCommand that handles all item types including Groups + from commands.node.batch_operations import DeleteMultipleCommand + delete_cmd = DeleteMultipleCommand(self, selected_items) + print(f"DEBUG: Using DeleteMultipleCommand: {delete_cmd.get_description()}") + result = self.execute_command(delete_cmd) + print(f"DEBUG: DeleteMultipleCommand returned: {result}") + return print(f"DEBUG: Created {len(commands)} delete commands") @@ -208,14 +215,18 @@ def _create_group_from_selection(self, selected_nodes): self.execute_command(command) def copy_selected(self): - """Copies selected nodes, their connections, and the graph's requirements to the clipboard.""" + """Copies selected nodes, their connections, and groups to the clipboard.""" selected_nodes = [item for item in self.selectedItems() if isinstance(item, (Node, RerouteNode))] - if not selected_nodes: - return {"requirements": [], "nodes": [], "connections": []} + selected_groups = [item for item in self.selectedItems() if type(item).__name__ == 'Group'] + + if not selected_nodes and not selected_groups: + return {"requirements": [], "nodes": [], "groups": [], "connections": []} nodes_data = [node.serialize() for node in selected_nodes] + groups_data = [group.serialize() for group in selected_groups] connections_data = [] selected_node_uuids = {node.uuid for node in selected_nodes} + for conn in self.connections: if hasattr(conn.start_pin.node, "uuid") and hasattr(conn.end_pin.node, "uuid") and conn.start_pin.node.uuid in selected_node_uuids and conn.end_pin.node.uuid in selected_node_uuids: connections_data.append(conn.serialize()) @@ -227,31 +238,42 @@ def copy_selected(self): main_window = views[0].window() requirements = main_window.current_requirements if hasattr(main_window, "current_requirements") else [] - clipboard_data = {"requirements": requirements, "nodes": nodes_data, "connections": connections_data} + clipboard_data = { + "requirements": requirements, + "nodes": nodes_data, + "groups": groups_data, + "connections": connections_data + } # Convert to markdown format for clipboard try: from data.flow_format import FlowFormatHandler handler = FlowFormatHandler() - clipboard_markdown = handler.data_to_markdown(clipboard_data, "Clipboard Content", "Copied nodes from PyFlowGraph") + clipboard_markdown = handler.data_to_markdown(clipboard_data, "Clipboard Content", "Copied nodes and groups from PyFlowGraph") QApplication.clipboard().setText(clipboard_markdown) except ImportError: # Fallback to JSON format if FlowFormatHandler is not available (e.g., during testing) import json QApplication.clipboard().setText(json.dumps(clipboard_data, indent=2)) - print(f"Copied {len(nodes_data)} nodes to clipboard as markdown.") + + total_items = len(nodes_data) + len(groups_data) + print(f"Copied {len(nodes_data)} nodes and {len(groups_data)} groups ({total_items} items total) to clipboard as markdown.") return clipboard_data - def paste(self): - """Pastes nodes and connections from the clipboard using command pattern.""" + def paste(self, mouse_position=None): + """Pastes nodes and connections from clipboard at mouse position or viewport center.""" clipboard_text = QApplication.clipboard().text() - # Determine paste position - paste_pos = QPointF(0, 0) # Default position - views = self.views() - if views: - paste_pos = views[0].mapToScene(views[0].viewport().rect().center()) + # Use mouse position if provided, otherwise fall back to viewport center + if mouse_position is not None: + paste_pos = mouse_position + else: + # Existing fallback logic + paste_pos = QPointF(0, 0) # Default position + views = self.views() + if views: + paste_pos = views[0].mapToScene(views[0].viewport().rect().center()) try: # Try to parse as markdown first @@ -285,32 +307,51 @@ def _paste_with_command(self, data, paste_pos): # Convert data format to match PasteNodesCommand expectations clipboard_data = self._convert_data_format(data) - # Create and execute paste command - from commands.node_commands import PasteNodesCommand - paste_cmd = PasteNodesCommand(self, clipboard_data, paste_pos) - result = self.execute_command(paste_cmd) + # Set paste operation flag to prevent Group.itemChange from moving nodes automatically + self._is_pasting = True - if not result: - print("Failed to paste nodes.") + try: + # Create and execute paste command + from commands.node_commands import PasteNodesCommand + paste_cmd = PasteNodesCommand(self, clipboard_data, paste_pos) + result = self.execute_command(paste_cmd) + + if not result: + print("Failed to paste nodes.") + finally: + # Always clear the paste flag + self._is_pasting = False def _convert_data_format(self, data): """Convert deserialize format to PasteNodesCommand format.""" clipboard_data = { 'nodes': [], + 'groups': [], 'connections': [] } - # Convert nodes + # Convert nodes - preserve ALL properties for node_data in data.get('nodes', []): converted_node = { - 'id': node_data.get('uuid', ''), + 'id': node_data.get('uuid', node_data.get('id', '')), # Try 'uuid' first, then 'id' 'title': node_data.get('title', 'Unknown'), 'description': node_data.get('description', ''), 'code': node_data.get('code', ''), - 'pos': node_data.get('pos', [0, 0]) + 'pos': node_data.get('pos', [0, 0]), + 'size': node_data.get('size', [200, 150]), + 'colors': node_data.get('colors', {}), + 'gui_state': node_data.get('gui_state', {}), + 'gui_code': node_data.get('gui_code', ''), + 'gui_get_values_code': node_data.get('gui_get_values_code', ''), + 'is_reroute': node_data.get('is_reroute', False) } clipboard_data['nodes'].append(converted_node) + # Convert groups + for group_data in data.get('groups', []): + # Groups use their full serialized data for pasting + clipboard_data['groups'].append(group_data) + # Convert connections for conn_data in data.get('connections', []): converted_conn = { @@ -324,18 +365,20 @@ def _convert_data_format(self, data): return clipboard_data def serialize(self): - """Serializes all nodes and their connections.""" + """Serializes all nodes, connections, and groups.""" nodes_data = [node.serialize() for node in self.nodes] connections_data = [conn.serialize() for conn in self.connections if conn.serialize()] + groups_data = [group.serialize() for group in self.groups] if hasattr(self, 'groups') else [] return { "graph_title": self.graph_title, "graph_description": self.graph_description, "nodes": nodes_data, - "connections": connections_data + "connections": connections_data, + "groups": groups_data } def deserialize(self, data, offset=QPointF(0, 0)): - """Deserializes graph data, creating all nodes and applying custom properties.""" + """Deserializes graph data, creating all nodes, connections, and groups.""" if not data: return if offset == QPointF(0, 0): @@ -347,6 +390,7 @@ def deserialize(self, data, offset=QPointF(0, 0)): uuid_to_node_map = {} nodes_to_update = [] + # First, deserialize nodes for node_data in data.get("nodes", []): original_pos = QPointF(node_data["pos"][0], node_data["pos"][1]) new_pos = original_pos + offset @@ -401,6 +445,7 @@ def deserialize(self, data, offset=QPointF(0, 0)): uuid_to_node_map[old_uuid] = node + # Then, deserialize connections for conn_data in data.get("connections", []): start_node = uuid_to_node_map.get(conn_data["start_node_uuid"]) end_node = uuid_to_node_map.get(conn_data["end_node_uuid"]) @@ -410,6 +455,29 @@ def deserialize(self, data, offset=QPointF(0, 0)): if start_pin and end_pin: self.create_connection(start_pin, end_pin, use_command=False) + # Finally, deserialize groups (only for complete graph loading, not copy/paste) + if offset == QPointF(0, 0) and "groups" in data: + try: + # Import Group class for deserialization + from core.group import Group + + for group_data in data.get("groups", []): + # Deserialize the group + group = Group.deserialize(group_data) + + # Add to scene and groups list + self.addItem(group) + if not hasattr(self, 'groups'): + self.groups = [] + self.groups.append(group) + + print(f"Restored group '{group.name}' with {len(group.member_node_uuids)} members") + + except ImportError as e: + print(f"Warning: Could not import Group class for deserialization: {e}") + except Exception as e: + print(f"Warning: Failed to deserialize groups: {e}") + # --- Definitive Resizing Fix --- # Defer the final layout calculation. This allows the Qt event loop to # process all pending widget creation and resizing events first, ensuring diff --git a/src/data/flow_format.py b/src/data/flow_format.py index 7873b4d..ff76720 100644 --- a/src/data/flow_format.py +++ b/src/data/flow_format.py @@ -14,7 +14,7 @@ def __init__(self): self.md = MarkdownIt() def data_to_markdown(self, graph_data: Dict[str, Any], title: str = "Untitled Graph", - description: str = "") -> str: + description: str = "") -> str: """Convert graph data to .md markdown format.""" flow_content = f"# {title}\n\n" @@ -26,6 +26,14 @@ def data_to_markdown(self, graph_data: Dict[str, Any], title: str = "Untitled Gr flow_content += self._node_to_flow(node) flow_content += "\n" + # Add groups (if any) + groups = graph_data.get("groups", []) + if groups: + flow_content += "## Groups\n\n" + flow_content += "```json\n" + flow_content += json.dumps(groups, indent=2) + flow_content += "\n```\n\n" + # Add connections flow_content += "## Connections\n\n" flow_content += "```json\n" @@ -103,6 +111,7 @@ def markdown_to_data(self, flow_content: str) -> Dict[str, Any]: "graph_title": "Untitled Graph", "graph_description": "", "nodes": [], + "groups": [], "connections": [], "requirements": [] } @@ -146,6 +155,9 @@ def markdown_to_data(self, flow_content: str) -> Dict[str, Any]: if heading_text == "Connections": current_section = "connections" current_node = None + elif heading_text == "Groups": + current_section = "groups" + current_node = None else: # Node header: "Node: Title (ID: uuid)" match = re.match(r"Node:\s*(.*?)\s*\(ID:\s*(.*?)\)", heading_text) @@ -187,6 +199,12 @@ def markdown_to_data(self, flow_content: str) -> Dict[str, Any]: except json.JSONDecodeError: pass # Skip invalid JSON + elif current_section == "groups" and language == "json": + try: + graph_data["groups"] = json.loads(content) + except json.JSONDecodeError: + pass # Skip invalid JSON + elif current_section == "node" and current_node is not None: if current_component == "metadata" and language == "json": try: diff --git a/src/ui/dialogs/group_properties_dialog.py b/src/ui/dialogs/group_properties_dialog.py new file mode 100644 index 0000000..77db092 --- /dev/null +++ b/src/ui/dialogs/group_properties_dialog.py @@ -0,0 +1,587 @@ +""" +Group Properties Dialog + +Dialog for editing group properties including name, description, colors, and size. +Allows users to customize existing groups with undo/redo support. +""" + +import sys +import os +from typing import Dict, Any, Optional +from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, + QLineEdit, QTextEdit, QLabel, QSpinBox, QCheckBox, + QPushButton, QDialogButtonBox, QMessageBox, QFrame, + QColorDialog, QGroupBox, QGridLayout) +from PySide6.QtCore import Qt, QSize, QPointF, QRectF +from PySide6.QtGui import QFont, QColor, QPainter, QPen, QBrush + +# Add project root to path for cross-package imports +project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) +if project_root not in sys.path: + sys.path.insert(0, project_root) + + +class ColorButton(QPushButton): + """Custom button that displays a color with transparency support and opens color picker when clicked.""" + + def __init__(self, initial_color: QColor, parent=None): + super().__init__(parent) + self.color = QColor(initial_color) if isinstance(initial_color, QColor) else QColor(initial_color) + self.setFixedSize(80, 30) # Slightly wider to show transparency better + self.clicked.connect(self._pick_color) + self._update_style() + + def _update_style(self): + """Update button style to show current color with transparency support.""" + r, g, b, a = self.color.getRgb() + + # Use simple background color - Qt will handle transparency properly + rgba_color = f"rgba({r}, {g}, {b}, {a/255.0})" + + self.setStyleSheet(f""" + QPushButton {{ + background-color: {rgba_color}; + border: 2px solid #555; + border-radius: 4px; + }} + QPushButton:hover {{ + border-color: #888; + }} + """) + + # Set tooltip to show RGBA values + self.setToolTip(f"RGBA({r}, {g}, {b}, {a})") + + def _pick_color(self): + """Open color picker dialog with alpha support.""" + # Use QColorDialog with alpha option + dialog = QColorDialog(self.color, self) + dialog.setOption(QColorDialog.ShowAlphaChannel, True) + + if dialog.exec() == QColorDialog.Accepted: + new_color = dialog.selectedColor() + if new_color.isValid(): + self.color = new_color + self._update_style() + + def get_color(self) -> QColor: + """Get current color with alpha.""" + return QColor(self.color) # Return a copy + + def set_color(self, color: QColor): + """Set color and update display.""" + self.color = QColor(color) # Make a copy + self._update_style() + + +class GroupPreviewWidget(QFrame): + """Widget that shows a mini preview of the group with current properties and transparency effects.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setFixedSize(220, 140) # Slightly larger to better show transparency + self.setFrameStyle(QFrame.StyledPanel) + + # Default properties + self.group_name = "Group" + self.member_count = 2 + self.color_background = QColor(45, 45, 55, 120) + self.color_border = QColor(100, 150, 200, 180) + self.color_title_bg = QColor(60, 60, 70, 200) + self.color_title_text = QColor(220, 220, 220) + self.padding = 20 + + # Set a simple checkerboard background to show transparency + self.setStyleSheet(""" + GroupPreviewWidget { + background-color: #eee; + background-image: + linear-gradient(45deg, #ddd 25%, transparent 25%, transparent 75%, #ddd 75%); + border: 1px solid #aaa; + } + """) + + def update_preview(self, name: str, colors: Dict[str, QColor], padding: int): + """Update preview with new properties.""" + self.group_name = name + self.color_background = colors.get('background', self.color_background) + self.color_border = colors.get('border', self.color_border) + self.color_title_bg = colors.get('title_bg', self.color_title_bg) + self.color_title_text = colors.get('title_text', self.color_title_text) + self.padding = padding + self.update() + + def paintEvent(self, event): + """Custom paint to show group preview with proper transparency effects.""" + # First call parent to draw checkerboard background + super().paintEvent(event) + + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + + # Draw mini group similar to actual group rendering + margin = 15 + preview_rect = self.rect().adjusted(margin, margin, -margin, -margin) + + # Draw background with transparency + painter.setBrush(QBrush(self.color_background)) + painter.setPen(Qt.NoPen) + painter.drawRoundedRect(preview_rect, 6, 6) + + # Draw title bar with transparency + title_height = 24 + title_rect = preview_rect.adjusted(0, 0, 0, -(preview_rect.height() - title_height)) + painter.setBrush(QBrush(self.color_title_bg)) + painter.drawRoundedRect(title_rect, 6, 6) + + # Draw border with transparency + painter.setBrush(Qt.NoBrush) + painter.setPen(QPen(self.color_border, 2)) + painter.drawRoundedRect(preview_rect, 6, 6) + + # Draw title text + painter.setPen(QPen(self.color_title_text)) + font = QFont("Arial", 9, QFont.Bold) + painter.setFont(font) + title_text = f"{self.group_name} ({self.member_count} nodes)" + painter.drawText(title_rect, Qt.AlignCenter, title_text) + + # Draw some mock nodes to show how transparency affects background visibility + node_size = 16 + center = QPointF(preview_rect.center()) + node1_pos = center + QPointF(-20, 10) + node2_pos = center + QPointF(15, 10) + + # Draw mock nodes as small rectangles + painter.setBrush(QBrush(QColor(180, 180, 200))) + painter.setPen(QPen(QColor(120, 120, 140), 1)) + + node1_rect = QRectF(node1_pos.x() - node_size/2, node1_pos.y() - node_size/2, node_size, node_size) + node2_rect = QRectF(node2_pos.x() - node_size/2, node2_pos.y() - node_size/2, node_size, node_size) + + painter.drawRoundedRect(node1_rect, 2, 2) + painter.drawRoundedRect(node2_rect, 2, 2) + + # Add small labels to nodes + painter.setPen(QPen(QColor(80, 80, 100))) + font.setPointSize(7) + painter.setFont(font) + painter.drawText(node1_rect, Qt.AlignCenter, "N1") + painter.drawText(node2_rect, Qt.AlignCenter, "N2") + + # Properly end the painter + painter.end() + + +class GroupPropertiesDialog(QDialog): + """ + Dialog for editing group properties including name, description, colors, and size. + Provides live preview and validation of changes. + """ + + def __init__(self, group, parent=None): + super().__init__(parent) + self.group = group + self.setWindowTitle("Group Properties") + self.setModal(True) + self.resize(500, 600) + + # Track original values for reset functionality + self.original_properties = self._get_current_properties() + + # Initialize dialog + self._setup_ui() + self._load_current_properties() + self._connect_signals() + self._update_preview() + + def _setup_ui(self): + """Setup the dialog user interface.""" + layout = QVBoxLayout(self) + + # Title + title_label = QLabel("Group Properties") + title_font = QFont() + title_font.setBold(True) + title_font.setPointSize(12) + title_label.setFont(title_font) + layout.addWidget(title_label) + + # Main content in horizontal layout + content_layout = QHBoxLayout() + + # Left side - Properties + left_layout = QVBoxLayout() + + # Basic properties + basic_group = QGroupBox("Basic Properties") + basic_layout = QFormLayout(basic_group) + + self.name_edit = QLineEdit() + self.name_edit.setPlaceholderText("Enter group name...") + basic_layout.addRow("Name:", self.name_edit) + + self.description_edit = QTextEdit() + self.description_edit.setPlaceholderText("Optional description...") + self.description_edit.setMaximumHeight(80) + basic_layout.addRow("Description:", self.description_edit) + + # Member count (read-only) + self.member_count_label = QLabel(str(len(self.group.member_node_uuids)) + if hasattr(self.group, 'member_node_uuids') else "0") + basic_layout.addRow("Member Nodes:", self.member_count_label) + + left_layout.addWidget(basic_group) + + # Color properties with alpha sliders + colors_group = QGroupBox("Colors") + colors_layout = QGridLayout(colors_group) + + # Import additional widgets for alpha sliders + from PySide6.QtWidgets import QSlider + + # Create color buttons + self.color_background_btn = ColorButton(self.group.color_background) + self.color_border_btn = ColorButton(self.group.color_border) + self.color_title_bg_btn = ColorButton(self.group.color_title_bg) + self.color_title_text_btn = ColorButton(self.group.color_title_text) + self.color_selection_btn = ColorButton(self.group.color_selection) + + # Create alpha sliders for colors that use transparency + self.alpha_background_slider = QSlider(Qt.Horizontal) + self.alpha_background_slider.setRange(0, 255) + self.alpha_background_slider.setValue(self.group.color_background.alpha()) + self.alpha_background_slider.setFixedWidth(100) + + self.alpha_border_slider = QSlider(Qt.Horizontal) + self.alpha_border_slider.setRange(0, 255) + self.alpha_border_slider.setValue(self.group.color_border.alpha()) + self.alpha_border_slider.setFixedWidth(100) + + self.alpha_title_bg_slider = QSlider(Qt.Horizontal) + self.alpha_title_bg_slider.setRange(0, 255) + self.alpha_title_bg_slider.setValue(self.group.color_title_bg.alpha()) + self.alpha_title_bg_slider.setFixedWidth(100) + + self.alpha_selection_slider = QSlider(Qt.Horizontal) + self.alpha_selection_slider.setRange(0, 255) + self.alpha_selection_slider.setValue(self.group.color_selection.alpha()) + self.alpha_selection_slider.setFixedWidth(100) + + # Create alpha labels to show current values + self.alpha_background_label = QLabel(f"{self.group.color_background.alpha()}") + self.alpha_background_label.setMinimumWidth(30) + self.alpha_border_label = QLabel(f"{self.group.color_border.alpha()}") + self.alpha_border_label.setMinimumWidth(30) + self.alpha_title_bg_label = QLabel(f"{self.group.color_title_bg.alpha()}") + self.alpha_title_bg_label.setMinimumWidth(30) + self.alpha_selection_label = QLabel(f"{self.group.color_selection.alpha()}") + self.alpha_selection_label.setMinimumWidth(30) + + # Layout: Label | Color Button | Alpha Slider | Alpha Value + # Background (row 0) + colors_layout.addWidget(QLabel("Background:"), 0, 0) + colors_layout.addWidget(self.color_background_btn, 0, 1) + colors_layout.addWidget(self.alpha_background_slider, 0, 2) + colors_layout.addWidget(self.alpha_background_label, 0, 3) + + # Border (row 1) + colors_layout.addWidget(QLabel("Border:"), 1, 0) + colors_layout.addWidget(self.color_border_btn, 1, 1) + colors_layout.addWidget(self.alpha_border_slider, 1, 2) + colors_layout.addWidget(self.alpha_border_label, 1, 3) + + # Title Background (row 2) + colors_layout.addWidget(QLabel("Title Background:"), 2, 0) + colors_layout.addWidget(self.color_title_bg_btn, 2, 1) + colors_layout.addWidget(self.alpha_title_bg_slider, 2, 2) + colors_layout.addWidget(self.alpha_title_bg_label, 2, 3) + + # Title Text (row 3) - no alpha slider since it's typically opaque + colors_layout.addWidget(QLabel("Title Text:"), 3, 0) + colors_layout.addWidget(self.color_title_text_btn, 3, 1) + colors_layout.addWidget(QLabel(""), 3, 2) # Empty space + colors_layout.addWidget(QLabel("255"), 3, 3) # Always opaque + + # Selection (row 4) + colors_layout.addWidget(QLabel("Selection:"), 4, 0) + colors_layout.addWidget(self.color_selection_btn, 4, 1) + colors_layout.addWidget(self.alpha_selection_slider, 4, 2) + colors_layout.addWidget(self.alpha_selection_label, 4, 3) + + left_layout.addWidget(colors_group) + + # Size properties + size_group = QGroupBox("Size & Layout") + size_layout = QFormLayout(size_group) + + self.width_spinbox = QSpinBox() + self.width_spinbox.setRange(int(self.group.min_width), 2000) + self.width_spinbox.setSuffix(" px") + size_layout.addRow("Width:", self.width_spinbox) + + self.height_spinbox = QSpinBox() + self.height_spinbox.setRange(int(self.group.min_height), 2000) + self.height_spinbox.setSuffix(" px") + size_layout.addRow("Height:", self.height_spinbox) + + self.padding_spinbox = QSpinBox() + self.padding_spinbox.setRange(5, 100) + self.padding_spinbox.setSuffix(" px") + size_layout.addRow("Padding:", self.padding_spinbox) + + left_layout.addWidget(size_group) + + # Reset button + self.reset_button = QPushButton("Reset to Defaults") + left_layout.addWidget(self.reset_button) + + left_layout.addStretch() + content_layout.addLayout(left_layout) + + # Right side - Preview + right_layout = QVBoxLayout() + + preview_label = QLabel("Preview") + preview_font = QFont() + preview_font.setBold(True) + preview_label.setFont(preview_font) + right_layout.addWidget(preview_label) + + self.preview_widget = GroupPreviewWidget() + right_layout.addWidget(self.preview_widget) + + right_layout.addStretch() + content_layout.addLayout(right_layout) + + layout.addLayout(content_layout) + + # Buttons + button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + self.ok_button = button_box.button(QDialogButtonBox.Ok) + self.ok_button.setText("Apply Changes") + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + # Store reference for validation + self.button_box = button_box + + def _load_current_properties(self): + """Load current group properties into the dialog.""" + self.name_edit.setText(self.group.name) + self.description_edit.setPlainText(getattr(self.group, 'description', '')) + + # Load size properties + self.width_spinbox.setValue(int(self.group.width)) + self.height_spinbox.setValue(int(self.group.height)) + self.padding_spinbox.setValue(int(getattr(self.group, 'padding', 20))) + + # Colors are already loaded into color buttons during creation + + def _connect_signals(self): + """Connect UI signals to update handlers.""" + # Text changes + self.name_edit.textChanged.connect(self._validate_and_preview) + self.description_edit.textChanged.connect(self._update_preview) + + # Size changes + self.width_spinbox.valueChanged.connect(self._update_preview) + self.height_spinbox.valueChanged.connect(self._update_preview) + self.padding_spinbox.valueChanged.connect(self._update_preview) + + # Color changes - connect to all color buttons + for color_btn in [self.color_background_btn, self.color_border_btn, + self.color_title_bg_btn, self.color_title_text_btn, + self.color_selection_btn]: + color_btn.clicked.connect(self._update_preview) + + # Alpha slider changes - connect to both update preview and sync with color buttons + self.alpha_background_slider.valueChanged.connect(self._on_alpha_background_changed) + self.alpha_border_slider.valueChanged.connect(self._on_alpha_border_changed) + self.alpha_title_bg_slider.valueChanged.connect(self._on_alpha_title_bg_changed) + self.alpha_selection_slider.valueChanged.connect(self._on_alpha_selection_changed) + + # Reset button + self.reset_button.clicked.connect(self._reset_to_defaults) + + def _validate_and_preview(self): + """Validate inputs and update preview.""" + # Validate group name + name = self.name_edit.text().strip() + is_valid = bool(name) and len(name) <= 100 + + self.ok_button.setEnabled(is_valid) + + if not name: + self.ok_button.setToolTip("Group name is required") + elif len(name) > 100: + self.ok_button.setToolTip("Group name too long (max 100 characters)") + else: + self.ok_button.setToolTip("Apply changes to group") + + self._update_preview() + + def _update_preview(self): + """Update the preview widget with current settings.""" + colors = { + 'background': self.color_background_btn.get_color(), + 'border': self.color_border_btn.get_color(), + 'title_bg': self.color_title_bg_btn.get_color(), + 'title_text': self.color_title_text_btn.get_color() + } + + name = self.name_edit.text().strip() or "Group" + padding = self.padding_spinbox.value() + + self.preview_widget.update_preview(name, colors, padding) + + + def _on_alpha_background_changed(self, value: int): + """Handle background alpha slider change.""" + # Update the color button with new alpha value + current_color = self.color_background_btn.get_color() + current_color.setAlpha(value) + self.color_background_btn.set_color(current_color) + + # Update alpha label + self.alpha_background_label.setText(str(value)) + + # Update preview + self._update_preview() + + def _on_alpha_border_changed(self, value: int): + """Handle border alpha slider change.""" + current_color = self.color_border_btn.get_color() + current_color.setAlpha(value) + self.color_border_btn.set_color(current_color) + + self.alpha_border_label.setText(str(value)) + self._update_preview() + + def _on_alpha_title_bg_changed(self, value: int): + """Handle title background alpha slider change.""" + current_color = self.color_title_bg_btn.get_color() + current_color.setAlpha(value) + self.color_title_bg_btn.set_color(current_color) + + self.alpha_title_bg_label.setText(str(value)) + self._update_preview() + + def _on_alpha_selection_changed(self, value: int): + """Handle selection alpha slider change.""" + current_color = self.color_selection_btn.get_color() + current_color.setAlpha(value) + self.color_selection_btn.set_color(current_color) + + self.alpha_selection_label.setText(str(value)) + self._update_preview() + + def _reset_to_defaults(self): + """Reset all properties to default values.""" + # Default colors with original alpha values + self.color_background_btn.set_color(QColor(45, 45, 55, 120)) + self.color_border_btn.set_color(QColor(100, 150, 200, 180)) + self.color_title_bg_btn.set_color(QColor(60, 60, 70, 200)) + self.color_title_text_btn.set_color(QColor(220, 220, 220, 255)) # Explicitly set alpha 255 + self.color_selection_btn.set_color(QColor(255, 165, 0, 100)) + + # Reset alpha sliders to match default values + self.alpha_background_slider.setValue(120) + self.alpha_border_slider.setValue(180) + self.alpha_title_bg_slider.setValue(200) + self.alpha_selection_slider.setValue(100) + + # Update alpha labels + self.alpha_background_label.setText("120") + self.alpha_border_label.setText("180") + self.alpha_title_bg_label.setText("200") + self.alpha_selection_label.setText("100") + + # Default size + self.width_spinbox.setValue(200) + self.height_spinbox.setValue(150) + self.padding_spinbox.setValue(20) + + # Keep name and description as-is (don't reset user content) + + self._update_preview() + + def _get_current_properties(self) -> Dict[str, Any]: + """Get current group properties for comparison/reset.""" + return { + 'name': self.group.name, + 'description': getattr(self.group, 'description', ''), + 'width': self.group.width, + 'height': self.group.height, + 'padding': getattr(self.group, 'padding', 20), + 'color_background': QColor(self.group.color_background), + 'color_border': QColor(self.group.color_border), + 'color_title_bg': QColor(self.group.color_title_bg), + 'color_title_text': QColor(self.group.color_title_text), + 'color_selection': QColor(self.group.color_selection), + } + + def get_properties(self) -> Dict[str, Any]: + """Get the configured properties from the dialog.""" + return { + 'name': self.name_edit.text().strip(), + 'description': self.description_edit.toPlainText().strip(), + 'width': float(self.width_spinbox.value()), + 'height': float(self.height_spinbox.value()), + 'padding': float(self.padding_spinbox.value()), + 'color_background': self.color_background_btn.get_color(), + 'color_border': self.color_border_btn.get_color(), + 'color_title_bg': self.color_title_bg_btn.get_color(), + 'color_title_text': self.color_title_text_btn.get_color(), + 'color_selection': self.color_selection_btn.get_color(), + } + + def get_changed_properties(self) -> Dict[str, Any]: + """Get only the properties that have changed from original values.""" + current = self.get_properties() + changed = {} + + for key, value in current.items(): + original_value = self.original_properties.get(key) + if isinstance(value, QColor) and isinstance(original_value, QColor): + # Compare colors by their RGBA values + if value.getRgb() != original_value.getRgb(): + changed[key] = value + elif value != original_value: + changed[key] = value + + return changed + + def accept(self): + """Override accept to perform final validation.""" + # Final validation + name = self.name_edit.text().strip() + if not name: + QMessageBox.warning(self, "Invalid Input", "Group name is required.") + return + + if len(name) > 100: + QMessageBox.warning(self, "Invalid Input", "Group name too long (max 100 characters).") + return + + super().accept() + + +def show_group_properties_dialog(group, parent=None) -> Optional[Dict[str, Any]]: + """ + Show group properties dialog and return changed properties. + + Args: + group: The group to edit properties for + parent: Parent widget + + Returns: + Dictionary of changed properties if accepted, None if cancelled + """ + dialog = GroupPropertiesDialog(group, parent) + + if dialog.exec() == QDialog.Accepted: + return dialog.get_changed_properties() + + return None \ No newline at end of file diff --git a/src/ui/editor/node_editor_view.py b/src/ui/editor/node_editor_view.py index 9f8c0bc..18457fc 100644 --- a/src/ui/editor/node_editor_view.py +++ b/src/ui/editor/node_editor_view.py @@ -52,7 +52,11 @@ def keyPressEvent(self, event: QKeyEvent): self.scene().copy_selected() event.accept() elif event.key() == Qt.Key_V and event.modifiers() == Qt.ControlModifier: - self.scene().paste() + # Get current mouse cursor position in scene coordinates + global_mouse_pos = QCursor.pos() + local_mouse_pos = self.mapFromGlobal(global_mouse_pos) + scene_mouse_pos = self.mapToScene(local_mouse_pos) + self.scene().paste(scene_mouse_pos) # Pass actual mouse position event.accept() else: super().keyPressEvent(event) @@ -61,21 +65,34 @@ def show_context_menu(self, event: QContextMenuEvent): scene_pos = self.mapToScene(event.pos()) item_at_pos = self.scene().itemAt(scene_pos, self.transform()) - # Find the top-level node if we clicked on a child item (using duck typing) + # Find the top-level item if we clicked on a child item (using duck typing) node = None + group = None if item_at_pos: current_item = item_at_pos - while current_item and type(current_item).__name__ not in ['Node', 'RerouteNode']: + while current_item: + if type(current_item).__name__ in ['Node', 'RerouteNode']: + node = current_item + break + elif type(current_item).__name__ == 'Group': + group = current_item + break current_item = current_item.parentItem() - if type(current_item).__name__ in ['Node', 'RerouteNode']: - node = current_item menu = QMenu(self) # Get selected items for group operations (using duck typing) selected_items = [item for item in self.scene().selectedItems() if type(item).__name__ in ['Node', 'RerouteNode']] - if node: + if group: + # Context menu for a group + properties_action = menu.addAction("Group Properties") + + action = menu.exec(event.globalPos()) + if action == properties_action: + group.show_properties_dialog() + + elif node: # Context menu for a node properties_action = menu.addAction("Properties") @@ -104,6 +121,12 @@ def show_context_menu(self, event: QContextMenuEvent): if not self._can_group_nodes(selected_items): group_action.setEnabled(False) + # Add group properties option if a group is selected (but not clicked directly) + selected_groups = [item for item in self.scene().selectedItems() if type(item).__name__ == 'Group'] + group_properties_action = None + if len(selected_groups) == 1: + group_properties_action = menu.addAction("Group Properties") + action = menu.exec(event.globalPos()) if action == add_node_action: main_window = self.window() @@ -111,6 +134,8 @@ def show_context_menu(self, event: QContextMenuEvent): main_window.on_add_node(scene_pos=scene_pos) elif action == group_action and group_action: self._create_group_from_selection(selected_items) + elif action == group_properties_action and group_properties_action: + selected_groups[0].show_properties_dialog() def _can_group_nodes(self, nodes): """Basic validation for group creation"""