From 6e3d9eb2290bb158a01630751e967996926e1e8f Mon Sep 17 00:00:00 2001 From: Bryan Howard Date: Fri, 22 Aug 2025 23:02:29 -0400 Subject: [PATCH 1/8] Fix group nodes undo/redo duplication bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Initialize groups list in NodeGraph constructor - Add groups serialization/deserialization to persist groups in files - Add groups cleanup to clear_graph() method - Add duplicate prevention checks in CreateGroupCommand - Groups now properly survive file save/load cycles - Multiple undo/redo operations no longer create duplicate groups Fixes issue where undoing and redoing group creation would result in duplicate groups appearing in the scene. 🤖 Generated with [Claude Code](https://claude.ai/code) --- src/commands/create_group_command.py | 9 ++++-- src/core/node_graph.py | 44 ++++++++++++++++++++++++---- 2 files changed, 46 insertions(+), 7 deletions(-) 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/core/node_graph.py b/src/core/node_graph.py index 32fc9cf..caa9560 100644 --- a/src/core/node_graph.py +++ b/src/core/node_graph.py @@ -37,13 +37,14 @@ 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 = "" # 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 def get_node_by_id(self, node_id): """Find node by UUID - helper for command restoration.""" @@ -92,7 +93,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 +102,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): @@ -324,18 +331,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 +356,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 +411,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 +421,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 From 9f95d8db3dd3e90a567aad1d0a861d62849c965d Mon Sep 17 00:00:00 2001 From: Bryan Howard Date: Fri, 22 Aug 2025 23:29:02 -0400 Subject: [PATCH 2/8] Add group properties editor with transparency support - Add GroupPropertiesDialog with full UI for editing group properties - Add alpha sliders for precise transparency control (background, border, title, selection colors) - Add GroupPropertyChangeCommand for undoable property changes with batch support - Add context menu support in Group class and NodeEditorView - Add GroupPreviewWidget with transparency visualization - Fix QPainter cleanup and QPointF arithmetic issues - Simplify CSS styling to avoid Qt stylesheet parser warnings Users can now right-click groups to access Properties dialog and adjust all color properties including transparency values with proper undo/redo support. --- src/commands/group_property_command.py | 222 ++++++++ src/core/group.py | 49 ++ src/ui/dialogs/group_properties_dialog.py | 587 ++++++++++++++++++++++ src/ui/editor/node_editor_view.py | 31 +- 4 files changed, 884 insertions(+), 5 deletions(-) create mode 100644 src/commands/group_property_command.py create mode 100644 src/ui/dialogs/group_properties_dialog.py 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/core/group.py b/src/core/group.py index c63a8df..b58432c 100644 --- a/src/core/group.py +++ b/src/core/group.py @@ -507,6 +507,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""" 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..f566581 100644 --- a/src/ui/editor/node_editor_view.py +++ b/src/ui/editor/node_editor_view.py @@ -61,21 +61,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 +117,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 +130,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""" From e697a800093a7f737ec93840d26979c56a8d92e6 Mon Sep 17 00:00:00 2001 From: Bryan Howard Date: Fri, 22 Aug 2025 23:44:34 -0400 Subject: [PATCH 3/8] Implement groups save/load functionality for markdown files - Add Groups section parsing to FlowFormatHandler markdown parser - Add Groups section export to FlowFormatHandler markdown writer - Update Group.serialize() to include complete color data with RGBA values - Update Group.deserialize() to restore all color properties and brushes - Update FlowSpec documentation with comprehensive groups specification - Remove creation_timestamp from groups (unused metadata clutter) - Add password_generator_tool_group.md example demonstrating groups in markdown Groups now fully serialize/deserialize with transparency support in .md files. All group properties including colors, position, size, padding, and membership are properly saved and restored when loading graphs from markdown format. --- docs/specifications/flow_spec.md | 109 ++++- examples/password_generator_tool_group.md | 517 ++++++++++++++++++++++ src/core/group.py | 63 ++- src/data/flow_format.py | 20 +- 4 files changed, 696 insertions(+), 13 deletions(-) create mode 100644 examples/password_generator_tool_group.md 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/core/group.py b/src/core/group.py index b58432c..2b70e5d 100644 --- a/src/core/group.py +++ b/src/core/group.py @@ -563,12 +563,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 @@ -582,7 +613,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 @@ -596,6 +626,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/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: From ed86de4ec80e43aa70bd9777d335870b0dca61a8 Mon Sep 17 00:00:00 2001 From: Bryan Howard Date: Fri, 22 Aug 2025 23:53:50 -0400 Subject: [PATCH 4/8] Implement comprehensive copy/paste functionality for groups and nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enhanced copy_selected() to include groups in clipboard data - Added complete property preservation for nodes (colors, size, GUI state) - Implemented group copy/paste with full property restoration - Fixed CreateNodeCommand to use correct Node API (calculate_absolute_minimum_size) - Added external clipboard support for selections with groups - Resolved 'Node object has no attribute min_width' runtime error 🤖 Generated with [Claude Code](https://claude.ai/code) --- src/commands/node_commands.py | 212 +++++++++++++++++++++++++++++----- src/core/node_graph.py | 39 +++++-- 2 files changed, 216 insertions(+), 35 deletions(-) diff --git a/src/commands/node_commands.py b/src/commands/node_commands.py index efc29c4..531fc82 100644 --- a/src/commands/node_commands.py +++ b/src/commands/node_commands.py @@ -27,7 +27,9 @@ 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 = ""): + 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. @@ -38,6 +40,12 @@ def __init__(self, node_graph, title: str, position: QPointF, 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 @@ -46,27 +54,69 @@ def __init__(self, node_graph, title: str, position: QPointF, 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: - # 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) + 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 @@ -743,28 +793,35 @@ def __init__(self, node_graph, clipboard_data: Dict[str, Any], paste_position: Q Args: node_graph: The NodeGraph instance - clipboard_data: Data from clipboard containing nodes and connections + 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 connection_count == 0: + 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 connection_count == 0: + elif node_count > 1 and group_count == 0 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" + 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: - description = f"Paste {node_count} nodes with {connection_count} connections" + total_items = node_count + group_count + description = f"Paste {total_items} items with {connection_count} connections" - # Store data for execute method to handle connection creation + # 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 = [] @@ -783,14 +840,20 @@ def __init__(self, node_graph, clipboard_data: Dict[str, Any], paste_position: Q paste_position.y() + offset_y ) - # Create node command + # 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', '') + 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)) @@ -798,7 +861,7 @@ def __init__(self, node_graph, clipboard_data: Dict[str, Any], paste_position: Q super().__init__(description, commands) def execute(self) -> bool: - """Execute node creation first, then create connections.""" + """Execute node creation first, then create connections and groups.""" # First execute node creation commands if not super().execute(): return False @@ -850,6 +913,101 @@ def execute(self) -> bool: else: print(f"Failed to create connection: {conn_cmd.get_description()}") + # 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: + # Offset group position similar to nodes + group_position = group_data.get('position', {'x': 0, 'y': 0}) + offset_position = QPointF( + group_position['x'] + self.paste_position.x(), + group_position['y'] + self.paste_position.y() + ) + + # 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) + + # Restore size + 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']}") + return True def _find_node_by_id(self, node_id: str): diff --git a/src/core/node_graph.py b/src/core/node_graph.py index caa9560..c82df0c 100644 --- a/src/core/node_graph.py +++ b/src/core/node_graph.py @@ -215,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()) @@ -234,19 +238,26 @@ 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 @@ -304,20 +315,32 @@ 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', ''), '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 = { From a74935416de44b631f38350eaf0ee470506a411f Mon Sep 17 00:00:00 2001 From: Bryan Howard Date: Sat, 23 Aug 2025 00:17:35 -0400 Subject: [PATCH 5/8] Fix double transformation in group paste operations Fixed issue where pasted groups with member nodes were being transformed twice: - Once correctly by PasteNodesCommand applying mouse position offset - Again incorrectly by Group.itemChange automatically moving member nodes Changes: - Added _is_pasting flag to NodeGraph to track paste operations - Modified Group.itemChange to skip automatic node movement during paste - Enhanced paste() method to accept mouse position parameter - Fixed data conversion to handle both 'uuid' and 'id' node identifiers Groups now paste correctly at mouse cursor position with proper node positioning. Generated with [Claude Code](https://claude.ai/code) --- src/core/group.py | 4 +++ src/core/node_graph.py | 42 ++++++++++++++++++++----------- src/ui/editor/node_editor_view.py | 6 ++++- 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/src/core/group.py b/src/core/group.py index 2b70e5d..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() diff --git a/src/core/node_graph.py b/src/core/node_graph.py index c82df0c..311b91c 100644 --- a/src/core/node_graph.py +++ b/src/core/node_graph.py @@ -41,10 +41,11 @@ def __init__(self, parent=None): 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 # 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.""" @@ -261,15 +262,19 @@ def copy_selected(self): 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 @@ -303,13 +308,20 @@ 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.""" @@ -322,7 +334,7 @@ def _convert_data_format(self, data): # 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', ''), diff --git a/src/ui/editor/node_editor_view.py b/src/ui/editor/node_editor_view.py index f566581..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) From 141522024e9846671fdbbd016804ac96a14bd3d5 Mon Sep 17 00:00:00 2001 From: Bryan Howard Date: Sat, 23 Aug 2025 00:24:04 -0400 Subject: [PATCH 6/8] Fix GUI refresh for pasted nodes with GUI code Added deferred GUI update mechanism for pasted nodes to ensure GUI widgets refresh correctly, similar to when loading graphs from files. Changes: - Added deferred _final_paste_update() method to PasteNodesCommand - Schedules GUI layout updates using QTimer.singleShot after paste completes - Calls node._update_layout() to force complete layout rebuild and pin updates - Only applies to regular nodes (excludes reroute nodes) This ensures pasted nodes with GUI code display their widgets correctly and behave identically to loaded nodes. Generated with [Claude Code](https://claude.ai/code) --- src/commands/node_commands.py | 98 ++++++++++++++++++++++++++++++----- 1 file changed, 85 insertions(+), 13 deletions(-) diff --git a/src/commands/node_commands.py b/src/commands/node_commands.py index 531fc82..d3bb0d1 100644 --- a/src/commands/node_commands.py +++ b/src/commands/node_commands.py @@ -826,19 +826,30 @@ def __init__(self, node_graph, clipboard_data: Dict[str, Any], paste_position: Q # 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 - # 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 - ) + 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( @@ -913,6 +924,13 @@ def execute(self) -> bool: 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 = [] @@ -932,12 +950,27 @@ def execute(self) -> bool: # Only create group if it has member nodes that were pasted if new_member_uuids: - # Offset group position similar to nodes + # Calculate transformation needed to move group to paste position group_position = group_data.get('position', {'x': 0, 'y': 0}) - offset_position = QPointF( - group_position['x'] + self.paste_position.x(), - group_position['y'] + self.paste_position.y() - ) + 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 = { @@ -966,10 +999,11 @@ def execute(self) -> bool: if created_group: created_group.setPos(offset_position) - # Restore size + # 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 @@ -1008,6 +1042,21 @@ def execute(self) -> bool: 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): @@ -1024,6 +1073,29 @@ def get_memory_usage(self) -> int: 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.""" From cc31f6ddf50ba5a86e479f42f0aa93931f095502 Mon Sep 17 00:00:00 2001 From: Bryan Howard Date: Sat, 23 Aug 2025 00:29:44 -0400 Subject: [PATCH 7/8] Refactor node_commands.py into modular package structure Split the monolithic 1,181-line node_commands.py file into a well-organized package structure for better maintainability and code organization. Changes: - Created src/commands/node/ package with focused modules: * basic_operations.py - Create, delete, move node commands * property_changes.py - Property and code change commands * batch_operations.py - Paste, move/delete multiple commands - Reduced main node_commands.py to 21 lines (97% reduction) - Maintained 100% backward compatibility through re-exports - Added comprehensive docstrings and module documentation - Preserved all existing functionality and command behavior Benefits: - Better code organization and maintainability - Easier navigation and understanding of command types - Improved testability with focused modules - Extensible structure for future command additions Generated with [Claude Code](https://claude.ai/code) --- src/commands/node/__init__.py | 22 + src/commands/node/basic_operations.py | 672 ++++++++++++++ src/commands/node/batch_operations.py | 416 +++++++++ src/commands/node/property_changes.py | 126 +++ src/commands/node_commands.py | 1192 +------------------------ 5 files changed, 1252 insertions(+), 1176 deletions(-) create mode 100644 src/commands/node/__init__.py create mode 100644 src/commands/node/basic_operations.py create mode 100644 src/commands/node/batch_operations.py create mode 100644 src/commands/node/property_changes.py 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..4a486f9 --- /dev/null +++ b/src/commands/node/batch_operations.py @@ -0,0 +1,416 @@ +""" +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 + + # 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 commands.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 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 d3bb0d1..9e9593c 100644 --- a/src/commands/node_commands.py +++ b/src/commands/node_commands.py @@ -1,1181 +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 = "", - 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 - ) - - -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, 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 .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 - - # 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 From 9a40008340de548e47076f7a310c132d2453e1d5 Mon Sep 17 00:00:00 2001 From: Bryan Howard Date: Sat, 23 Aug 2025 00:39:33 -0400 Subject: [PATCH 8/8] Fix group deletion functionality - groups can now be deleted with Delete key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add DeleteGroupCommand with full state preservation and undo support - Enhance DeleteMultipleCommand to handle Group objects alongside nodes/connections - Fix node_graph.py keyPressEvent to use DeleteMultipleCommand for all item types - Groups were previously ignored in keyboard deletion, now work seamlessly - Comprehensive undo/redo support maintains group properties, colors, and positioning - Update command exports to include new DeleteGroupCommand 🤖 Generated with [Claude Code](https://claude.ai/code) --- src/commands/__init__.py | 3 +- src/commands/delete_group_command.py | 171 ++++++++++++++++++++++++++ src/commands/node/batch_operations.py | 47 +++++-- src/core/node_graph.py | 15 ++- 4 files changed, 217 insertions(+), 19 deletions(-) create mode 100644 src/commands/delete_group_command.py 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/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/node/batch_operations.py b/src/commands/node/batch_operations.py index 4a486f9..ceeb796 100644 --- a/src/commands/node/batch_operations.py +++ b/src/commands/node/batch_operations.py @@ -380,11 +380,13 @@ def __init__(self, node_graph, selected_items: List): 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)): @@ -394,17 +396,42 @@ def __init__(self, node_graph, selected_items: List): 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 - 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" + # 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" diff --git a/src/core/node_graph.py b/src/core/node_graph.py index 311b91c..4242d3f 100644 --- a/src/core/node_graph.py +++ b/src/core/node_graph.py @@ -145,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")