fun with blocks

This commit is contained in:
2026-05-15 02:51:19 -07:00
parent 17789a7615
commit 66f16d1771
2 changed files with 452 additions and 96 deletions
+24 -19
View File
@@ -1,6 +1,6 @@
{
"individual": [
{ "name": "New Event", "type": "begin" },
{ "name": "New Event", "type": "begin", "sub_type": "created_event" },
{
"name": "Average",
"type": "middle",
@@ -37,24 +37,29 @@
{ "name": "End", "type": "end" },
{ "name": "Joint 1", "type": "logic" },
{ "name": "Joint 2", "type": "logic" },
{ "name": "Joint 3", "type": "logic" },
{ "name": "Joint 4", "type": "logic" },
{ "name": "Joint 5", "type": "logic" },
{ "name": "Joint 6", "type": "logic" },
{ "name": "Joint 7", "type": "logic" },
{ "name": "Joint 8", "type": "logic" },
{ "name": "Joint 9", "type": "logic" },
{ "name": "Joint 10", "type": "logic" },
{ "name": "Joint 11", "type": "logic" },
{ "name": "Joint 12", "type": "logic" },
{ "name": "Joint 13", "type": "logic" },
{ "name": "Joint 14", "type": "logic" },
{ "name": "Joint 15", "type": "logic" },
{ "name": "Joint 16", "type": "logic" },
{ "name": "Joint 17", "type": "logic" },
{ "name": "New Calculated Joint", "type": "begin" }
{ "name": "Nose", "type": "logic" },
{ "name": "Left Eye", "type": "logic" },
{ "name": "Right Eye", "type": "logic" },
{ "name": "Left Ear", "type": "logic" },
{ "name": "Right Ear", "type": "logic" },
{ "name": "Left Shoulder", "type": "logic" },
{ "name": "Right Shoulder", "type": "logic" },
{ "name": "Left Elbow", "type": "logic" },
{ "name": "Right Elbow", "type": "logic" },
{ "name": "Left Wrist", "type": "logic" },
{ "name": "Right Wrist", "type": "logic" },
{ "name": "Left Hip", "type": "logic" },
{ "name": "Right Hip", "type": "logic" },
{ "name": "Left Knee", "type": "logic" },
{ "name": "Right Knee", "type": "logic" },
{ "name": "Left Ankle", "type": "logic" },
{ "name": "Right Ankle", "type": "logic" },
{ "name": "New Calculated Joint", "type": "begin", "sub_type": "created_joint" }
],
"group": [
{ "name": "New Activity", "type": "begin" },
{ "name": "End Activity", "type": "end" }
]
}
+428 -77
View File
@@ -34,7 +34,7 @@ from pose_worker import run_pose_analysis
from batch_processing import BatchProcessorDialog
import PySide6
from PySide6.QtWidgets import (QApplication, QDoubleSpinBox, QFormLayout, QGraphicsItem, QGraphicsProxyWidget, QGraphicsTextItem, QLineEdit, QListWidget, QListWidgetItem, QMainWindow, QProgressDialog, QSizePolicy, QStyleOptionGraphicsItem, QTabBar, QWidget, QVBoxLayout, QGraphicsView, QGraphicsScene,
from PySide6.QtWidgets import (QApplication, QDoubleSpinBox, QFormLayout, QGraphicsItem, QGraphicsProxyWidget, QGraphicsTextItem, QLineEdit, QListWidget, QListWidgetItem, QMainWindow, QProgressDialog, QSizePolicy, QStyleOptionGraphicsItem, QTabBar, QTreeWidget, QTreeWidgetItem, QWidget, QVBoxLayout, QGraphicsView, QGraphicsScene,
QHBoxLayout, QSplitter, QLabel, QPushButton, QComboBox, QInputDialog, QGraphicsRectItem,
QFileDialog, QScrollArea, QMessageBox, QSlider, QTextEdit, QGroupBox, QGridLayout, QCheckBox, QTabWidget, QProgressBar)
from PySide6.QtCore import QEvent, QObject, Qt, QThread, Signal, QUrl, QRectF, QPointF, QRect, QSizeF, QTimer
@@ -3165,10 +3165,10 @@ from PySide6.QtWidgets import QGraphicsItem, QGraphicsSimpleTextItem, QInputDial
class BlockSignalProxy(QObject):
nameChanged = Signal(str, str)
class PuzzleBlock(QGraphicsPathItem):
def __init__(self, b_type, label, parent_item=None, fields=None):
def __init__(self, b_type, label, parent_item=None, fields=None, sub_type="standard"):
super().__init__(parent_item)
self.signals = BlockSignalProxy()
@@ -3178,6 +3178,8 @@ class PuzzleBlock(QGraphicsPathItem):
self.width = 160
self.height = 50
self.tab_size = 15
self.fields = fields if fields is not None else []
self.sub_type = sub_type
self.parent_block = None
self.child_block = None
@@ -3404,15 +3406,38 @@ class PuzzleBlock(QGraphicsPathItem):
self.signals.nameChanged.emit(old_text, new_text)
class BlockLibrary(QListWidget):
def force_resize(self):
"""Recalculates height based on layout and updates the visual path."""
if hasattr(self, 'container') and self.container.layout():
# Force the layout to settle
self.container.layout().activate()
# Update height based on the new size hint
self.height = 50 + self.container.layout().sizeHint().height()
self.update_path()
class BlockLibrary(QTreeWidget):
def __init__(self, parent_tab, stringy):
super().__init__()
self.setDragEnabled(True)
self.setHeaderHidden(True) # Hide the "1" header at the top
self.setIndentation(15)
self.setAnimated(True)
self.mode = stringy
self.parent_tab = parent_tab # Reference to main tab to access canvas
self.categories = {}
self.type_themes = {
"begin": {"label": "ENTRY EVENTS", "color": "#2e7d32"}, # Dark Green
"middle": {"label": "PROCESSORS", "color": "#1565c0"}, # Dark Blue
"end": {"label": "EXPORTS", "color": "#c62828"}, # Dark Red
"logic": {"label": "VARIABLES", "color": "#ef6c00"}, # Dark Orange
"created_event": {"label": "CREATED EVENTS", "color": "#6a1b9a"}, # Deep Purple
"created_joint": {"label": "CALCULATED JOINTS", "color": "#00695c"} # Teal/Blue-Green
}
self.load_blocks_from_json("blocks.json")
@@ -3430,35 +3455,84 @@ class BlockLibrary(QListWidget):
for block in blocks:
# NEW: Get fields if they exist, otherwise empty list
fields = block.get('fields', [])
self.add_item(block['name'], block['type'], fields)
self.add_item(block['name'], block['type'], block.get('fields', []), block.get('sub_type', "standard"))
except Exception as e:
print(f"Failed to load block library: {e}")
def import_individual_events(self):
individual_config = "config_individual.json"
if not os.path.exists(individual_config): return
def add_item(self, label, block_type, fields):
item = QListWidgetItem(label)
item.setData(Qt.UserRole, block_type)
# NEW: Store fields list in the item
item.setData(Qt.UserRole + 1, fields)
self.addItem(item)
try:
with open(individual_config, 'r') as f:
save_data = json.load(f)
for block in save_data:
# Check for the specific sub_type we just defined
# We specifically want 'begin' blocks that were marked as events
if block.get('b_type') == "begin":
# Use the custom name from the inputs if available
inputs = block.get('inputs', {})
# Assuming your "New Event" block has a QLineEdit for the name
event_name = next(iter(inputs.values()), block.get('label'))
print(f"[DEBUG] Importing: {event_name}")
self.add_item(event_name, "created_event", fields=[], sub_type="standard")
except Exception as e:
print(f"Error importing individual events: {e}")
def get_or_create_category(self, b_type):
"""Helper to ensure category dropdowns exist and are styled."""
if b_type not in self.categories:
theme = self.type_themes.get(b_type, {"label": b_type.upper(), "color": "#444444"})
# Create top-level category item
cat = QTreeWidgetItem(self)
cat.setText(0, theme["label"])
cat.setBackground(0, QBrush(QColor(theme["color"])))
cat.setForeground(0, QBrush(QColor("white")))
cat.setFlags(cat.flags() & ~Qt.ItemIsDragEnabled) # Categories can't be dragged
self.categories[b_type] = cat
cat.setExpanded(True) # Start with dropdowns open
return self.categories[b_type]
def add_item(self, label, block_type, fields, sub_type):
category = self.get_or_create_category(block_type)
# Create child item under the category
item = QTreeWidgetItem(category)
item.setText(0, label)
# Store metadata
item.setData(0, Qt.UserRole, block_type)
item.setData(0, Qt.UserRole + 1, fields)
item.setData(0, Qt.UserRole + 2, sub_type)
# Optional: Subtle styling for child items
item.setForeground(0, QBrush(QColor("#dddddd")))
def startDrag(self, supportedActions):
item = self.currentItem()
if not item: return
if not item or item.parent() is None:
return
# Pack everything into a clean dictionary
drag_data = {
"type": item.data(0, Qt.UserRole),
"label": item.text(0),
"fields": item.data(0, Qt.UserRole + 1),
"sub_type": item.data(0, Qt.UserRole + 2) # Use the type as subtype by default
}
mime_data = QMimeData()
# NEW: We must include the fields in the drag data!
# Format: type | label | fields_as_json_string
b_type = item.data(Qt.UserRole)
label = item.text()
fields = item.data(Qt.UserRole + 1)
fields_str = json.dumps(fields) # Convert list to string for transfer
mime_data.setText(f"{b_type}|{label}|{fields_str}")
mime_data.setText(json.dumps(drag_data)) # Send as ONE structured string
drag = QDrag(self)
drag.setMimeData(mime_data)
@@ -3468,38 +3542,141 @@ class BlockLibrary(QListWidget):
def add_dynamic_logic_item(self, block_item):
print("asdasd")
"""
Triggered when a 'begin' block is dropped on the canvas.
Adds a corresponding 'logic' block to this library.
Triggered immediately when a 'begin' block is dropped on the canvas.
"""
# 1. Check for duplicates
# We don't want to add "Joint 1" five times if they drag five 'begin' blocks
# 1. Get the actual identity we assigned in dropEvent
# This will now be 'created_joint' or 'created_event' instead of just 'begin'
s_type = getattr(block_item, 'sub_type', 'standard')
label = block_item.label_text
items = self.findItems(label, Qt.MatchExactly)
for item in items:
if item.data(Qt.UserRole) == "logic":
print(f"DEBUG: {label} (logic) already exists in library. Skipping.")
# 2. Route to the correct sidebar category
if s_type == "created_joint":
target_category = "created_joint" # Teal
elif s_type == "created_event":
target_category = "created_event" # Orange (Individual Variables)
else:
return # Don't add standard 'begin' blocks to the logic list
# 3. Prevent Duplicate Sidebar Entries
category_item = self.get_or_create_category(target_category)
for i in range(category_item.childCount()):
if category_item.child(i).text(0) == label:
return
# 2. Add the item as a 'logic' type
# In your system, logic blocks have no fields (they are just variables)
print(f"DEBUG: Dynamically adding '{label}' as a logic block.")
self.add_item(label, "logic", fields=[])
# 4. Update the Sidebar UI immediately
self.add_item(label, target_category, fields=[], sub_type=s_type)
print(f"[DEBUG] Instant Sidebar Update: Added {label} to {target_category}")
def update_item_name(self, old_name, new_name):
print(f"[DEBUG - Library] Searching for items matching: '{old_name}'")
items = self.findItems(old_name, Qt.MatchExactly)
if not items:
print(f"[DEBUG - Library] No items found matching '{old_name}'")
"""Rename logic blocks in the VARIABLES dropdown."""
cat = self.get_or_create_category("created_joint")
for i in range(cat.childCount()):
item = cat.child(i)
if item.text(0) == old_name:
item.setText(0, new_name)
def discover_saved_blocks(self, save_file, target_subtype):
"""Scans save file and populates categories using the helper function."""
if not os.path.exists(save_file):
return
try:
with open(save_file, 'r') as f:
data = json.load(f)
for item in items:
# Ensure we only rename the logic-type items
if item.data(Qt.UserRole) == "logic":
item.setText(new_name)
print(f"[DEBUG - Library] Successfully updated sidebar item to '{new_name}'")
# Use the helper to ensure the Teal or Purple folder exists
target_cat = self.get_or_create_category(target_subtype)
if not target_cat:
print(f"[ERROR] Could not resolve category for: {target_subtype}")
return
for block_data in data:
if block_data.get('sub_type') == target_subtype:
# Resolve name (LineEdit input or generic label)
inputs = block_data.get('inputs', {})
name = next(iter(inputs.values()), block_data.get('label'))
# Scoped duplicate check
is_duplicate = False
for i in range(target_cat.childCount()):
if target_cat.child(i).text(0) == name:
is_duplicate = True
break
if not is_duplicate:
self.add_item(
label=name,
block_type=target_subtype,
fields=[],
sub_type=target_subtype
)
print(f"[DEBUG] Restored '{name}' to {target_cat.text(0)}")
except Exception as e:
print(f"[ERROR] Discovery error: {e}")
def remove_dynamic_logic_item(self, label, s_type):
"""
Surgical removal from the sidebar.
"""
target_label = str(label).strip().lower()
# BLACKLIST: Never search or delete from these category keys.
# These represent your "Factory" templates and "Nose/Eye" entries.
forbidden_categories = ["begin", "entry_events", "standard_nodes"]
# WHITELIST: Only look for the spawns/variables here.
allowed_categories = ["created_joint", "logic", "created_event"]
for cat_key in allowed_categories:
if cat_key in self.categories and cat_key not in forbidden_categories:
cat = self.categories[cat_key]
for i in range(cat.childCount() - 1, -1, -1):
child = cat_item = cat.child(i)
if child.text(0).strip().lower() == target_label:
cat.removeChild(child)
print(f"[DEBUG] Sidebar: Removed '{label}' from {cat_key}")
return # Exit once the spawn is deleted
# def add_dynamic_logic_item(self, block_item):
# """
# Triggered when a 'begin' block is dropped on the canvas.
# Adds a corresponding 'logic' block to this library.
# """
# # 1. Check for duplicates
# # We don't want to add "Joint 1" five times if they drag five 'begin' blocks
# label = block_item.label_text
# items = self.findItems(label, Qt.MatchExactly)
# for item in items:
# if item.data(Qt.UserRole) == "logic":
# print(f"DEBUG: {label} (logic) already exists in library. Skipping.")
# return
# # 2. Add the item as a 'logic' type
# # In your system, logic blocks have no fields (they are just variables)
# print(f"DEBUG: Dynamically adding '{label}' as a logic block.")
# self.add_item(label, "logic", fields=[])
# def update_item_name(self, old_name, new_name):
# print(f"[DEBUG - Library] Searching for items matching: '{old_name}'")
# items = self.findItems(old_name, Qt.MatchExactly)
# if not items:
# print(f"[DEBUG - Library] No items found matching '{old_name}'")
# for item in items:
# # Ensure we only rename the logic-type items
# if item.data(Qt.UserRole) == "logic":
# item.setText(new_name)
# print(f"[DEBUG - Library] Successfully updated sidebar item to '{new_name}'")
from PySide6.QtWidgets import QGraphicsLineItem, QGraphicsRectItem, QGraphicsSimpleTextItem
@@ -3509,6 +3686,7 @@ from PySide6.QtGui import QPen, QColor, QBrush
class TopologyCanvas(QGraphicsView):
beginBlockDropped = Signal(object)
beginBlockDeleted = Signal(str, str)
def __init__(self):
self.scene = QGraphicsScene()
@@ -3521,7 +3699,7 @@ class TopologyCanvas(QGraphicsView):
def on_new_definition_created(self, block_item):
# Connect the block's rename signal to our canvas sync method
print(f"[DEBUG - Canvas] Connecting signal for new block: {block_item.label_text}")
block_item.signals.nameChanged.connect(self.sync_logic_blocks)
self.wire_block_signals(block_item)
def sync_logic_blocks(self, old_name, new_name):
print(f"[DEBUG - Canvas] Syncing. Old: {old_name}, New: {new_name}")
@@ -3569,6 +3747,7 @@ class TopologyCanvas(QGraphicsView):
# Defer the logic to the next event loop cycle (stops the DLL crash)
QTimer.singleShot(0, lambda: self.safe_process_release(global_pos, scene_pos))
def safe_process_release(self, global_pos, scene_pos):
self._processing_release = True
try:
@@ -3602,33 +3781,45 @@ class TopologyCanvas(QGraphicsView):
def dropEvent(self, event):
raw_data = event.mimeData().text()
parts = raw_data.split("|")
if len(parts) < 2: return # Safety check
block_type, label = parts[0], parts[1]
fields = []
try:
# 1. Parse the structured JSON
raw_data = event.mimeData().text()
data = json.loads(raw_data)
# 2. Extract with safe defaults
b_type = data.get("type", "middle")
label = data.get("label", "New Block")
fields = data.get("fields", [])
sub_type = data.get("sub_type", "standard")
# Safe JSON parsing
if len(parts) > 2:
try:
fields = json.loads(parts[2])
except Exception as e:
print(f"DEBUG: Failed to parse: {e}")
print(sub_type)
new_block = PuzzleBlock(block_type, label, fields=fields)
# 3. Determine the visual class of the block
# (Same logic as before, but cleaner)
if b_type == "created_event":
visual_type = "begin"
elif b_type == "created_joint":
visual_type = "logic"
else:
visual_type = b_type
self.scene.addItem(new_block)
# Try snapping, otherwise place at drop location
if not self.perform_snap(new_block, self.mapToScene(event.position().toPoint())):
new_block.setPos(self.mapToScene(event.position().toPoint()))
if block_type == "begin":
self.beginBlockDropped.emit(new_block)
# 4. Create the block
new_block = PuzzleBlock(visual_type, label, fields=fields, sub_type=sub_type)
self.scene.addItem(new_block)
# 5. Position
drop_pos = self.mapToScene(event.position().toPoint())
if not self.perform_snap(new_block, drop_pos):
new_block.setPos(drop_pos)
# 6. Library Feedback
if visual_type == "begin":
self.beginBlockDropped.emit(new_block)
event.acceptProposedAction()
event.acceptProposedAction()
except Exception as e:
print(f"CRITICAL: Failed to parse drag-and-drop JSON: {e}")
def get_stack_extremity(self, block, direction):
@@ -3644,14 +3835,6 @@ class TopologyCanvas(QGraphicsView):
return curr
def keyPressEvent(self, event):
if event.key() == Qt.Key.Key_F7:
self.diagnostic_dump()
if event.key() == Qt.Key.Key_F8:
self.print_logical_graphs()
super().keyPressEvent(event)
def print_logical_graphs(self):
"""Crawls the logical links and prints all current stacks."""
print(f"\n{'='*60}\nCURRENT LOGICAL GRAPHS (F8 Triggered)")
@@ -3957,7 +4140,168 @@ class TopologyCanvas(QGraphicsView):
def save_to_json(self, file_path="config.json"):
data = []
blocks = [item for item in self.scene.items() if isinstance(item, PuzzleBlock)]
# Mapping to store IDs for linking
for block in blocks:
block_data = {
"id": id(block),
"b_type": block.b_type,
"sub_type": getattr(block, 'sub_type', 'standard'),
"label": block.label_text,
"pos": [block.pos().x(), block.pos().y()],
"parent_id": id(block.parent_block) if block.parent_block else None,
"child_id": id(block.child_block) if block.child_block else None,
"host_id": id(block.host_block) if getattr(block, 'host_block', None) else None,
"slot_key": getattr(block, 'current_slot', None),
"fields": block.fields,
"inputs": {k: v.text() for k, v in block.inputs.items() if isinstance(v, QLineEdit)}
}
data.append(block_data)
with open(file_path, 'w') as f:
json.dump(data, f, indent=4)
print(f"Configuration saved to {file_path}")
def load_from_json(self, file_path="config.json"):
if not os.path.exists(file_path):
return
self.scene.clear()
with open(file_path, 'r') as f:
data = json.load(f)
id_map = {}
# 1. Create all blocks
for b in data:
# Note: Pass the original fields if you have them,
# or ensure PuzzleBlock handles empty fields gracefully
new_block = PuzzleBlock(b['b_type'], b['label'], fields=b['fields'], sub_type=b.get('sub_type', 'standard'))
new_block.setPos(b['pos'][0], b['pos'][1])
# Restore text inputs
for key, val in b.get('inputs', {}).items():
if key in new_block.inputs:
widget = new_block.inputs[key]
if isinstance(widget, QLineEdit):
widget.setText(val)
# CRITICAL: Force the block to wrap around its new content
new_block.force_resize()
self.scene.addItem(new_block)
id_map[b['id']] = new_block
self.wire_block_signals(new_block)
# 2. Re-establish logical links
for b in data:
curr = id_map[b['id']]
# Connect Chained Stacks (Vertical)
if b.get('child_id') in id_map:
curr.child_block = id_map[b['child_id']]
if b.get('parent_id') in id_map:
curr.parent_block = id_map[b['parent_id']]
# Connect Embedded Logic Blocks (Horizontal/Internal)
if b.get('host_id') in id_map:
host = id_map[b['host_id']]
slot_key = b['slot_key']
# Re-running embed_logic_block will fix the internal Z-value and position
self.embed_logic_block(curr, host, slot_key)
if hasattr(self, 'block_library_ref') and self.block_library_ref:
# Determine what we are looking for based on current editor mode
target = "created_joint" if self.block_library_ref.mode == "individual" else "created_event"
self.block_library_ref.discover_saved_blocks(file_path, target)
# 3. Final visual refresh
self.scene.update()
def keyPressEvent(self, event):
if event.key() == Qt.Key.Key_F7:
self.diagnostic_dump()
if event.key() == Qt.Key.Key_F8:
self.print_logical_graphs()
if event.key() in (Qt.Key_Delete, Qt.Key_Backspace):
# Get all selected items on the canvas
selected_items = self.scene.selectedItems()
for item in selected_items:
if isinstance(item, PuzzleBlock):
self.delete_block(item)
else:
# Crucial: let other keys (like arrows for moving blocks) work normally
super().keyPressEvent(event)
def delete_block(self, block):
"""
Surgical Deletion with Orphan Cleanup:
1. Removes master definition and signals sidebar.
2. Scans canvas for any 'logic' instances of this block and deletes them.
"""
if not block:
return
# 1. IDENTIFY IDENTITY
v_type = getattr(block, 'b_type', 'middle')
s_type = getattr(block, 'sub_type', 'standard')
label = block.label_text
# 2. TRIGGER SIDEBAR & ORPHAN CLEANUP
# Only if it's a master definition (Begin) of a dynamic type
if v_type == "begin" and s_type in ["created_joint", "created_event"]:
print(f"[DEBUG] Master Deleted: {label}. Cleaning orphans...")
# Signal sidebar removal
self.beginBlockDeleted.emit(label, s_type)
# CLEANUP ORPHANS: Find every logic block on the canvas with the same name
for item in self.scene.items():
# We look for blocks that are NOT the one we are currently deleting
# and match the name and the 'logic' type.
if item != block and hasattr(item, 'b_type') and item.b_type == "logic":
if item.label_text == label:
print(f"[DEBUG] Removing orphaned instance: {label}")
# We call scene.removeItem directly to avoid recursive signal loops
self.scene.removeItem(item)
# 3. HANDLE VERTICAL CONNECTIONS (STREAMS)
if hasattr(block, 'child_block') and block.child_block:
block.child_block.parent_block = None
if hasattr(block, 'parent_block') and block.parent_block:
block.parent_block.child_block = None
# 4. HANDLE SLOTS (Embedded logic blocks)
if hasattr(block, 'slots'):
for slot_key, embedded_item in list(block.slots.items()):
if embedded_item:
self.delete_block(embedded_item)
block.slots[slot_key] = None
# 5. PHYSICAL REMOVAL OF THE TARGET BLOCK
if block in self.scene.items():
self.scene.removeItem(block)
def wire_block_signals(self, block_item):
"""Ensures a block's internal changes are reflected in the UI/Sidebar."""
if hasattr(block_item, 'signals') and hasattr(block_item.signals, 'nameChanged'):
# Disconnect first to prevent double-triggering if called twice
try:
block_item.signals.nameChanged.disconnect(self.sync_logic_blocks)
except:
pass
block_item.signals.nameChanged.connect(self.sync_logic_blocks)
print(f"[DEBUG - CANVAS] Wired signals for: {block_item.label_text}")
class ModelParameterConfigurationTab(QWidget):
@@ -4023,6 +4367,7 @@ class ModelParameterConfigurationTab(QWidget):
self.setup_ui()
QTimer.singleShot(0, lambda: self.canvas.load_from_json(f"config_{self.mode}.json"))
def setup_ui(self):
@@ -4044,6 +4389,7 @@ class ModelParameterConfigurationTab(QWidget):
self.canvas.block_library_ref = self.block_library
self.canvas.beginBlockDropped.connect(self.block_library.add_dynamic_logic_item)
self.canvas.beginBlockDeleted.connect(self.block_library.remove_dynamic_logic_item)
# Reuse your existing Inspector logic
self.inspector_scroll = QScrollArea()
@@ -4058,6 +4404,11 @@ class ModelParameterConfigurationTab(QWidget):
sidebar_layout.addWidget(QLabel("Properties"))
sidebar_layout.addWidget(self.inspector_scroll, 1)
self.save_btn = QPushButton("Save Configuration")
# Save specifically for this 'mode' so different tabs don't overwrite each other
self.save_btn.clicked.connect(lambda: self.canvas.save_to_json(f"config_{self.mode}.json"))
sidebar_layout.addWidget(self.save_btn)
# Add to main splitter
self.horizontal_splitter.addWidget(self.canvas)
self.horizontal_splitter.addWidget(sidebar_container)