summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorNavya-18172025-07-20 23:42:06 +0530
committerNavya-18172025-07-20 23:42:06 +0530
commite977b17bfdd1d40e57f8a39957c216fac7fc645f (patch)
treeca906dcdb3773024c328635039455e14e33d0d5b
parent8a73c161ab76ec48f87835deb5e3b5931c08dbeb (diff)
downloadpyvnt_node_editor-e977b17bfdd1d40e57f8a39957c216fac7fc645f.tar.gz
pyvnt_node_editor-e977b17bfdd1d40e57f8a39957c216fac7fc645f.tar.bz2
pyvnt_node_editor-e977b17bfdd1d40e57f8a39957c216fac7fc645f.zip
Added properties panel and updated various files
-rw-r--r--.gitignore26
-rw-r--r--README.md13
-rw-r--r--loader/node_converter.py4
-rw-r--r--main.py2
-rw-r--r--nodes/base_graphical_node.py34
-rw-r--r--nodes/case_folder_output_graphical.py6
-rw-r--r--nodes/enm_p_graphical.py18
-rw-r--r--nodes/flt_p_graphical.py23
-rw-r--r--nodes/int_p_graphical.py12
-rw-r--r--nodes/key_c_graphical.py142
-rw-r--r--nodes/list_cp_graphical.py23
-rw-r--r--nodes/node_c_graphical.py30
-rw-r--r--nodes/output_graphical.py16
-rw-r--r--nodes/str_p_graphical.py4
-rw-r--r--nodes/tensor_p_graphical.py52
-rw-r--r--nodes/vector_p_graphical.py4
-rw-r--r--requirements.txt3
-rw-r--r--utils/case_utils.py2
-rw-r--r--view/commands.py11
-rw-r--r--view/main_window.py82
-rw-r--r--view/properties_panel.py426
21 files changed, 796 insertions, 137 deletions
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