From 3f4c4163f06bfb8f38ecb618913c19b351711461 Mon Sep 17 00:00:00 2001 From: tyler Date: Fri, 1 May 2026 13:19:43 -0700 Subject: [PATCH] start of config editor --- main.py | 690 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 681 insertions(+), 9 deletions(-) diff --git a/main.py b/main.py index dc7fc0e..5136c88 100644 --- a/main.py +++ b/main.py @@ -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"{status_text}") + else: + pass + #self.parent().info_label.setText("✅ Configuration Valid") + + +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): """