From e977b17bfdd1d40e57f8a39957c216fac7fc645f Mon Sep 17 00:00:00 2001 From: Navya-1817 Date: Sun, 20 Jul 2025 23:42:06 +0530 Subject: Added properties panel and updated various files --- .gitignore | 26 +++ README.md | 13 ++ loader/node_converter.py | 4 +- main.py | 2 - nodes/base_graphical_node.py | 34 +-- nodes/case_folder_output_graphical.py | 6 + nodes/enm_p_graphical.py | 18 +- nodes/flt_p_graphical.py | 23 +- nodes/int_p_graphical.py | 12 +- nodes/key_c_graphical.py | 142 +++++++++++- nodes/list_cp_graphical.py | 23 +- nodes/node_c_graphical.py | 30 ++- nodes/output_graphical.py | 16 +- nodes/str_p_graphical.py | 4 +- nodes/tensor_p_graphical.py | 52 ++--- nodes/vector_p_graphical.py | 4 +- requirements.txt | 3 +- utils/case_utils.py | 2 - view/commands.py | 11 - view/main_window.py | 82 +++++-- view/properties_panel.py | 426 ++++++++++++++++++++++++++++++++++ 21 files changed, 796 insertions(+), 137 deletions(-) create mode 100644 .gitignore create mode 100644 view/properties_panel.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..023bd8a --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Python cache files +__pycache__/ +*.py[cod] +*$py.class + +# Virtual environment +venv/ +env/ +ENV/ + +# PyVNT related files - ignore changes +venv/pyvnt/ + +# Parser generated files +loader/parser/parser.out +loader/parser/parsetab.py + +# Output directory +/output/ + +# IDE files +.idea/ +.vscode/ + +# Log files +*.log diff --git a/README.md b/README.md index b492023..5367c0d 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,19 @@ The project is architected into the following modules: - `socket.py`: Connection points on nodes for data flow - `undo_redo_manager.py`: Command pattern implementation for edit history - `commands.py`: Command objects for all editable operations + - `properties_panel.py`: The Properties Panel for editing node properties interactively +## Properties Panel + +The **Properties Panel** (see `view/properties_panel.py`) is a dynamic sidebar that displays and allows editing of the properties for the currently selected node. It provides context-sensitive controls (such as text fields, spin boxes, checkboxes, and lists) that are tailored to the type of node selected. Changes made in the Properties Panel are synchronized with the node in real time, and vice versa. This enables quick, precise editing of node parameters, names, values, and other attributes without directly interacting with the node on the canvas. + +**Key features:** +- Automatically updates to reflect the selected node's properties +- Supports two-way synchronization between the panel and node widgets +- Provides specialized controls for different node types (e.g., vectors, enums, lists) +- Allows editing of advanced options like element order, case names, and output filenames +- Ensures that all changes are immediately reflected in the node graph and output + +The Properties Panel is essential for efficient node configuration and is a core part of the application's workflow. - `config/`: Contains configuration-related functionality - `themes.py`: Theme definitions for UI customization diff --git a/loader/node_converter.py b/loader/node_converter.py index 4c6201d..c322782 100644 --- a/loader/node_converter.py +++ b/loader/node_converter.py @@ -474,8 +474,8 @@ class NodeConverter: # Set the name from the List_CP object if hasattr(list_cp, 'name'): - if hasattr(visual_node, 'name_input'): - visual_node.name_input.setText(list_cp.name) + if hasattr(visual_node, 'name_edit'): + visual_node.name_edit.setText(list_cp.name) # Set the isNode property if hasattr(list_cp, 'is_a_node') and hasattr(visual_node, 'isnode_checkbox'): diff --git a/main.py b/main.py index 18ddedd..bbc8759 100644 --- a/main.py +++ b/main.py @@ -5,8 +5,6 @@ OpenFOAM Case Generator - Main Application Entry Point import sys import os - - from PyQt6.QtWidgets import QApplication from view.main_window import MainWindow diff --git a/nodes/base_graphical_node.py b/nodes/base_graphical_node.py index 70757b9..f698a70 100644 --- a/nodes/base_graphical_node.py +++ b/nodes/base_graphical_node.py @@ -8,8 +8,11 @@ from PyQt6.QtGui import QPainter, QPen, QBrush, QColor, QFont, QPainterPath, QAc from view.socket import Socket - class BaseGraphicalNode(QGraphicsItem): + # (Removed property_changed signal; QGraphicsItem is not a QObject) + def set_highlighted(self, highlighted): + self._highlighted = highlighted + self.update() """ Base class for all graphical nodes in the node editor. @@ -20,9 +23,10 @@ class BaseGraphicalNode(QGraphicsItem): - Common layout and sizing logic - Name widget management """ - def __init__(self, parent=None): super().__init__(parent) + # Optional callback for property changes (set by main window/scene) + self.on_property_changed = None # Usage: fn(node, property_name, new_value) # Node dimensions (can be overridden by subclasses) - Increased for better visibility self.width = 300 # Increased from 220 for better visibility @@ -105,7 +109,7 @@ class BaseGraphicalNode(QGraphicsItem): self.name_proxy = QGraphicsProxyWidget(self) self.name_proxy.setWidget(self.name_edit) - # Connect signal for name changes + # Connect signal for name changes (live sync) self.name_edit.textChanged.connect(self._on_name_changed) # Store the old name for undo tracking @@ -142,14 +146,9 @@ class BaseGraphicalNode(QGraphicsItem): # Only create undo command if we have an old name and the scene supports undo if (hasattr(self, '_old_name') and self._old_name != text and self.scene() and hasattr(self.scene(), 'undo_manager')): - from view.commands import ChangeNodePropertyCommand command = ChangeNodePropertyCommand(self, 'name', self._old_name, text) - - # Mark as executed since the change already happened command._executed = True - - # Only add if this isn't a duplicate manager = self.scene().undo_manager should_add = True if (manager.commands and manager.current_index >= 0 and @@ -158,19 +157,16 @@ class BaseGraphicalNode(QGraphicsItem): if (last_cmd.node == self and last_cmd.property_name == 'name' and last_cmd.new_value == text and hasattr(last_cmd, '_executed') and last_cmd._executed): should_add = False - if should_add: - # Remove any commands after current index if manager.current_index < len(manager.commands) - 1: manager.commands = manager.commands[:manager.current_index + 1] - - # Add the command manager.commands.append(command) manager.current_index += 1 manager._emit_state_changed() - - # Update old name self._old_name = text + # Notify via callback if set (for live panel update) + if self.on_property_changed: + self.on_property_changed(self, 'name', text) def add_input_socket(self, multi_connection=True): """ @@ -318,13 +314,17 @@ class BaseGraphicalNode(QGraphicsItem): # Draw main node body rect = QRectF(0, 0, self.width, self.height) - # Selection highlight - if self.isSelected(): + # Selection or highlight + if getattr(self, '_highlighted', False): + selection_pen = QPen(QColor(255, 140, 0), 5) # Thick orange + painter.setPen(selection_pen) + painter.setBrush(QBrush(self.background_color)) + painter.drawRoundedRect(rect, 8, 8) + elif self.isSelected(): selection_pen = QPen(self.selected_pen_color, 3) painter.setPen(selection_pen) painter.setBrush(QBrush(self.background_color)) painter.drawRoundedRect(rect, 8, 8) - # Node background painter.setPen(QPen(self.border_color, 2)) painter.setBrush(QBrush(self.background_color)) diff --git a/nodes/case_folder_output_graphical.py b/nodes/case_folder_output_graphical.py index f3a4d17..ac31af1 100644 --- a/nodes/case_folder_output_graphical.py +++ b/nodes/case_folder_output_graphical.py @@ -24,6 +24,12 @@ from utils.case_utils import resolve_case_path, CaseManager, validate_case_name, class CaseFolderOutputNode(BaseGraphicalNode): + def set_case_name_from_panel(self, text): + """Set the case_name_edit text from the properties panel, avoiding signal loops.""" + if self.case_name_edit.text() != text: + old_block = self.case_name_edit.blockSignals(True) + self.case_name_edit.setText(text) + self.case_name_edit.blockSignals(old_block) """ Advanced output node that creates complete OpenFOAM case folder structures. diff --git a/nodes/enm_p_graphical.py b/nodes/enm_p_graphical.py index 02d01ac..3246193 100644 --- a/nodes/enm_p_graphical.py +++ b/nodes/enm_p_graphical.py @@ -16,6 +16,9 @@ class Enm_PGraphicalNode(BaseGraphicalNode): self.add_output_socket(multi_connection=False) self._update_height() + def set_on_property_changed(self, callback): + self.on_property_changed = callback + def _create_enum_widgets(self): """Create simple, efficient enum property widgets""" style = "color: white; font-size: 14px; font-family: Arial; font-weight: bold; padding: 2px;" @@ -267,11 +270,24 @@ class Enm_PGraphicalNode(BaseGraphicalNode): self.default_proxy.setWidget(self.default_combo) # Update default combo when items selection changes - self.items_list.itemSelectionChanged.connect(self._update_default_options) + self.items_list.itemSelectionChanged.connect(self._on_items_selection_changed) + self.default_combo.currentTextChanged.connect(self._on_default_changed) # Initialize default options self._update_default_options() + def _on_items_selection_changed(self): + self._update_default_options() + if hasattr(self, 'on_property_changed') and self.on_property_changed: + self.on_property_changed(self, 'items', self.get_selected_items()) + + def get_selected_items(self): + return [self.items_list.item(i).text() for i in range(self.items_list.count()) if self.items_list.item(i).isSelected()] + + def _on_default_changed(self, value): + if hasattr(self, 'on_property_changed') and self.on_property_changed: + self.on_property_changed(self, 'default', value) + def _update_default_options(self): """Update default combo options when items selection changes""" selected_items = [] diff --git a/nodes/flt_p_graphical.py b/nodes/flt_p_graphical.py index 735aca1..196e5f8 100644 --- a/nodes/flt_p_graphical.py +++ b/nodes/flt_p_graphical.py @@ -9,16 +9,15 @@ class Flt_PGraphicalNode(BaseGraphicalNode): def __init__(self, parent=None): super().__init__(parent) self.width = 300 # Increased from 250 for better visibility - # Create the UI widgets self._create_float_widgets() - # Add output socket (circular for single connection) self.add_output_socket(multi_connection=False) - # Update height based on content self._update_height() - + def set_on_property_changed(self, callback): + self.on_property_changed = callback + def _create_float_widgets(self): """Create float property widgets with name field""" style = "color: white; font-size: 14px; font-family: Arial; font-weight: bold; padding: 2px;" @@ -63,6 +62,7 @@ class Flt_PGraphicalNode(BaseGraphicalNode): background-color: #4a4a4a; } """) + self.name_edit.textChanged.connect(self._on_name_changed) self.name_proxy = QGraphicsProxyWidget(self) self.name_proxy.setWidget(self.name_edit) @@ -78,6 +78,7 @@ class Flt_PGraphicalNode(BaseGraphicalNode): self.value_spin.setValue(1e-06) # Set default to 1e-06 like in demo cases self.value_spin.setFocusPolicy(Qt.FocusPolicy.StrongFocus) # Enable keyboard focus self.value_spin.setStyleSheet(spin_style) + self.value_spin.valueChanged.connect(self._on_value_changed) self.value_proxy = QGraphicsProxyWidget(self) self.value_proxy.setWidget(self.value_spin) @@ -93,6 +94,7 @@ class Flt_PGraphicalNode(BaseGraphicalNode): self.min_spin.setValue(0.0) # PyVNT default minimum self.min_spin.setFocusPolicy(Qt.FocusPolicy.StrongFocus) # Enable keyboard focus self.min_spin.setStyleSheet(spin_style) + self.min_spin.valueChanged.connect(self._on_min_changed) self.min_proxy = QGraphicsProxyWidget(self) self.min_proxy.setWidget(self.min_spin) @@ -108,6 +110,7 @@ class Flt_PGraphicalNode(BaseGraphicalNode): self.max_spin.setValue(100.0) # PyVNT default maximum self.max_spin.setFocusPolicy(Qt.FocusPolicy.StrongFocus) # Enable keyboard focus self.max_spin.setStyleSheet(spin_style) + self.max_spin.valueChanged.connect(self._on_max_changed) self.max_proxy = QGraphicsProxyWidget(self) self.max_proxy.setWidget(self.max_spin) @@ -187,3 +190,15 @@ class Flt_PGraphicalNode(BaseGraphicalNode): maximum=max_val # User-specified maximum ) return pyvnt_float + + def _on_value_changed(self, value): + if hasattr(self, 'on_property_changed') and self.on_property_changed: + self.on_property_changed(self, 'value', value) + + def _on_min_changed(self, value): + if hasattr(self, 'on_property_changed') and self.on_property_changed: + self.on_property_changed(self, 'min', value) + + def _on_max_changed(self, value): + if hasattr(self, 'on_property_changed') and self.on_property_changed: + self.on_property_changed(self, 'max', value) diff --git a/nodes/int_p_graphical.py b/nodes/int_p_graphical.py index 1243b92..f6d6efe 100644 --- a/nodes/int_p_graphical.py +++ b/nodes/int_p_graphical.py @@ -10,16 +10,16 @@ class Int_PGraphicalNode(BaseGraphicalNode): def __init__(self, parent=None): super().__init__(parent) self.width = 260 - # Create the UI widgets self._create_integer_widgets() - # Add output socket (circular for single connection) self.add_output_socket(multi_connection=False) - # Update height based on content self._update_height() + def set_on_property_changed(self, callback): + self.on_property_changed = callback + def _create_integer_widgets(self): """Create integer property widgets""" style = "color: white; font-size: 14px; font-family: Arial; font-weight: bold; padding: 2px;" @@ -37,6 +37,7 @@ class Int_PGraphicalNode(BaseGraphicalNode): self.name_edit.setPlaceholderText("Enter parameter name") self.name_edit.setFocusPolicy(Qt.FocusPolicy.StrongFocus) # Enable keyboard focus self.name_edit.setStyleSheet(input_style) + self.name_edit.textChanged.connect(self._on_name_changed) self.name_proxy = QGraphicsProxyWidget(self) self.name_proxy.setWidget(self.name_edit) @@ -51,6 +52,7 @@ class Int_PGraphicalNode(BaseGraphicalNode): self.value_spinbox.setValue(2) self.value_spinbox.setFocusPolicy(Qt.FocusPolicy.StrongFocus) # Enable keyboard focus self.value_spinbox.setStyleSheet(spinbox_style) + self.value_spinbox.valueChanged.connect(self._on_value_changed) self.value_proxy = QGraphicsProxyWidget(self) self.value_proxy.setWidget(self.value_spinbox) @@ -107,3 +109,7 @@ class Int_PGraphicalNode(BaseGraphicalNode): ) return pyvnt_int + + def _on_value_changed(self, value): + if hasattr(self, 'on_property_changed') and self.on_property_changed: + self.on_property_changed(self, 'value', value) diff --git a/nodes/key_c_graphical.py b/nodes/key_c_graphical.py index ce60904..7a68316 100644 --- a/nodes/key_c_graphical.py +++ b/nodes/key_c_graphical.py @@ -7,6 +7,8 @@ from nodes.base_graphical_node import BaseGraphicalNode class Key_CGraphicalNode(BaseGraphicalNode): + def __init__(self, parent=None): + super().__init__() def _flatten(self, items): """Recursively flatten a nested list/tuple structure, handling List_CP objects.""" for x in items: @@ -37,19 +39,111 @@ class Key_CGraphicalNode(BaseGraphicalNode): def __init__(self, parent=None): super().__init__(parent=parent) - # Key_C specific setup self.width = 200 # Increased from 160 for better visibility - # Create name widget using base class method self.create_name_widget("default_key", "e.g., solver, tolerance") - # Add sockets: one multi-input, one single-output self.add_input_socket(multi_connection=True) # Square - can accept multiple Value_P self.add_output_socket(multi_connection=False) # Circle - single connection to Node_C - + # Element ordering state + self.custom_element_order = None # Update height after adding content self._update_height() + + def show_context_menu(self, event): + """Override to add custom context menu for Key_C operations""" + from PyQt6.QtWidgets import QMenu, QMessageBox + from PyQt6.QtGui import QAction + menu = QMenu() + # Add standard delete action from parent + delete_action = QAction("Delete Node", menu) + delete_action.triggered.connect(self.delete_node) + menu.addAction(delete_action) + menu.addSeparator() + # Add element order action + order_action = QAction("Set Element Order...", menu) + order_action.triggered.connect(self.show_element_order_dialog) + menu.addAction(order_action) + # Add a reset element order action + if self.custom_element_order is not None: + reset_action = QAction("Reset Element Order", menu) + reset_action.triggered.connect(self.reset_element_order) + menu.addAction(reset_action) + # Show the menu at cursor position + if self.scene() and self.scene().views(): + view = self.scene().views()[0] + global_pos = view.mapToGlobal(view.mapFromScene(event.scenePos())) + menu.exec(global_pos) + + def show_element_order_dialog(self): + """Show dialog to set element order for Key_C""" + from PyQt6.QtWidgets import QMessageBox, QDialog + try: + from view.element_order_dialog import ElementOrderDialog + # Get all connected element names + connected_elements = self.get_connected_element_names() + if not connected_elements: + QMessageBox.information( + None, + "No Elements", + "This node has no connected elements to order." + ) + return + # Use custom order if set, else default + if self.custom_element_order: + order_names = [name for name in self.custom_element_order if name in connected_elements] + for name in connected_elements: + if name not in order_names: + order_names.append(name) + else: + order_names = connected_elements + dialog = ElementOrderDialog( + parent=None, + node_data=order_names, + title=f"Set Element Order for {self.name_edit.text()}" + ) + if dialog.exec() == QDialog.DialogCode.Accepted: + self.custom_element_order = dialog.get_ordered_elements() + if self.on_property_changed: + self.on_property_changed(self, 'element_order', self.custom_element_order) + QMessageBox.information( + None, + "Order Set", + f"Custom element order set for {self.name_edit.text()}" + ) + except Exception as e: + QMessageBox.warning( + None, + "Error", + f"An error occurred: {str(e)}" + ) + + def reset_element_order(self): + """Reset any custom element order""" + from PyQt6.QtWidgets import QMessageBox + self.custom_element_order = None + if self.on_property_changed: + self.on_property_changed(self, 'element_order', self.custom_element_order) + QMessageBox.information( + None, + "Order Reset", + f"Element order reset to default for {self.name_edit.text()}" + ) + + def get_connected_element_names(self): + """Get names of all elements connected to this node (for ordering)""" + element_names = [] + for socket in self.input_sockets: + for edge in getattr(socket, 'edges', []): + other_socket = edge.getOtherSocket(socket) + if other_socket and hasattr(other_socket, 'node'): + connected_node = other_socket.node + if hasattr(connected_node, 'name_edit'): + name = connected_node.name_edit.text().strip() + if name: + element_names.append(name) + return element_names def get_node_title(self): """Return the title for this node type""" @@ -60,6 +154,7 @@ class Key_CGraphicalNode(BaseGraphicalNode): Lazy evaluation: Build PyVNT Key_C object from current UI state + connected inputs. This method is only called when data is actually needed (e.g., file generation). Allows List_CP as a valid value for Key_C. + Applies custom element order if set. """ from pyvnt.Container.key import Key_C @@ -67,7 +162,9 @@ class Key_CGraphicalNode(BaseGraphicalNode): if not current_name: current_name = "unnamed_key" + # Collect connected values and their names connected_values = [] + value_name_map = {} for socket in self.input_sockets: for edge in getattr(socket, 'edges', []): other_socket = edge.getOtherSocket(socket) @@ -76,22 +173,45 @@ class Key_CGraphicalNode(BaseGraphicalNode): if hasattr(connected_node, 'get_pyvnt_object'): try: value_obj = connected_node.get_pyvnt_object() - print(f"[Key_CGraphicalNode] Got value from connected node: {type(value_obj).__name__} | {value_obj}") if value_obj is not None: connected_values.append(value_obj) + # Map name for ordering + if hasattr(connected_node, 'name_edit'): + name = connected_node.name_edit.text().strip() + if name: + value_name_map[name] = value_obj except Exception as e: print(f"Warning: Failed to get PyVNT object from connected value node: {e}") - print(f"[Key_CGraphicalNode] All connected values: {[type(v).__name__ for v in connected_values]}") + # Apply custom element order if defined + ordered_values = None + if self.custom_element_order is not None: + # Only include values that are still connected + ordered_values = [value_name_map[name] for name in self.custom_element_order if name in value_name_map] + # Add any remaining values not in the order list + for name, val in value_name_map.items(): + if name not in self.custom_element_order: + ordered_values.append(val) + else: + ordered_values = connected_values + # If any value is a List_CP, pass it directly (do not flatten) - if connected_values and any(x.__class__.__name__ == "List_CP" for x in connected_values): - listcp_values = [x for x in connected_values if x.__class__.__name__ == "List_CP"] - print(f"[Key_CGraphicalNode] Passing List_CP values directly: {listcp_values}") + if ordered_values and any(x.__class__.__name__ == "List_CP" for x in ordered_values): + listcp_values = [x for x in ordered_values if x.__class__.__name__ == "List_CP"] return Key_C(current_name, *listcp_values) else: - flat_values = list(self._flatten(connected_values)) - print(f"[Key_CGraphicalNode] Passing flattened values: {flat_values}") + flat_values = list(self._flatten(ordered_values)) if flat_values: return Key_C(current_name, *flat_values) else: return Key_C(current_name) + + def onSocketConnected(self, socket): + # When a new element is connected, refresh element order in panel + if hasattr(self, 'on_property_changed') and self.on_property_changed: + self.on_property_changed(self, 'element_order', self.custom_element_order) + + def onSocketDisconnected(self, socket): + # When an element is disconnected, refresh element order in panel + if hasattr(self, 'on_property_changed') and self.on_property_changed: + self.on_property_changed(self, 'element_order', self.custom_element_order) diff --git a/nodes/list_cp_graphical.py b/nodes/list_cp_graphical.py index 29182e2..b46dff3 100644 --- a/nodes/list_cp_graphical.py +++ b/nodes/list_cp_graphical.py @@ -1,6 +1,6 @@ """List_CPGraphicalNode class for PyQt6 node editor""" -from PyQt6.QtWidgets import (QGraphicsProxyWidget, QLabel, QTextEdit, +from PyQt6.QtWidgets import (QGraphicsProxyWidget, QLabel, QLineEdit, QTextEdit, QPushButton, QVBoxLayout, QWidget, QHBoxLayout, QCheckBox) from PyQt6.QtCore import Qt from nodes.base_graphical_node import BaseGraphicalNode @@ -59,14 +59,14 @@ class List_CPGraphicalNode(BaseGraphicalNode): self.name_label_proxy = QGraphicsProxyWidget(self) self.name_label_proxy.setWidget(self.name_label) - self.name_input = QTextEdit() - self.name_input.setPlaceholderText("Enter list name (e.g., 'boundary', 'vertices')") - self.name_input.setText("listVal") - self.name_input.setStyleSheet(input_style) - self.name_input.setMaximumHeight(25) - self.name_input.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.name_edit = QLineEdit() + self.name_edit.setPlaceholderText("Enter list name (e.g., 'boundary', 'vertices')") + self.name_edit.setText("listVal") + self.name_edit.setStyleSheet(input_style) + self.name_edit.setFixedHeight(25) + self.name_edit.textChanged.connect(self._on_name_changed) self.name_proxy = QGraphicsProxyWidget(self) - self.name_proxy.setWidget(self.name_input) + self.name_proxy.setWidget(self.name_edit) # IsNode checkbox self.isnode_checkbox = QCheckBox("Is Node List") @@ -159,7 +159,7 @@ class List_CPGraphicalNode(BaseGraphicalNode): y += 20 text_width = self.width - 2 * self.content_margin - self.name_input.setFixedSize(text_width, 25) + self.name_edit.setFixedSize(text_width, 25) self.name_proxy.setPos(self.content_margin, y) y += 30 @@ -210,7 +210,7 @@ class List_CPGraphicalNode(BaseGraphicalNode): except ImportError: return None - name = self.name_input.toPlainText().strip() or "listVal" + name = self.name_edit.text().strip() or "listVal" is_node = self.isnode_checkbox.isChecked() if is_node: @@ -223,12 +223,11 @@ class List_CPGraphicalNode(BaseGraphicalNode): if hasattr(connected_node, 'get_pyvnt_object'): try: child_obj = connected_node.get_pyvnt_object() - print(f"[List_CPGraphicalNode] Got child from connected node: {type(child_obj).__name__} | {child_obj}") + if child_obj is not None: child_values.append(child_obj) except Exception as e: print(f"Warning: Failed to get PyVNT object from connected child: {e}") - print(f"[List_CPGraphicalNode] All collected child values: {[type(v).__name__ for v in child_values]}") return List_CP(name, values=child_values, isNode=True) else: # Aggregate all connected value nodes (including List_CP) as a single sublist for OpenFOAM block lines diff --git a/nodes/node_c_graphical.py b/nodes/node_c_graphical.py index 1c67a34..a58989f 100644 --- a/nodes/node_c_graphical.py +++ b/nodes/node_c_graphical.py @@ -21,10 +21,8 @@ class Node_CGraphicalNode(BaseGraphicalNode): def __init__(self, parent=None): super().__init__(parent) - # Node_C specific setup self.width = 220 # Increased from 180 for better visibility - # Create name widget self.create_name_widget("default_node", "Node name...") @@ -82,7 +80,6 @@ class Node_CGraphicalNode(BaseGraphicalNode): # Get all connected elements (Key_C and Node_C) connected_elements = self.get_connected_element_names() - if not connected_elements: QMessageBox.information( None, @@ -90,17 +87,26 @@ class Node_CGraphicalNode(BaseGraphicalNode): "This node has no connected elements to order." ) return - - # Create the dialog + # Use custom order if set, else default + if self.custom_element_order: + order_names = [name for name in self.custom_element_order if name in connected_elements] + # Add any new elements not in the custom order + for name in connected_elements: + if name not in order_names: + order_names.append(name) + else: + order_names = connected_elements dialog = ElementOrderDialog( parent=None, - node_data=connected_elements, + node_data=order_names, title=f"Set Element Order for {self.name_edit.text()}" ) # Show dialog and get results if dialog.exec() == QDialog.DialogCode.Accepted: self.custom_element_order = dialog.get_ordered_elements() + if self.on_property_changed: + self.on_property_changed(self, 'element_order', self.custom_element_order) QMessageBox.information( None, "Order Set", @@ -117,6 +123,8 @@ class Node_CGraphicalNode(BaseGraphicalNode): def reset_element_order(self): """Reset any custom element order""" self.custom_element_order = None + if self.on_property_changed: + self.on_property_changed(self, 'element_order', self.custom_element_order) QMessageBox.information( None, "Order Reset", @@ -221,3 +229,13 @@ class Node_CGraphicalNode(BaseGraphicalNode): print(f"Warning: Failed to apply custom element order: {e}") return pyvnt_node + + def onSocketConnected(self, socket): + # When a new element is connected, refresh element order in panel + if hasattr(self, 'on_property_changed') and self.on_property_changed: + self.on_property_changed(self, 'element_order', self.custom_element_order) + + def onSocketDisconnected(self, socket): + # When an element is disconnected, refresh element order in panel + if hasattr(self, 'on_property_changed') and self.on_property_changed: + self.on_property_changed(self, 'element_order', self.custom_element_order) diff --git a/nodes/output_graphical.py b/nodes/output_graphical.py index 7ba70f9..e25acdf 100644 --- a/nodes/output_graphical.py +++ b/nodes/output_graphical.py @@ -6,6 +6,12 @@ from nodes.base_graphical_node import BaseGraphicalNode from pyvnt.Converter.Writer.writer import writeTo class OutputGraphicalNode(BaseGraphicalNode): + def set_filename_from_panel(self, text): + """Set the filename_edit text from the properties panel, avoiding signal loops.""" + if self.filename_edit.text() != text: + old_block = self.filename_edit.blockSignals(True) + self.filename_edit.setText(text) + self.filename_edit.blockSignals(old_block) """ Terminal node for OpenFOAM file generation using PyVNT. Visual node with a single input socket and file generation controls. @@ -13,14 +19,11 @@ class OutputGraphicalNode(BaseGraphicalNode): def __init__(self, parent=None): super().__init__(parent) self.width = 240 # Increased from 200 for better visibility - # Output node has input socket (for receiving data) and output socket (for chaining to case folder) - self.add_input_socket(multi_connection=True) + self.add_input_socket(multi_connection=False) self.add_output_socket(multi_connection=True) # Add output socket for case folder connection - # Create the UI widgets self._create_widgets() - # Update height based on content self._update_height() @@ -124,7 +127,6 @@ class OutputGraphicalNode(BaseGraphicalNode): Returns dict with 'valid' boolean and 'message' string. Adds debug output and always treats List_CP as valid. """ - print(f"DEBUG: Validating node at depth {depth}: {type(node).__name__}") try: from pyvnt.Reference.basic import Int_P, Flt_P, Enm_P, Str_P from pyvnt.Reference.vector import Vector_P @@ -171,7 +173,6 @@ class OutputGraphicalNode(BaseGraphicalNode): if not items: return {"valid": False, "message": f"Key_C '{node.name}' has no values. Connect Int_P, Flt_P, Enm_P, Str_P, Vector_P, Dim_Set_P, Tensor_P, or List_CP nodes to its inputs."} for i, (key, value) in enumerate(items): - print(f"DEBUG: Key_C '{node.name}' value '{key}' type: {type(value).__name__}") # Always treat List_CP as valid if isinstance(value, List_CP): continue @@ -188,15 +189,12 @@ class OutputGraphicalNode(BaseGraphicalNode): return {"valid": False, "message": f"Key_C '{node.name}' validation error: {str(e)}"} # List_CP elif hasattr(node, '__class__') and 'List_CP' in node.__class__.__name__: - print(f"DEBUG: List_CP node at depth {depth} is always valid.") - # Always treat List_CP as valid pass # Value nodes (leaf nodes) elif (isinstance(node, (Int_P, Flt_P, Enm_P, Str_P, Vector_P, Dim_Set_P, Tensor_P)) or hasattr(node, '__class__') and any(ptype in node.__class__.__name__ for ptype in ['Int_P', 'Flt_P', 'Enm_P', 'Str_P', 'Vector_P', 'Dim_Set_P', 'Tensor_P'])): pass else: - print(f"DEBUG: Unknown node type at depth {depth}: {type(node).__name__}") return {"valid": False, "message": f"Unknown PyVNT object type: {type(node).__name__}"} return {"valid": True, "message": "Valid"} diff --git a/nodes/str_p_graphical.py b/nodes/str_p_graphical.py index e6df022..a8bdf78 100644 --- a/nodes/str_p_graphical.py +++ b/nodes/str_p_graphical.py @@ -9,13 +9,10 @@ class Str_PGraphicalNode(BaseGraphicalNode): def __init__(self, parent=None): super().__init__(parent) self.width = 260 # Wider for text input - # Create the UI widgets self._create_string_widgets() - # Add output socket (circular for single connection) self.add_output_socket(multi_connection=False) - # Update height based on content self._update_height() @@ -48,6 +45,7 @@ class Str_PGraphicalNode(BaseGraphicalNode): self.name_edit.setText("format") # Default OpenFOAM-like name self.name_edit.setPlaceholderText("Enter parameter name") self.name_edit.setStyleSheet(input_style) + self.name_edit.textChanged.connect(self._on_name_changed) self.name_proxy = QGraphicsProxyWidget(self) self.name_proxy.setWidget(self.name_edit) diff --git a/nodes/tensor_p_graphical.py b/nodes/tensor_p_graphical.py index f9acbce..a75165d 100644 --- a/nodes/tensor_p_graphical.py +++ b/nodes/tensor_p_graphical.py @@ -9,14 +9,11 @@ class Tensor_PGraphicalNode(BaseGraphicalNode): def __init__(self, parent=None): super().__init__(parent) - self.width = 340 # Increased from 320 for better layout - + self.width = 320 # Decreased width to fit components tightly # Create the UI widgets self._create_tensor_widgets() - # Add output socket (circular for single connection) self.add_output_socket(multi_connection=False) - # Update height based on content self._update_height() @@ -177,47 +174,38 @@ class Tensor_PGraphicalNode(BaseGraphicalNode): # Component spin boxes (arrange in grid if 3x3, 2x2, etc.) size = len(self.component_spins) - if size == 4: # 2x2 tensor - cols, rows = 2, 2 - elif size == 9: # 3x3 tensor - cols, rows = 3, 3 - elif size == 16: # 4x4 tensor - cols, rows = 4, 4 - else: # Linear arrangement for other sizes - cols, rows = min(3, size), (size + 2) // 3 - - component_width = 80 - component_height = 28 # Increased from 25 for better spacing + # Always use 2 columns per row for all sizes + cols = 2 + rows = (size + cols - 1) // cols + + component_width = 110 # More width per component + component_height = 36 start_x = self.content_margin - + + h_spacing = 32 # Increased horizontal spacing to prevent overlap + v_spacing = 4 # Reduced vertical spacing, but enough to avoid overlap + for i, proxy in enumerate(self.component_proxies): row = i // cols col = i % cols - x = start_x + col * (component_width + 5) - y = y_offset + row * (component_height + 4) # Increased margin + x = start_x + col * (component_width + h_spacing) + y = y_offset + row * (component_height + v_spacing) proxy.setPos(x, y) def _update_height(self): """Update node height based on number of components""" base_height = 140 # Increased from 120 for better spacing - + # Calculate additional height for components - size = len(self.component_spins) if hasattr(self, 'component_spins') else 9 - if size == 4: # 2x2 - rows = 2 - elif size == 9: # 3x3 - rows = 3 - elif size == 16: # 4x4 - rows = 4 - else: # Linear - rows = (size + 2) // 3 - - component_height = rows * 32 # Increased from 28 for better spacing + size = len(self.component_spins) if hasattr(self, 'component_spins') else 0 + cols = 2 + rows = (size + cols - 1) // cols if size > 0 else 0 + component_height = rows * 36 + max(0, (rows - 1) * 4) # Match reduced vertical spacing self.height = base_height + component_height - + # Position widgets after height update self._position_widgets() - + # Position sockets and update graphics self._position_sockets() self.prepareGeometryChange() diff --git a/nodes/vector_p_graphical.py b/nodes/vector_p_graphical.py index 2567d09..dca1cff 100644 --- a/nodes/vector_p_graphical.py +++ b/nodes/vector_p_graphical.py @@ -9,13 +9,10 @@ class Vector_PGraphicalNode(BaseGraphicalNode): def __init__(self, parent=None): super().__init__(parent) self.width = 280 # Wider for vector input controls - # Create the UI widgets self._create_vector_widgets() - # Add output socket (circular for single connection) self.add_output_socket(multi_connection=False) - # Update height based on content self._update_height() @@ -62,6 +59,7 @@ class Vector_PGraphicalNode(BaseGraphicalNode): background-color: #4a4a4a; } """) + self.name_edit.textChanged.connect(self._on_name_changed) self.name_proxy = QGraphicsProxyWidget(self) self.name_proxy.setWidget(self.name_edit) diff --git a/requirements.txt b/requirements.txt index 5601ae5..6bba4b8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ # Required Python packages for CaseGenerator PyQt6>=6.0.0 typing-extensions>=4.0.0 -pyvnt>=0.0.0 \ No newline at end of file +pyvnt>=0.0.0 +pyyaml>=6.0 \ No newline at end of file diff --git a/utils/case_utils.py b/utils/case_utils.py index 02a7dda..85020ff 100644 --- a/utils/case_utils.py +++ b/utils/case_utils.py @@ -145,7 +145,6 @@ class CaseManager: shutil.copy2(file_path, backup_path) return backup_path except Exception as e: - print(f"Warning: Could not create backup of {file_path}: {e}") return None def add_file_to_folder(self, folder: str, filename: str, content: str, @@ -181,7 +180,6 @@ class CaseManager: backup_path = self.backup_existing_file(folder, filename) if backup_path: backup_name = os.path.basename(backup_path) - print(f"Created backup: {backup_name}") try: with open(file_path, 'w', encoding='utf-8') as f: diff --git a/view/commands.py b/view/commands.py index 2b6c5ff..a7f44db 100644 --- a/view/commands.py +++ b/view/commands.py @@ -285,7 +285,6 @@ class DeleteNodeCommand(Command): self.scene.addItem(edge) except Exception as e: - print(f"Failed to restore edge: {e}") continue @@ -305,7 +304,6 @@ class MoveNodeCommand(Command): self._executed = True return True except Exception as e: - print(f"Failed to move node: {e}") return False def undo(self) -> bool: @@ -315,7 +313,6 @@ class MoveNodeCommand(Command): self._executed = False return True except Exception as e: - print(f"Failed to undo node move: {e}") return False @@ -347,7 +344,6 @@ class CreateEdgeCommand(Command): return True except Exception as e: - print(f"Failed to create edge: {e}") return False def undo(self) -> bool: @@ -372,7 +368,6 @@ class CreateEdgeCommand(Command): return True return False except Exception as e: - print(f"Failed to undo edge creation: {e}") return False @@ -405,7 +400,6 @@ class DeleteEdgeCommand(Command): return True return False except Exception as e: - print(f"Failed to delete edge: {e}") return False def undo(self) -> bool: @@ -423,7 +417,6 @@ class DeleteEdgeCommand(Command): self._executed = False return True except Exception as e: - print(f"Failed to undo edge deletion: {e}") return False @@ -444,7 +437,6 @@ class ChangeNodePropertyCommand(Command): self._executed = True return True except Exception as e: - print(f"Failed to change property: {e}") return False def undo(self) -> bool: @@ -454,7 +446,6 @@ class ChangeNodePropertyCommand(Command): self._executed = False return True except Exception as e: - print(f"Failed to undo property change: {e}") return False def _set_property(self, value): @@ -495,7 +486,6 @@ class CompositeCommand(Command): cmd.undo() except: pass - print(f"Failed to execute composite command: {e}") return False def undo(self) -> bool: @@ -509,5 +499,4 @@ class CompositeCommand(Command): self._executed = False return True except Exception as e: - print(f"Failed to undo composite command: {e}") return False diff --git a/view/main_window.py b/view/main_window.py index 2ffa498..0eae844 100644 --- a/view/main_window.py +++ b/view/main_window.py @@ -58,6 +58,33 @@ class NodeListWidget(QListWidget): class MainWindow(QMainWindow): + def _on_node_property_changed(self, prop, value): + # Always update the properties panel for the last selected node + node = self._last_selected_node if hasattr(self, '_last_selected_node') else None + if node: + self.update_properties_panel(node) + def update_properties_panel(self, node=None): + """Update the properties panel with the selected node's properties""" + if node is None: + # Try to get selected node from current scene + scene = self.get_current_editor_scene() + if scene: + selected = [item for item in scene.selectedItems() if hasattr(item, 'get_node_title')] + node = selected[0] if selected else None + self.properties_panel.set_node(node) + # Set up live update callback for node property changes + if node and hasattr(node, 'on_property_changed'): + node.on_property_changed = lambda n, prop, value: self._on_node_property_changed(prop, value) + self._last_selected_node = node + + def highlight_selected_node(self, node): + """Highlight the selected node with a thick orange border, remove from others""" + scene = self.get_current_editor_scene() + if not scene: + return + for item in scene.items(): + if hasattr(item, 'set_highlighted'): + item.set_highlighted(item is node) """Main application window""" def __init__(self): @@ -121,8 +148,8 @@ class MainWindow(QMainWindow): # Create first tab with default case self._create_new_case_tab() - # Set splitter proportions (left pane: 120px, editor: rest) - splitter.setSizes([120, 880]) + # Set splitter proportions (left pane: 180px, editor: rest) + splitter.setSizes([180, 880]) splitter.setCollapsible(0, False) # Left pane cannot be collapsed self.setGeometry(100, 100, 1200, 800) @@ -199,8 +226,17 @@ class MainWindow(QMainWindow): # Connect signals editor_view.node_created.connect(self.on_node_created) - - # Connect undo/redo signals + # Connect selection change to update properties panel and highlight + def on_selection_changed(): + try: + selected = [item for item in editor_scene.selectedItems() if hasattr(item, 'get_node_title')] + node = selected[0] if selected else None + self.update_properties_panel(node) + self.highlight_selected_node(node) + except RuntimeError: + # Scene or items have been deleted; safely ignore + return + editor_scene.selectionChanged.connect(on_selection_changed) editor_scene.undo_manager.can_undo_changed.connect(self._update_undo_action) editor_scene.undo_manager.can_redo_changed.connect(self._update_redo_action) editor_scene.undo_manager.undo_text_changed.connect(self._update_undo_text) @@ -339,28 +375,35 @@ class MainWindow(QMainWindow): return None def _create_left_pane(self): - """Create the left control pane""" + """Create the left control pane (now extendible, no fixed width)""" left_widget = QWidget() - left_widget.setFixedWidth(120) + # Remove fixed width to allow splitter resizing left_widget.setStyleSheet(LEFT_PANE_STYLES) - + layout = QVBoxLayout(left_widget) layout.setSpacing(10) layout.setContentsMargins(10, 10, 10, 10) - + # Node Library Group nodes_group = QGroupBox("Node Library") nodes_layout = QVBoxLayout(nodes_group) - - # Create node list for other nodes - no height restriction to show all nodes + + # Node list: no scroll bars, show all nodes, no height restriction self.node_list = NodeListWidget() - + self.node_list.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.node_list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.node_list.setSizeAdjustPolicy(self.node_list.SizeAdjustPolicy.AdjustToContents) + self.node_list.setMaximumHeight(16777215) # No height restriction + self.node_list.setMinimumHeight(0) + from PyQt6.QtWidgets import QFrame + self.node_list.setFrameShape(QFrame.Shape.NoFrame) + # Add all available node types node_types = [ "Node_C", "Key_C", "Enm_P", - "Int_P", + "Int_P", "Flt_P", "Vector_P", "Dim_Set_P", @@ -370,17 +413,20 @@ class MainWindow(QMainWindow): "Output", "Case Folder" ] - for node_type in node_types: item = QListWidgetItem(node_type) self.node_list.addItem(item) - + nodes_layout.addWidget(self.node_list) layout.addWidget(nodes_group) - + + # --- Properties Panel --- + from view.properties_panel import PropertiesPanel + self.properties_panel = PropertiesPanel() + layout.addWidget(self.properties_panel) + # Add stretch to push everything to top layout.addStretch() - return left_widget def _open_case(self): @@ -714,10 +760,10 @@ class MainWindow(QMainWindow): if type(graphics_node).__name__ == 'CaseFolderOutputNode': if hasattr(graphics_node, 'set_default_output_dir'): graphics_node.set_default_output_dir(self.default_output_dir) - # Mark current tab as modified self._mark_tab_modified() - # You can add logic here to update properties pane, etc. + self.update_properties_panel(graphics_node) + self.highlight_selected_node(graphics_node) def _create_menu(self): """Create the menu bar""" diff --git a/view/properties_panel.py b/view/properties_panel.py new file mode 100644 index 0000000..55b38dc --- /dev/null +++ b/view/properties_panel.py @@ -0,0 +1,426 @@ +from PyQt6.QtWidgets import QGroupBox, QFormLayout, QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox, QCheckBox, QLabel, QWidget, QPushButton, QListWidget, QListWidgetItem +from PyQt6.QtCore import Qt + +class PropertiesPanel(QGroupBox): + def __init__(self, parent=None): + super().__init__("Properties", parent) + self.layout = QFormLayout() + self.setLayout(self.layout) + self.setMinimumWidth(120) + self.setMaximumWidth(220) + self.node = None + self.fields = {} + self.setStyleSheet("QGroupBox { font-weight: bold; }") + + def clear(self): + while self.layout.count(): + item = self.layout.takeAt(0) + w = item.widget() + if w: + w.deleteLater() + self.fields = {} + self.node = None + + def set_node(self, node): + # Always clear and rebuild, even if node is the same + self.clear() + if node is None: + self.setTitle("Properties") + return + self.node = node + self.setTitle(f"Properties: {getattr(node, 'get_node_title', lambda: type(node).__name__)()}") + + # --- Str_PGraphicalNode: Name and Value fields with two-way sync --- + if node.__class__.__name__ == 'Str_PGraphicalNode' and hasattr(node, 'name_edit') and hasattr(node, 'value_edit'): + import weakref + # Name field + name_edit = QLineEdit(node.name_edit.text()) + name_edit.textChanged.connect(lambda text: self._update_node_name(text)) + name_edit_ref = weakref.ref(name_edit) + def sync_name(): + edit = name_edit_ref() + if edit is not None and edit.text() != node.name_edit.text(): + edit.setText(node.name_edit.text()) + node.name_edit.textChanged.connect(sync_name) + self.layout.addRow("Name", name_edit) + self.fields['name'] = name_edit + # Value field + value_edit = QLineEdit(node.value_edit.text()) + def update_node_value(text): + if node.value_edit.text() != text: + node.value_edit.setText(text) + value_edit.textChanged.connect(update_node_value) + value_edit_ref = weakref.ref(value_edit) + def sync_value(): + edit = value_edit_ref() + if edit is not None and edit.text() != node.value_edit.text(): + edit.setText(node.value_edit.text()) + node.value_edit.textChanged.connect(sync_value) + self.layout.addRow("Value", value_edit) + self.fields['value'] = value_edit + # --- Name field (QLineEdit or QTextEdit) for other nodes (but not Str_PGraphicalNode) --- + elif hasattr(node, 'name_edit') and node.__class__.__name__ != 'Str_PGraphicalNode': + import weakref + name_edit = QLineEdit(node.name_edit.text()) + # Live sync: update node name as user types + name_edit.textChanged.connect(lambda text: self._update_node_name(text)) + # Sync node -> panel, avoid accessing deleted widget + name_edit_ref = weakref.ref(name_edit) + def sync_name(): + edit = name_edit_ref() + if edit is not None and edit.text() != node.name_edit.text(): + edit.setText(node.name_edit.text()) + node.name_edit.textChanged.connect(sync_name) + self.layout.addRow("Name", name_edit) + self.fields['name'] = name_edit + # --- Tensor_PGraphicalNode fields (name, size, components) --- + if ( + hasattr(node, 'size_spin') and hasattr(node, 'component_spins') + ): + # Size field + import weakref + size_spin = QSpinBox() + size_spin.setRange(node.size_spin.minimum(), node.size_spin.maximum()) + size_spin.setValue(node.size_spin.value()) + # Update node when panel changes + def on_panel_size_changed(v): + node.size_spin.setValue(v) + # Immediately refresh panel to match new size + self.set_node(node) + size_spin.valueChanged.connect(on_panel_size_changed) + # Update panel when node changes, avoid deleted widget + size_spin_ref = weakref.ref(size_spin) + def sync_size(): + spin = size_spin_ref() + if spin is not None and spin.value() != node.size_spin.value(): + spin.setValue(node.size_spin.value()) + node.size_spin.valueChanged.connect(sync_size) + self.layout.addRow("Size", size_spin) + self.fields['size'] = size_spin + + # Components fields + for i, node_comp_spin in enumerate(node.component_spins): + comp_spin = QDoubleSpinBox() + comp_spin.setDecimals(node_comp_spin.decimals()) + comp_spin.setRange(node_comp_spin.minimum(), node_comp_spin.maximum()) + comp_spin.setValue(node_comp_spin.value()) + # Update node when panel changes, but only if idx is valid + def set_value_safe(v, idx=i): + if idx < len(node.component_spins): + node.component_spins[idx].setValue(v) + comp_spin.valueChanged.connect(set_value_safe) + # Update panel when node changes + def make_node_to_panel_sync(panel_spin=comp_spin, node_spin=node_comp_spin): + def sync(): + if abs(panel_spin.value() - node_spin.value()) > 1e-8: + panel_spin.setValue(node_spin.value()) + node_spin.valueChanged.connect(sync) + make_node_to_panel_sync() + self.layout.addRow(f"Component {i+1}", comp_spin) + self.fields[f"component_{i+1}"] = comp_spin + # No fallback name field for List_CPGraphicalNode; handled in node UI only + + # --- Vector components (x, y, z) --- + if hasattr(node, 'x_spin') and hasattr(node, 'y_spin') and hasattr(node, 'z_spin'): + for comp in ['x', 'y', 'z']: + spin = QDoubleSpinBox() + spin.setDecimals(6) + spin.setRange(-1e6, 1e6) + node_spin = getattr(node, f"{comp}_spin") + spin.setValue(node_spin.value()) + # Update node when panel changes + spin.valueChanged.connect(lambda v, c=comp: getattr(node, f"{c}_spin").setValue(v)) + # Update panel when node changes + def make_node_to_panel_sync(panel_spin=spin, node_spin=node_spin): + def sync(): + if abs(panel_spin.value() - node_spin.value()) > 1e-8: + panel_spin.setValue(node_spin.value()) + node_spin.valueChanged.connect(sync) + make_node_to_panel_sync() + self.layout.addRow(comp.upper(), spin) + self.fields[comp] = spin + + # --- Dimension set (Dim_Set_PGraphicalNode) --- + if hasattr(node, 'dim_names') and hasattr(node, 'dim_spins'): + for name, spinbox in zip(node.dim_names, node.dim_spins): + spin = QSpinBox() + spin.setRange(-10, 10) + spin.setValue(spinbox.value()) + # Update node when panel changes + spin.valueChanged.connect(lambda v, s=spinbox: s.setValue(v)) + # Update panel when node changes + def make_node_to_panel_sync(panel_spin=spin, node_spin=spinbox): + def sync(): + if panel_spin.value() != node_spin.value(): + panel_spin.setValue(node_spin.value()) + node_spin.valueChanged.connect(sync) + make_node_to_panel_sync() + self.layout.addRow(name, spin) + self.fields[name] = spin + + # --- List elements (List_CPGraphicalNode) --- + if hasattr(node, 'elements_text'): + elements_edit = QLineEdit(node.elements_text.toPlainText().replace('\n', ' | ')) + # Two-way sync: panel -> node + def update_elements(): + new_text = elements_edit.text().replace(' | ', '\n') + if node.elements_text.toPlainText() != new_text: + node.elements_text.setPlainText(new_text) + elements_edit.editingFinished.connect(update_elements) + # Two-way sync: node -> panel + import weakref + elements_edit_ref = weakref.ref(elements_edit) + def sync_elements(): + edit = elements_edit_ref() + node_text = node.elements_text.toPlainText().replace('\n', ' | ') + if edit is not None and edit.text() != node_text: + edit.setText(node_text) + node.elements_text.textChanged.connect(sync_elements) + self.layout.addRow("Elements", elements_edit) + self.fields['elements'] = elements_edit + if hasattr(node, 'isnode_checkbox'): + isnode_cb = QCheckBox() + isnode_cb.setChecked(node.isnode_checkbox.isChecked()) + # Improve visibility: black outline + isnode_cb.setStyleSheet(""" + QCheckBox { color: black; font-size: 11px; font-family: Arial; } + QCheckBox::indicator { width: 13px; height: 13px; border: 2px solid black; border-radius: 3px; background-color: #fff; } + QCheckBox::indicator:checked { background-color: #0078d4; border: 2px solid #0078d4; } + """) + # Two-way sync: panel -> node + def update_isnode(val): + if node.isnode_checkbox.isChecked() != val: + node.isnode_checkbox.setChecked(val) + isnode_cb.toggled.connect(update_isnode) + # Two-way sync: node -> panel + import weakref + isnode_cb_ref = weakref.ref(isnode_cb) + def sync_isnode(): + cb = isnode_cb_ref() + if cb is not None and cb.isChecked() != node.isnode_checkbox.isChecked(): + cb.setChecked(node.isnode_checkbox.isChecked()) + node.isnode_checkbox.toggled.connect(sync_isnode) + self.layout.addRow("Is Node List", isnode_cb) + self.fields['isnode'] = isnode_cb + + # --- Value field (for Int_P, Flt_P, etc) --- + if hasattr(node, 'value_spinbox'): + spin = QSpinBox() + spin.setValue(node.value_spinbox.value()) + spin.valueChanged.connect(lambda v: self._update_node_value(v)) + self.layout.addRow("Value", spin) + self.fields['value'] = spin + elif hasattr(node, 'value_spin'): + dspin = QDoubleSpinBox() + dspin.setDecimals(10) + dspin.setValue(node.value_spin.value()) + dspin.valueChanged.connect(lambda v: self._update_node_value(v)) + self.layout.addRow("Value", dspin) + self.fields['value'] = dspin + + # --- Enum items (for Enm_P) --- + if hasattr(node, 'items_list'): + # Multi-select QListWidget for items + items_widget = QListWidget() + items_widget.setSelectionMode(QListWidget.SelectionMode.MultiSelection) + items = [node.items_list.item(i).text() for i in range(node.items_list.count())] + items_widget.addItems(items) + # Sync selection from node to panel + for i, item in enumerate(items): + if node.items_list.item(i).isSelected(): + items_widget.item(i).setSelected(True) + # When user changes selection in panel, update node and default combo + def on_items_selection_changed(): + selected = set() + for i in range(items_widget.count()): + sel = items_widget.item(i).isSelected() + node.items_list.item(i).setSelected(sel) + if sel: + selected.add(items_widget.item(i).text()) + # Update default combo in both panel and node + update_default_combo(selected) + items_widget.itemSelectionChanged.connect(on_items_selection_changed) + self.layout.addRow("Items", items_widget) + self.fields['items'] = items_widget + + # QComboBox for default value, only from selected items + default_combo = QComboBox() + selected_items = [items_widget.item(i).text() for i in range(items_widget.count()) if items_widget.item(i).isSelected()] + default_combo.addItems(selected_items) + # Sync default from node to panel + if hasattr(node, 'default_combo'): + default_combo.setCurrentText(node.default_combo.currentText()) + # When user changes default in panel, update node + def on_default_changed(val): + if hasattr(node, 'default_combo'): + node.default_combo.setCurrentText(val) + default_combo.currentTextChanged.connect(on_default_changed) + self.layout.addRow("Default", default_combo) + self.fields['default'] = default_combo + + # Helper to update default combo when items selection changes + def update_default_combo(selected=None): + if selected is None: + selected = [items_widget.item(i).text() for i in range(items_widget.count()) if items_widget.item(i).isSelected()] + # Ensure selected is a list for indexing + if not isinstance(selected, list): + selected = list(selected) + cur = default_combo.currentText() + default_combo.blockSignals(True) + default_combo.clear() + default_combo.addItems(selected) + if cur in selected: + default_combo.setCurrentText(cur) + elif selected: + default_combo.setCurrentText(selected[0]) + default_combo.blockSignals(False) + # Store for later use (if needed) + self._update_enum_default_combo = update_default_combo + # Initial update + update_default_combo() + + # --- Min/Max (for Flt_P) --- + if hasattr(node, 'min_spin'): + minspin = QDoubleSpinBox(); minspin.setDecimals(2) + minspin.setValue(node.min_spin.value()) + minspin.valueChanged.connect(lambda v: self._update_node_min(v)) + self.layout.addRow("Min", minspin) + self.fields['min'] = minspin + if hasattr(node, 'max_spin'): + maxspin = QDoubleSpinBox(); maxspin.setDecimals(2) + maxspin.setRange(0.0, 100.0) + maxspin.setValue(100.00) + maxspin.valueChanged.connect(lambda v: self._update_node_max(v)) + self.layout.addRow("Max", maxspin) + self.fields['max'] = maxspin + + # --- Element Order (for Node_C, Key_C) --- + if hasattr(node, 'show_element_order_dialog'): + btn = QPushButton("Set Element Order...") + btn.clicked.connect(node.show_element_order_dialog) + + # Drag-and-drop list for element order + order_list = QListWidget() + order_list.setDragDropMode(QListWidget.DragDropMode.InternalMove) + order_list.setDefaultDropAction(Qt.DropAction.MoveAction) + order_list.setSelectionMode(QListWidget.SelectionMode.SingleSelection) + order_list.setMaximumHeight(80) + # Get current order or default + if getattr(node, 'custom_element_order', None): + order_names = list(node.custom_element_order) + else: + # Use current connected element names as default + order_names = node.get_connected_element_names() if hasattr(node, 'get_connected_element_names') else [] + order_list.addItems(order_names) + # Sync: when user reorders, update node and trigger callback + def on_order_changed(): + new_order = [order_list.item(i).text() for i in range(order_list.count())] + node.custom_element_order = new_order + if hasattr(node, 'on_property_changed') and node.on_property_changed: + node.on_property_changed(node, 'element_order', new_order) + order_list.model().rowsMoved.connect(lambda *_: on_order_changed()) + # Also update list if order changes from dialog + def refresh_order_list(): + order_list.clear() + if getattr(node, 'custom_element_order', None): + order_names = list(node.custom_element_order) + else: + order_names = node.get_connected_element_names() if hasattr(node, 'get_connected_element_names') else [] + order_list.addItems(order_names) + # Auto-refresh panel when tensor size changes + def refresh_panel_on_size(): + if self.node is node: + self.set_node(node) + if hasattr(node, 'size_spin'): + node.size_spin.valueChanged.connect(refresh_panel_on_size) + # Store for later refresh + self.fields['element_order_list'] = order_list + # Compose row + row_widget = QWidget() + row_layout = QFormLayout(row_widget) + row_layout.setContentsMargins(0, 0, 0, 0) + row_layout.addRow(btn, order_list) + self.layout.addRow("Element Order", row_widget) + # Patch set_node to refresh the list when panel is rebuilt + self.refresh_order_list = refresh_order_list + + # --- Case Name (for Case Folder Output) --- + if hasattr(node, 'case_name_edit'): + case_edit = QLineEdit(node.case_name_edit.text()) + # Two-way sync: panel -> node + def update_node_case_name(text): + # Use set_case_name_from_panel if available to avoid signal loops + if hasattr(node, 'set_case_name_from_panel'): + node.set_case_name_from_panel(text) + else: + node.case_name_edit.setText(text) + case_edit.textChanged.connect(update_node_case_name) + # Two-way sync: node -> panel + import weakref + case_edit_ref = weakref.ref(case_edit) + def sync_case_name(): + edit = case_edit_ref() + node_text = node.case_name_edit.text() + if edit is not None and edit.text() != node_text: + edit.setText(node_text) + node.case_name_edit.textChanged.connect(sync_case_name) + self.layout.addRow("Case Name", case_edit) + self.fields['case_name'] = case_edit + + # --- Output Filename (for Output node) --- + if hasattr(node, 'filename_edit'): + fname_edit = QLineEdit(node.filename_edit.text()) + # Two-way sync: panel -> node + def update_node_filename(text): + # Use set_filename_from_panel if available to avoid signal loops + if hasattr(node, 'set_filename_from_panel'): + node.set_filename_from_panel(text) + else: + node.filename_edit.setText(text) + fname_edit.textChanged.connect(update_node_filename) + # Two-way sync: node -> panel + import weakref + fname_edit_ref = weakref.ref(fname_edit) + def sync_filename(): + edit = fname_edit_ref() + node_text = node.filename_edit.text() + if edit is not None and edit.text() != node_text: + edit.setText(node_text) + node.filename_edit.textChanged.connect(sync_filename) + self.layout.addRow("Filename", fname_edit) + self.fields['filename'] = fname_edit + + def _update_node_name(self, text): + if self.node and hasattr(self.node, 'name_edit'): + node_edit = self.node.name_edit + if node_edit.text() != text: + old_block = node_edit.blockSignals(True) + node_edit.setText(text) + node_edit.blockSignals(old_block) + + def _update_node_value(self, value): + if self.node: + if hasattr(self.node, 'value_spinbox'): + self.node.value_spinbox.setValue(value) + elif hasattr(self.node, 'value_spin'): + self.node.value_spin.setValue(value) + + def _update_enum_default(self, value): + if self.node and hasattr(self.node, 'default_combo'): + self.node.default_combo.setCurrentText(value) + + def _update_node_min(self, value): + if self.node and hasattr(self.node, 'min_spin'): + self.node.min_spin.setValue(value) + + def _update_node_max(self, value): + if self.node and hasattr(self.node, 'max_spin'): + self.node.max_spin.setValue(value) + + def _update_case_name(self, text): + if self.node and hasattr(self.node, 'case_name_edit'): + self.node.case_name_edit.setText(text) + + def _update_filename(self, text): + if self.node and hasattr(self.node, 'filename_edit'): + self.node.filename_edit.setText(text) \ No newline at end of file -- cgit