start of config editor

This commit is contained in:
2026-05-01 13:19:43 -07:00
parent 2aa000da04
commit 3f4c4163f0
+681 -9
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, QLineEdit, QListWidget, QListWidgetItem, QMainWindow, QProgressDialog, QTabBar, QWidget, QVBoxLayout, QGraphicsView, QGraphicsScene,
from PySide6.QtWidgets import (QApplication, QDoubleSpinBox, QGraphicsItem, QLineEdit, QListWidget, QListWidgetItem, QMainWindow, QProgressDialog, QStyleOptionGraphicsItem, QTabBar, QWidget, QVBoxLayout, QGraphicsView, QGraphicsScene,
QHBoxLayout, QSplitter, QLabel, QPushButton, QComboBox, QInputDialog,
QFileDialog, QScrollArea, QMessageBox, QSlider, QTextEdit, QGroupBox, QGridLayout, QCheckBox, QTabWidget, QProgressBar)
from PySide6.QtCore import QEvent, Qt, QThread, Signal, QUrl, QRectF, QPointF, QRect, QSizeF, QTimer
@@ -220,6 +220,40 @@ class OpenFileWindow(QWidget):
pkl_layout.addWidget(self.lbl_pkl_path, 1, 1)
main_layout.addWidget(self.pkl_group)
# section 3.5
# --- Velocities and Deviations Section ---
self.calc_group = QGroupBox("Calculated Events")
self.calc_group.setCheckable(True)
self.calc_group.setChecked(False)
calc_layout = QGridLayout(self.calc_group)
# --- Velocity Row ---
self.cb_velocity = QCheckBox("Enable Velocities")
self.cb_velocity.setChecked(True)
self.spin_vel_threshold = QDoubleSpinBox()
self.spin_vel_threshold.setRange(0.0, 999.99)
self.spin_vel_threshold.setValue(15)
self.spin_vel_threshold.setSuffix(" px/s") # Optional: add units for clarity
# --- Deviation Row ---
self.cb_deviation = QCheckBox("Enable Deviations")
self.cb_deviation.setChecked(True)
self.spin_dev_threshold = QDoubleSpinBox()
self.spin_dev_threshold.setRange(0.0, 999.99)
self.spin_dev_threshold.setValue(80)
self.spin_dev_threshold.setSuffix(" px") # Optional: add units for clarity
# Add to Grid: (widget, row, column)
calc_layout.addWidget(self.cb_velocity, 0, 0)
calc_layout.addWidget(QLabel("Vel. Threshold:"), 0, 1)
calc_layout.addWidget(self.spin_vel_threshold, 0, 2)
calc_layout.addWidget(self.cb_deviation, 1, 0)
calc_layout.addWidget(QLabel("Dev. Threshold:"), 1, 1)
calc_layout.addWidget(self.spin_dev_threshold, 1, 2)
main_layout.addWidget(self.calc_group)
# --- Section 4: Inference ---
self.cfg_group = QGroupBox("Inference Settings")
c_grid = QGridLayout(self.cfg_group)
@@ -445,6 +479,12 @@ class OpenFileWindow(QWidget):
# ML Model Data
"use_pkl": self.pkl_group.isChecked(),
"pkl_path": self.pkl_path if self.pkl_group.isChecked() else None,
"use_calculations": self.calc_group.isChecked(),
"velocity_enabled": self.cb_velocity.isChecked(),
"velocity_threshold": self.spin_vel_threshold.value(),
"deviation_enabled": self.cb_deviation.isChecked(),
"deviation_threshold": self.spin_dev_threshold.value(),
# Inference Settings
"use_pose": not self.check_bypass_inference.isChecked(),
@@ -458,6 +498,7 @@ from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QPushButton,
QLabel, QFileDialog, QFrame, QComboBox)
from PySide6.QtCore import Qt
class TrainModelWindow(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
@@ -638,6 +679,168 @@ class TrainModelWindow(QDialog):
import json
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QListWidget, QListWidgetItem,
QFileDialog, QCheckBox, QMessageBox)
from PySide6.QtCore import Qt
class ExportTimelineJsonWindow(QDialog):
def __init__(self, timeline_data, fps=30.0, parent=None):
super().__init__(parent)
self.setWindowTitle("Export Timeline Data")
self.setFixedSize(500, 550)
self.timeline_data = timeline_data
self.fps = fps
self.output_path = None
self.setup_ui()
def setup_ui(self):
layout = QVBoxLayout(self)
layout.setSpacing(12)
# --- Section 1: Output Location ---
self.path_display = QLabel("No output file selected...")
self.path_display.setStyleSheet("background: #1e1e1e; padding: 8px; border-radius: 3px;")
btn_browse = QPushButton("Select Output Location")
btn_browse.clicked.connect(self.browse_file)
layout.addWidget(QLabel("Export Destination:"))
layout.addWidget(self.path_display)
layout.addWidget(btn_browse)
# --- Section 2: Track Selection ---
layout.addWidget(QLabel("Select Tracks to Include:"))
self.track_list = QListWidget()
self.populate_track_list()
layout.addWidget(self.track_list)
# --- Section 3: 'Fancy' Calculations Filter ---
self.cb_fancy = QCheckBox("Apply Fancy Filtering")
self.cb_fancy.setToolTip("Drops any Dev_ or Vel_ track events that overlap with an active BORIS event.")
layout.addWidget(self.cb_fancy)
layout.addStretch()
# --- Final Actions ---
button_box = QHBoxLayout()
self.btn_export = QPushButton("Export JSON")
self.btn_export.setEnabled(False)
self.btn_export.setStyleSheet("background-color: #2e7d32; font-weight: bold; padding: 8px;")
self.btn_export.clicked.connect(self.perform_export)
btn_cancel = QPushButton("Cancel")
btn_cancel.clicked.connect(self.reject)
button_box.addWidget(btn_cancel)
button_box.addWidget(self.btn_export)
layout.addLayout(button_box)
def populate_track_list(self):
"""Populates the list widget with all available tracks, defaulting to checked."""
for track_name in sorted(self.timeline_data.keys()):
item = QListWidgetItem(track_name)
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
item.setCheckState(Qt.Checked)
self.track_list.addItem(item)
def browse_file(self):
file_path, _ = QFileDialog.getSaveFileName(
self, "Save Timeline JSON", "", "JSON Files (*.json)"
)
if file_path:
# Ensure extension
if not file_path.endswith('.json'):
file_path += '.json'
self.output_path = file_path
self.path_display.setText(file_path)
self.btn_export.setEnabled(True)
def perform_export(self):
if not self.output_path:
return
# 1. Get explicitly selected tracks
selected_tracks = []
for i in range(self.track_list.count()):
item = self.track_list.item(i)
if item.checkState() == Qt.Checked:
selected_tracks.append(item.text())
# 2. Gather BORIS intervals for the "Fancy" overlap check
do_fancy = self.cb_fancy.isChecked()
boris_intervals = []
if do_fancy:
for track_name in selected_tracks:
# Assuming BORIS events don't start with Dev_ or Vel_
if not track_name.startswith(("Dev_", "Vel_")):
for ev in self.timeline_data.get(track_name, []):
boris_intervals.append((ev[0], ev[1]))
# 3. Process events into a flat list
flat_events = []
for track_name in selected_tracks:
is_calc_track = track_name.startswith(("Dev_", "Vel_"))
events = self.timeline_data.get(track_name, [])
for ev in events:
start_f = ev[0]
end_f = ev[1]
# 'Fancy' Logic: Skip this event if it's a calc track and overlaps with BORIS
if do_fancy and is_calc_track:
overlap_found = False
for (b_start, b_end) in boris_intervals:
# Standard math for checking if two intervals overlap
if max(start_f, b_start) <= min(end_f, b_end):
overlap_found = True
break
if overlap_found:
continue # Drop completely
# Append valid events
flat_events.append({
"track_name": track_name,
"start_frame": int(start_f),
"start_sec": round(start_f / self.fps, 3),
"end_frame": int(end_f),
"end_sec": round(end_f / self.fps, 3)
})
# 4. Order events chronologically by start frame
flat_events.sort(key=lambda x: x["start_frame"])
# 5. Build final JSON structure
all_possible_tracks = list(self.timeline_data.keys())
export_payload = {
"metadata": {
"fps": self.fps,
"total_events_exported": len(flat_events),
"fancy_filtering_applied": do_fancy,
"all_possible_tracks": all_possible_tracks
},
"events": flat_events
}
# 6. Save to disk
try:
with open(self.output_path, 'w') as f:
json.dump(export_payload, f, indent=4)
self.accept() # Close the dialog successfully
except Exception as e:
QMessageBox.critical(self, "Export Error", f"Failed to write JSON:\n{str(e)}")
class AboutWindow(QWidget):
"""
Simple About window displaying basic application information.
@@ -1134,8 +1337,8 @@ class TimelineWidget(QWidget):
self.sync_fps = 30.0
self.is_scrubbing = False
self.total_content_height = (15 * self.track_height) + self.ruler_height
self.setMinimumHeight(self.total_content_height + self.scrollbar_buffer)
# self.total_content_height = (15 * self.track_height) + self.ruler_height
# self.setMinimumHeight(self.total_content_height + self.scrollbar_buffer)
@@ -1148,6 +1351,9 @@ class TimelineWidget(QWidget):
"fps": fps
}
self.sync_fps = fps
self.total_content_height = (len(self.track_names) * self.track_height) + self.ruler_height
self.setMinimumHeight(self.total_content_height + self.scrollbar_buffer)
# Generate colors dynamically since we don't know the tracks ahead of time
self.track_colors = [QColor.fromHsl((i * 360 // max(1, len(self.track_names))), 160, 140)
@@ -1510,7 +1716,6 @@ class SkeletonOverlay(QWidget):
def set_frame(self, frame_idx):
debug_print()
self.current_frame = frame_idx
self.update()
@@ -1528,7 +1733,6 @@ class SkeletonOverlay(QWidget):
def paintEvent(self, event):
debug_print()
if not self.data or 'raw_kps' not in self.data:
return
@@ -1573,7 +1777,15 @@ class SkeletonOverlay(QWidget):
p_left = kp_live[idx_l_hip][:2]
p_right = kp_live[idx_r_hip][:2]
pelvis_live = (p_left + p_right) / 2
kp_baseline = self.data['baseline_kp_mean'] + pelvis_live
base_raw = self.data['baseline_kp_mean']
# CRITICAL: Center the baseline template around its own pelvis first
# This prevents the "Double Dipping" jump
b_l_hip, b_r_hip = base_raw[idx_l_hip], base_raw[idx_r_hip]
pelvis_base = (b_l_hip + b_r_hip) / 2
# New calculation: (Template - its center) + live anchor
kp_baseline = (base_raw - pelvis_base) + pelvis_live
painter.setPen(QPen(QColor(200, 200, 200, 200), 2, Qt.DashLine))
for s_name, e_name in self.CONNECTIONS:
@@ -1763,6 +1975,7 @@ class PremiereWindow(QMainWindow):
# Window instances
self.load_window = None
self.train_window = None
self.export_window = None
self.about = None
self.help = None
@@ -1794,10 +2007,15 @@ class PremiereWindow(QMainWindow):
train_act = QAction("Train Model", self)
train_act.triggered.connect(self.open_train_model_dialog)
config_act = QAction("Configuration Editor", self)
config_act.triggered.connect(self.open_model_configuration_tab)
menu.addAction(load_act)
menu.addAction(train_act)
menu.addAction(config_act)
# Show the menu right under the mouse cursor
menu.exec(QCursor.pos())
@@ -1871,7 +2089,17 @@ class PremiereWindow(QMainWindow):
# Connect the initialization button from OpenFileWindow to our tab creator
self.train_window.btn_train.clicked.connect(self.handle_start_training)
self.train_window.show()
def open_model_configuration_tab(self):
self.handle_model_configuration_session()
def open_export_data_dialog(self):
if self.export_window is None or not self.export_window.isVisible():
self.export_window = ExportTimelineJsonWindow(self)
# Connect the initialization button from OpenFileWindow to our tab creator
# self.export_window.btn_train.clicked.connect(self.handle_start_training)
self.export_window.show()
def handle_start_training(self):
params = self.train_window.get_selection()
@@ -1940,6 +2168,19 @@ class PremiereWindow(QMainWindow):
# 5. Switch to it
self.tabs.setCurrentIndex(new_idx)
def handle_model_configuration_session(self):
new_tab = ModelParameterConfigurationTab()
# 4. Handle Tab Placement (Keep '+' at the end)
tab_name = "Configuration Editor"
plus_idx = self.tabs.count() - 1
new_idx = self.tabs.insertTab(plus_idx, new_tab, tab_name)
# 5. Switch to it
self.tabs.setCurrentIndex(new_idx)
def close_tab(self, index):
# Prevent closing the Welcome tab if it's the only one left
if index == 0 and self.tabs.count() == 1:
@@ -2163,12 +2404,17 @@ class VideoAnalysisTab(QWidget):
self.inspector_scroll.setWidget(self.info_label)
# NEW: Export Button for Metrics
self.btn_export_metrics = QPushButton("Export Metrics to JSON")
self.btn_export_metrics = QPushButton("Export Data for Machine Learning...")
self.btn_export_metrics.clicked.connect(self.export_behavior_metrics)
self.btn_export_metrics.setEnabled(False) # Enable only after load
self.btn_export_flares = QPushButton("Export Timeline Events for FLARES...")
self.btn_export_flares.clicked.connect(self.export_timeline_flares)
self.btn_export_flares.setEnabled(False) # Enable only after load
info_layout.addWidget(self.inspector_scroll)
info_layout.addWidget(self.btn_export_metrics)
info_layout.addWidget(self.btn_export_flares)
top_splitter.addWidget(video_container)
top_splitter.addWidget(info_container)
@@ -2275,6 +2521,23 @@ class VideoAnalysisTab(QWidget):
baseline_mean = np.mean(valid_data, axis=0) if valid_data else np.zeros((17, 2))
if self.config.get('use_calculations'):
dists, velocities = self.generate_automated_tracks(baseline_mean)
if self.config.get('velocity_enabled'):
for joint_name, idx in self.skeleton_overlay.KP_MAP.items():
vel_events = self.create_event_blocks(velocities[:, idx], threshold=float(self.config.get('velocity_threshold', 15)))
if vel_events:
self.processed_data[f"Vel_{joint_name}"] = vel_events
if self.config.get('deviation_enabled'):
# 2. Convert to timeline events (using a threshold of e.g. 50 pixels)
for joint_name, idx in self.skeleton_overlay.KP_MAP.items():
dev_events = self.create_event_blocks(dists[:, idx], threshold=float(self.config.get('deviation_threshold', 50)))
if dev_events:
self.processed_data[f"Dev_{joint_name}"] = dev_events
overlay_payload = {
"raw_kps": self.raw_kpts,
"width": v_w,
@@ -2308,6 +2571,54 @@ class VideoAnalysisTab(QWidget):
def generate_automated_tracks(self, baseline_mean):
# Convert list of frames to a numpy stack: (frames, joints, xy)
raw_stack = np.stack([f[:, :2] for f in self.raw_kpts])
# 1. Get Live Pelvis (Anchor)
l_hip_idx, r_hip_idx = 11, 12 # Adjust based on your KP_MAP
pelvis_live = (raw_stack[:, l_hip_idx] + raw_stack[:, r_hip_idx]) / 2
# 2. Center the Baseline Template
pelvis_base = (baseline_mean[l_hip_idx] + baseline_mean[r_hip_idx]) / 2
base_template = baseline_mean - pelvis_base
# 3. Broadcast template across all frames
# (frames, 1, 2) + (17, 2) -> (frames, 17, 2)
target_pos = pelvis_live[:, np.newaxis, :] + base_template
# 4. Euclidean Distance
dists = np.linalg.norm(raw_stack - target_pos, axis=2)
# 5. Velocity (Difference between frames)
velocities = np.zeros_like(dists)
velocities[1:] = np.linalg.norm(np.diff(raw_stack, axis=0), axis=2)
return dists, velocities
def create_event_blocks(self, data_array, threshold):
events = []
active = False
start_f = 0
mask = data_array > threshold
for f, is_high in enumerate(mask):
if is_high and not active:
active = True
start_f = f
elif not is_high and active:
active = False
if (f - start_f) > 5: # Filter out noise shorter than 5 frames
events.append([start_f, f, "Moderate", "N/A"])
return events
def on_ai_inference_complete(self, ai_events):
"""Runs when the thread finishes. Merges AI into the existing UI."""
# 1. Merge AI tracks into the dictionary that already has BORIS
@@ -2398,6 +2709,7 @@ class VideoAnalysisTab(QWidget):
# 2. Update Stats & Export button
self.update_inspector_stats(self.processed_data, fps)
self.btn_export_metrics.setEnabled(True)
self.btn_export_flares.setEnabled(True)
print(f"DEBUG: UI Synced with {len(self.processed_data)} total tracks.")
@@ -2422,6 +2734,15 @@ class VideoAnalysisTab(QWidget):
self.info_label.setHtml(stats_text)
def export_timeline_flares(self):
export_dialog = ExportTimelineJsonWindow(
timeline_data=self.processed_data,
fps=self.fps,
parent=self
)
export_dialog.exec()
def export_behavior_metrics(self):
"""Exports the processed behavior metrics to a new JSON file."""
if not self.processed_data:
@@ -2431,6 +2752,7 @@ class VideoAnalysisTab(QWidget):
"metadata": {
"source_video": os.path.basename(self.config.get('video_path', 'unknown')),
"session": self.config.get('session_key', 'unknown'),
"pose_model": self.config.get('pose_model', 'unknown'),
"export_timestamp": datetime.now().isoformat(),
"fps": self.config.get('fps', 30.0)
},
@@ -2811,6 +3133,356 @@ class VideoAnalysisTab(QWidget):
if hasattr(self, 'worker') and self.worker.isRunning():
self.worker.terminate()
"""
------------------------------ It all breaks here ------------------------------
"""
from PySide6.QtWidgets import (QWidget, QVBoxLayout, QSplitter, QListWidget,
QGraphicsView, QGraphicsScene, QListWidgetItem)
from PySide6.QtCore import Qt, QMimeData
from PySide6.QtGui import QDrag
from PySide6.QtGui import QPainterPath, QColor, QPen, QBrush
from PySide6.QtWidgets import QGraphicsPathItem, QGraphicsSimpleTextItem
from PySide6.QtWidgets import QGraphicsItem, QGraphicsSimpleTextItem, QInputDialog
class PuzzleBlock(QGraphicsPathItem):
def __init__(self, b_type, label, parent_item=None):
super().__init__(parent_item)
self.b_type = b_type # "begin", "middle", "end"
self.label_text = label
self.width = 160
self.height = 50
self.tab_size = 15
self.setFlags(
QGraphicsItem.GraphicsItemFlag.ItemIsMovable |
QGraphicsItem.GraphicsItemFlag.ItemIsSelectable |
QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
)
self.update_path()
# Color Coding
colors = {"begin": "#2e7d32", "middle": "#1565c0", "end": "#c62828"}
self.setBrush(QBrush(QColor(colors.get(b_type, "#555"))))
self.setPen(QPen(QColor("#ffffff"), 1))
self.label_item = QGraphicsSimpleTextItem(self.label_text, self)
self.label_item.setBrush(QColor("white"))
self.label_item.setPos(25, 15)
def update_path(self):
self.prepareGeometryChange()
path = QPainterPath()
path.moveTo(0, 0)
# 1. TOP EDGE
# Middle and End blocks now have a "Nub" (Male) on top
if self.b_type in ["middle", "end"]:
path.lineTo(30, 0)
# Nub (Male) - Sweep is negative to curve OUTWARD
path.arcTo(30, -self.tab_size/2, self.tab_size, self.tab_size, 180, -180)
path.lineTo(self.width, 0)
# 2. RIGHT EDGE
path.lineTo(self.width, self.height)
# 3. BOTTOM EDGE
# Begin and Middle blocks now have a "Hole" (Female) on bottom
if self.b_type in ["begin", "middle"]:
path.lineTo(30 + self.tab_size, self.height)
# Socket (Female) - Sweep is positive to curve INWARD
path.arcTo(30, self.height - self.tab_size/2, self.tab_size, self.tab_size, 0, 180)
path.lineTo(0, self.height)
# 4. LEFT EDGE
path.lineTo(0, 0)
path.closeSubpath()
self.setPath(path)
def mouseDoubleClickEvent(self, event):
if self.b_type == "begin":
new_name, ok = QInputDialog.getText(None, "Rename Step", "Enter name:", text=self.label_text)
if ok and new_name:
self.label_text = new_name
self.label_item.setText(new_name)
super().mouseDoubleClickEvent(event)
class BlockLibrary(QListWidget):
def __init__(self, parent_tab):
super().__init__()
self.setDragEnabled(True)
self.add_item("Begin Event", "begin")
self.add_item("Process Step", "middle")
self.add_item("Finish/Export", "end")
self.parent_tab = parent_tab # Reference to main tab to access canvas
def add_item(self, label, block_type):
item = QListWidgetItem(label)
item.setData(Qt.UserRole, block_type)
self.addItem(item)
def startDrag(self, supportedActions):
item = self.currentItem()
mime_data = QMimeData()
# Pass the type and label as string
mime_data.setText(f"{item.data(Qt.UserRole)}|{item.text()}")
drag = QDrag(self)
drag.setMimeData(mime_data)
result = drag.exec(supportedActions)
print(f"DEBUG: Drag finished with result: {result}")
if result != Qt.IgnoreAction:
self.finalize_drop_snapping()
def finalize_drop_snapping(self):
# Grab all blocks and sort by their index in the scene (newest is usually last)
all_items = self.parent_tab.canvas.scene.items()
puzzle_blocks = [i for i in all_items if isinstance(i, PuzzleBlock)]
if puzzle_blocks:
# We want the block that was just dropped
# Usually the first in scene.items() is the top-most/newest
newest_block = puzzle_blocks[0]
# Call the method that was missing
self.parent_tab.canvas.perform_snap(newest_block, newest_block.scenePos())
from PySide6.QtWidgets import QGraphicsLineItem, QGraphicsRectItem, QGraphicsSimpleTextItem
from PySide6.QtGui import QPen, QColor, QBrush
class TopologyCanvas(QGraphicsView):
def __init__(self):
self.scene = QGraphicsScene()
super().__init__(self.scene)
self.setAcceptDrops(True)
self.scene.setSceneRect(0, 0, 2000, 2000)
self.chain = [] # Track items if needed
def mouseReleaseEvent(self, event):
# 1. Native release
super().mouseReleaseEvent(event)
# 2. Sidebar Trash Logic
global_pos = event.globalPosition().toPoint()
widget_under_mouse = QApplication.widgetAt(global_pos)
print("huh")
is_over_library = False
current_widget = widget_under_mouse
while current_widget:
if isinstance(current_widget, BlockLibrary):
is_over_library = True
break
current_widget = current_widget.parentWidget()
if is_over_library:
print("Action: Trashing selected items")
for item in self.scene.selectedItems():
if isinstance(item, PuzzleBlock):
self.scene.removeItem(item)
return
selected = [i for i in self.scene.selectedItems() if isinstance(i, PuzzleBlock)]
if selected:
dragged_item = selected[0]
# Use scenePos() for the internal coordinate math
self.perform_snap(dragged_item, dragged_item.scenePos())
self.validate_topology()
def perform_snap(self, dragged_item, pos):
""" This is the method the library is looking for. """
overlap = 0
# Create a search area around the block's current position
snap_rect = QRectF(pos.x() - 25, pos.y() - 25, 200, 100)
items_nearby = self.scene.items(snap_rect)
for target in items_nearby:
if isinstance(target, PuzzleBlock) and target != dragged_item:
# SNAP BELOW: Dragged Item is BELOW the target
if dragged_item.b_type in ["middle", "end"] and target.b_type in ["begin", "middle"]:
dragged_item.setPos(target.scenePos() + QPointF(0, target.height - overlap))
print(f"DEBUG: {dragged_item.label_text} snapped to BOTTOM of {target.label_text}")
return True
# SNAP ABOVE: Dragged Item is ABOVE the target
elif dragged_item.b_type in ["begin", "middle"] and target.b_type in ["middle", "end"]:
dragged_item.setPos(target.scenePos() - QPointF(0, dragged_item.height - overlap))
print(f"DEBUG: {dragged_item.label_text} snapped to TOP of {target.label_text}")
return True
return False
def dropEvent(self, event):
raw_data = event.mimeData().text()
if "|" not in raw_data: return
# Only process drops from the Library (New blocks)
if isinstance(event.source(), BlockLibrary):
block_type, label = raw_data.split("|")
pos = self.mapToScene(event.position().toPoint())
new_block = PuzzleBlock(block_type, label)
new_block.setPos(pos)
self.scene.addItem(new_block)
self.validate_topology()
event.acceptProposedAction()
def dragEnterEvent(self, event):
if event.mimeData().hasText():
event.acceptProposedAction()
def dragMoveEvent(self, event):
if event.mimeData().hasText():
event.acceptProposedAction()
def validate_topology(self):
all_items = [i for i in self.scene.items() if isinstance(i, PuzzleBlock)]
has_begin = any(i.b_type == "begin" for i in all_items)
has_end = any(i.b_type == "end" for i in all_items)
warnings = []
if not has_begin:
warnings.append("⚠️ Missing 'Begin' block (No data source defined).")
if not has_end:
warnings.append("⚠️ Missing 'Finish' block (Data will not be saved).")
if warnings:
status_text = "\n".join(warnings)
#self.parent().info_label.setText(f"<font color='red'>{status_text}</font>")
else:
pass
#self.parent().info_label.setText("<font color='#00ff00'>✅ Configuration Valid</font>")
class ModelParameterConfigurationTab(QWidget):
def __init__(self):
super().__init__()
self.setStyleSheet("""
QMainWindow, QWidget#centralWidget {
background-color: #1e1e1e;
}
QLabel, QStatusBar, QMenuBar {
color: #ffffff;
}
/* Target the Timeline specifically */
TimelineWidget {
background-color: #1e1e1e;
border: 1px solid #333333;
}
/* Button styling with Grey borders */
QDialog, QMessageBox, QFileDialog {
background-color: #2b2b2b;
}
QDialog QLabel, QMessageBox QLabel {
color: #ffffff;
}
QPushButton {
background-color: #2b2b2b;
color: #ffffff;
border: 1px solid #555555; /* Subtle Grey border */
border-radius: 3px;
padding: 4px;
}
QPushButton:hover {
background-color: #3d3d3d;
border-color: #888888; /* Brightens border on hover */
}
QPushButton:pressed {
background-color: #111111;
}
QPushButton:disabled {
border-color: #333333;
color: #444444;
}
/* Splitter/Divider styling */
QSplitter::handle {
background-color: #333333; /* Dark grey dividers */
}
QSplitter::handle:horizontal {
width: 2px;
}
QSplitter::handle:vertical {
height: 2px;
}
/* ScrollArea styling to keep it dark */
QScrollArea, QScrollArea > QWidget > QWidget {
background-color: #1e1e1e;
border: none;
}
""")
self.setup_ui()
def setup_ui(self):
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
# Main horizontal split: [ Canvas | Library/Inspector ]
self.horizontal_splitter = QSplitter(Qt.Horizontal)
# --- LEFT: Topology Canvas ---
self.canvas = TopologyCanvas()
# --- RIGHT: Sidebar (Library + Inspector) ---
sidebar_container = QWidget()
sidebar_layout = QVBoxLayout(sidebar_container)
self.block_library = BlockLibrary(parent_tab=self)
# Reuse your existing Inspector logic
self.inspector_scroll = QScrollArea()
self.inspector_scroll.setWidgetResizable(True)
self.info_label = QTextEdit()
self.info_label.setReadOnly(True)
self.info_label.setStyleSheet("background-color: #2b2b2b; color: #ffffff;")
self.inspector_scroll.setWidget(self.info_label)
sidebar_layout.addWidget(QLabel("Block Library"))
sidebar_layout.addWidget(self.block_library, 2) # Library gets more space
sidebar_layout.addWidget(QLabel("Properties"))
sidebar_layout.addWidget(self.inspector_scroll, 1)
# Add to main splitter
self.horizontal_splitter.addWidget(self.canvas)
self.horizontal_splitter.addWidget(sidebar_container)
self.horizontal_splitter.setSizes([800, 300])
main_layout.addWidget(self.horizontal_splitter)
def resource_path(relative_path):
"""