""" Filename: flaresbasewidget.py Description: Custom window design and supporting methods for FLARES Author: Tyler de Zeeuw License: GPL-3.0 """ import os from copy import deepcopy from typing import Sequence, Any import pandas as pd from pandas import DataFrame from PySide6.QtWidgets import QApplication, QComboBox, QDialog, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QListView, QMessageBox, QPushButton, QScrollArea, QSizePolicy, QVBoxLayout, QWidget, QFrame, QSpinBox, QFileDialog from PySide6.QtGui import QPalette, QStandardItemModel, QStandardItem, QPixmap, QIntValidator, QDoubleValidator from PySide6.QtCore import QEvent, QPoint, QSize, QTimer, Qt, Signal from src.shared.shareddata import APP_NAME, PIPELINE_STAGES class FullClickComboBox(QComboBox): def __init__(self, parent=None): super().__init__(parent) self.setEditable(True) self.lineEdit().setReadOnly(True) self.lineEdit().installEventFilter(self) def eventFilter(self, obj, event): if obj == self.lineEdit(): if event.type() == QEvent.MouseButtonPress: return True if event.type() == QEvent.MouseButtonRelease: self.showPopup() return True return super().eventFilter(obj, event) class ClickableLabel(QLabel): def __init__(self, full_pixmap: QPixmap, thumbnail_pixmap: QPixmap): super().__init__() self._pixmap_full = full_pixmap self.setPixmap(thumbnail_pixmap) self.setAlignment(Qt.AlignmentFlag.AlignCenter) self.setFixedSize(thumbnail_pixmap.size()) self.setStyleSheet("border: 1px solid gray; margin: 2px;") def mousePressEvent(self, event): viewer = QWidget() viewer.setWindowTitle(f"Expanded View - {APP_NAME.upper()}") layout = QVBoxLayout(viewer) label = QLabel() label.setPixmap(self._pixmap_full) label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(label) viewer.resize(1000, 800) viewer.show() self._expanded_viewer = viewer # keep reference alive class ParameterInputDialog(QDialog): def __init__(self, params_dict, parent=None): """ params_dict format: { idx: [ { "key": "p_val", "label": "Significance threshold P-value (e.g. 0.05)", "default": "0.05", "type": float, }, { "key": "graph_scale", "label": "Graph scale factor", "default": "1", "type": int, } ], ... } """ super().__init__(parent) self.setWindowTitle(f"Input Parameters - {APP_NAME.upper()}") self.params_dict = params_dict self.inputs = {} # {(idx, param_key): QLineEdit} main_layout = QVBoxLayout(self) intro_label = QLabel( "Some methods require parameters to continue:\n" "Clicking OK will simply use default values if input is left empty." ) main_layout.addWidget(intro_label) self.setMinimumWidth(400) self.scroll = QScrollArea() self.scroll.setWidgetResizable(True) self.scroll.setMaximumHeight(800) self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.scroll_content = QWidget() self.scroll_layout = QVBoxLayout(self.scroll_content) self.scroll_layout.setContentsMargins(10, 10, 10, 10) self.scroll.setWidget(self.scroll_content) main_layout.addWidget(self.scroll) for idx, param_list in params_dict.items(): full_text = param_list[0].get('full_text', f"Index [{idx}]") group_label = QLabel(f"{full_text} requires parameters:") group_label.setStyleSheet("font-weight: bold; margin-top: 10px;") self.scroll_layout.addWidget(group_label) for param_info in param_list: label = QLabel(param_info["label"]) self.scroll_layout.addWidget(label) if param_info.get("type") == list: widget = QComboBox(self) # Convert options to string just in case they aren't options = [str(opt) for opt in param_info.get("options", [])] widget.addItems(options) # Set default choice if it exists in the options list default_val = str(param_info.get("default", "")) if default_val in options: widget.setCurrentText(default_val) else: widget = QLineEdit(self) widget.setPlaceholderText(str(param_info.get("default", ""))) self.scroll_layout.addWidget(widget) self.inputs[(idx, param_info["key"])] = widget # Buttons btn_layout = QHBoxLayout() ok_btn = QPushButton("OK", self) cancel_btn = QPushButton("Cancel", self) btn_layout.addWidget(ok_btn) btn_layout.addWidget(cancel_btn) main_layout.addLayout(btn_layout) ok_btn.clicked.connect(self.accept) cancel_btn.clicked.connect(self.reject) def get_values(self): """ Validate and return values dict in form: { idx: { param_key: value, ... }, ... } Returns None if validation fails (error dialog shown). """ values = {} for (idx, param_key), widget in self.inputs.items(): if isinstance(widget, QComboBox): text = widget.currentText().strip() else: text = widget.text().strip() # Find param info dict param_info = None for p in self.params_dict[idx]: if p['key'] == param_key: param_info = p break if param_info is None: # This shouldn't happen, but just in case: self._show_error(f"Internal error: No param info for index {idx} key '{param_key}'") return None if not text: text = str(param_info.get('default', '')) param_type = param_info.get('type', str) try: if param_type == int: val = int(text) elif param_type == float: val = float(text) elif param_type == bool: # Convert common bool strings to bool val_lower = text.lower() if val_lower in ('true', '1', 'yes', 'y'): val = True elif val_lower in ('false', '0', 'no', 'n'): val = False else: raise ValueError(f"Invalid bool value: {text}") elif param_type in (str, list): val = text else: val = text # fallback except (ValueError, TypeError): type_name = "list option" if param_type == list else param_type.__name__ self._show_error( f"Invalid input for index {idx} parameter '{param_key}': '{text}'\n" f"Expected type: {type_name}" ) return None if idx not in values: values[idx] = {} values[idx][param_key] = val return values def _show_error(self, message): error_box = QMessageBox(self) error_box.setIcon(QMessageBox.Critical) error_box.setWindowTitle(f"Input Error - {APP_NAME.upper()}") error_box.setText(message) error_box.exec_() class FullClickComboBox(QComboBox): def __init__(self, parent=None): super().__init__(parent) self.setEditable(True) self.lineEdit().setReadOnly(True) self.lineEdit().installEventFilter(self) def eventFilter(self, obj, event): if obj == self.lineEdit(): if event.type() == QEvent.MouseButtonPress: return True if event.type() == QEvent.MouseButtonRelease: self.showPopup() return True return super().eventFilter(obj, event) class FilePickerWidget(QWidget): # This custom signal lets our container mimic a standard QLineEdit textChanged = Signal(str) def __init__(self, default_val="", file_filter="JSON Files (*.json);;All Files (*)", parent=None): super().__init__(parent) # Inline layout to hold line edit and button side-by-side layout = QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(5) self.line_edit = QLineEdit() self.line_edit.setText(str(default_val)) self.line_edit.setPlaceholderText("Select file path...") # Forward internal text updates out through our custom component signal self.line_edit.textChanged.connect(self.textChanged.emit) self.browse_btn = QPushButton("Browse...") self.browse_btn.clicked.connect(self.open_file_dialog) layout.addWidget(self.line_edit) layout.addWidget(self.browse_btn) self.file_filter = file_filter def open_file_dialog(self): # Open PySide6 native file browser file_path, _ = QFileDialog.getOpenFileName( self, "Select Configuration File", self.line_edit.text(), self.file_filter ) if file_path: self.line_edit.setText(file_path) # Mimic standard text getter/setter behaviors so parent systems remain unbothered def text(self): return self.line_edit.text() def setText(self, text): self.line_edit.setText(text) class ParamSection(QWidget): """ Args: section_data (dict): Dictionary containing section title and list of parameter info. Expected format: { "title": str, "params": [ { "name": str, "type": type, "default": any, "help": str (optional) }, ... ] } """ dirty_state_changed = Signal(bool) def __init__(self, section_data, global_widgets): super().__init__() layout = QVBoxLayout() self.setLayout(layout) self.widgets = global_widgets self.dependencies = [] self.selected_path = None self.param_rows = [] self.header_widgets = [] self.dirty_params = {} self._updating_checkstates = False # Title label self.title_label = QLabel(section_data["title"]) self.title_label.setStyleSheet("font-weight: bold; font-size: 14px; margin-top: 10px; margin-bottom: 5px;") layout.addWidget(self.title_label) self.header_widgets.append(self.title_label) # Horizontal line line = QFrame() line.setFrameShape(QFrame.Shape.HLine) line.setFrameShadow(QFrame.Shadow.Sunken) layout.addWidget(line) self.header_widgets.append(line) for param in section_data["params"]: h_layout = QHBoxLayout() is_advanced = param.get("advanced", False) param_name = param["name"] help_text = param.get("help", "") # Build label text and tooltips label_text = f"⚠️ {param_name}" if is_advanced else param_name label = QLabel(label_text) if is_advanced: label.setToolTip(f"ADVANCED: {help_text}") else: label.setToolTip(help_text) help_btn = QPushButton("?") help_btn.setFixedWidth(25) help_btn.setToolTip(help_text) help_btn.clicked.connect(lambda _, text=help_text, name=param_name: self.show_help_popup(name, text)) h_layout.addWidget(help_btn) h_layout.addWidget(label) h_layout.setStretch(0, 1) h_layout.setStretch(1, 6) default_val = param["default"] param_type = param["type"] type_str = str(param_type).lower() # Create input widget based on type if param_type == bool or "bool" in type_str: widget = QComboBox() widget.addItems(["True", "False"]) widget.setCurrentText(str(default_val)) widget.currentTextChanged.connect(lambda _, p=param_name: self.check_if_changed(p)) widget.currentTextChanged.connect(self.notify_global_update) elif param_type == int or "int" in type_str: widget = QLineEdit() widget.setValidator(QIntValidator()) widget.setText(str(default_val)) widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p)) elif param_type == float or "float" in type_str: widget = QLineEdit() widget.setValidator(QDoubleValidator()) widget.setText(str(default_val)) widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p)) elif param_type == list or "list" in type_str: options = param.get("options", []) if param.get("exclusive", True): widget = QComboBox() widget.addItems(options) initial_text = default_val[0] if (isinstance(default_val, list) and len(default_val) > 0) else str(default_val) widget.setCurrentText(initial_text) widget.currentTextChanged.connect(lambda _, p=param_name: self.check_if_changed(p)) widget.currentTextChanged.connect(self.notify_global_update) else: widget = self._create_multiselect_dropdown(options, default_val=default_val, param_name=param_name) elif param_type == range or "range" in type_str: widget = QSpinBox() widget.setRange(0, 999) if isinstance(default_val, range): widget.setValue(default_val.stop) elif str(default_val).isdigit(): widget.setValue(int(default_val)) else: widget.setValue(15) widget.valueChanged.connect(lambda _, p=param_name: self.check_if_changed(p)) elif param_type == "json_file": widget = FilePickerWidget(default_val=default_val, file_filter="JSON Files (*.json)") widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p)) else: widget = QLineEdit() widget.setText(str(default_val)) widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p)) self.widgets[param_name] = { "widget": widget, "label": label, "default": default_val, "saved_value": deepcopy(default_val), "type": param_type, "h_layout": h_layout } if "depends_on" in param: deps_list = param["depends_on"] if isinstance(param["depends_on"], list) else [{ "parent_name": param["depends_on"], "depends_value": param.get("depends_value", "True") }] self.dependencies.append({ "child_name": param_name, "conditions": deps_list }) widget.setToolTip(help_text) h_layout.addWidget(widget) h_layout.setStretch(2, 3) layout.addLayout(h_layout) self.param_rows.append(([help_btn, label, widget], h_layout, is_advanced)) self.update_dependencies() def update_theme_colors(self): self.title_label.style().unpolish(self.title_label) self.title_label.style().polish(self.title_label) def is_different(self, val_a, val_b, param_type=None): """Compares two parameter values to determine if they differ.""" type_str = str(param_type).lower() # BOOL comparison if param_type == bool or "bool" in type_str: def to_bool(v): if isinstance(v, bool): return v return str(v).strip().lower() in ("true", "1", "yes") return to_bool(val_a) != to_bool(val_b) # LIST comparison if param_type == list or "list" in type_str: def to_list(v): if v is None: return [] if isinstance(v, list): res = [] for item in v: res.extend(to_list(item)) return res if isinstance(v, str): s = v.strip() if s.startswith('[') and s.endswith(']'): s = s[1:-1] items = [x.strip().strip("'\"") for x in s.split(',') if x.strip().strip("'\"")] return [i for i in items if i != ""] return [str(v).strip()] return sorted(to_list(val_a)) != sorted(to_list(val_b)) # RANGE comparison if param_type == range or "range" in type_str: def to_range_stop(v): if isinstance(v, range): return v.stop try: return int(v) except (ValueError, TypeError): return 0 return to_range_stop(val_a) != to_range_stop(val_b) # INT / FLOAT comparison if param_type in (int, float) or "int" in type_str or "float" in type_str: try: if val_a is not None and val_b is not None and str(val_a).strip() != "" and str(val_b).strip() != "": return float(val_a) != float(val_b) except (ValueError, TypeError): pass # String / Default Fallback str_a = "" if val_a is None else str(val_a).strip() str_b = "" if val_b is None else str(val_b).strip() return str_a != str_b def check_if_changed(self, param_name, current_value=None, *args, **kwargs): """ Toggles bold font/blue color on the label if the value differs from default. Always pulls current UI value from get_param_values() if not explicitly passed. """ info = self.widgets.get(param_name) if not info: return label = info["label"] default = info["default"] saved = info.get("saved_value", default) param_type = info["type"] if current_value is None: current_value = self.get_param_values().get(param_name) # 1. COLORING LOGIC (Compares against 'default') if self.is_different(current_value, default, param_type): label.setStyleSheet("color: #3498db; font-weight: bold;") else: label.setStyleSheet("") # 2. DIRTY STATE LOGIC (Compares against 'saved_value') self.dirty_params[param_name] = self.is_different(current_value, saved, param_type) self.dirty_state_changed.emit(any(self.dirty_params.values())) def reset_to_defaults(self): """ Resets all UI input widgets back to their defined default values in SECTIONS, resets saved baseline states, updates label styling (removes blue highlight), and updates dependent widget visibility/enablement. """ for param_name, info in self.widgets.items(): widget = info["widget"] default_val = info["default"] param_type = info["type"] type_str = str(param_type).lower() # Update saved baseline to match default info["saved_value"] = deepcopy(default_val) # Reset Widget Values if param_type == bool or "bool" in type_str: if isinstance(widget, QComboBox): widget.setCurrentText(str(default_val)) elif param_type in (int, float, str) or "int" in type_str or "float" in type_str or "str" in type_str: if isinstance(widget, QLineEdit): widget.setText("" if default_val is None else str(default_val)) elif param_type == list or "list" in type_str: if isinstance(widget, FullClickComboBox): defaults = default_val if isinstance(default_val, list) else ([default_val] if default_val else []) model = widget.model() self._updating_checkstates = True normal_items = [] for i in range(2, model.rowCount()): item = model.item(i) normal_items.append(item) state = Qt.Checked if item.text() in defaults else Qt.Unchecked item.setCheckState(state) # Sync toggle select all item toggle_item = model.item(1) if toggle_item and normal_items: all_checked = all(i.checkState() == Qt.Checked for i in normal_items) toggle_item.setCheckState(Qt.Checked if all_checked else Qt.Unchecked) self._updating_checkstates = False self.update_dropdown_label(param_name) elif isinstance(widget, QComboBox): initial_text = default_val[0] if (isinstance(default_val, list) and len(default_val) > 0) else str(default_val) widget.setCurrentText(initial_text) elif param_type == range or "range" in type_str: if isinstance(widget, QSpinBox): if isinstance(default_val, range): widget.setValue(default_val.stop) elif str(default_val).isdigit(): widget.setValue(int(default_val)) else: widget.setValue(15) elif param_type == "json_file": if hasattr(widget, "setText"): widget.setText("" if default_val is None else str(default_val)) # Re-evaluate visual styling and dirty flags self.check_if_changed(param_name) self.dirty_params.clear() self.dirty_state_changed.emit(False) self.update_dependencies() # Aliases for clear button calls def clear(self): self.reset_to_defaults() def reset(self): self.reset_to_defaults() def reset_baseline_to_default(self): """Resets baseline saved values back to defaults and re-checks visual styling.""" for name, info in self.widgets.items(): info["saved_value"] = deepcopy(info["default"]) self.check_if_changed(name) self.dirty_params.clear() self.dirty_state_changed.emit(False) def save_current_as_baseline(self): """Call this when a project is saved to lock current UI state as saved_value.""" current_values = self.get_param_values() for name, info in self.widgets.items(): if name in current_values: info["saved_value"] = deepcopy(current_values[name]) self.check_if_changed(name) self.dirty_params.clear() self.dirty_state_changed.emit(False) def set_advanced_visible(self, show_advanced: bool): has_visible_rows = False for row_widgets, _, is_advanced in self.param_rows: visible = show_advanced or not is_advanced for w in row_widgets: w.setVisible(visible) if visible: has_visible_rows = True for hw in self.header_widgets: hw.setVisible(has_visible_rows) self.setVisible(has_visible_rows) def has_any_changes(self): current_values = self.get_param_values() for name, info in self.widgets.items(): if self.is_different(current_values.get(name), info["default"], info["type"]): return True return False def notify_global_update(self): parent = self.parent() if parent: for section in parent.findChildren(ParamSection): section.update_dependencies() def update_dependencies(self): for dep in self.dependencies: child_info = self.widgets.get(dep["child_name"]) if not child_info: continue all_conditions_met = True for cond in dep["conditions"]: parent_name = cond.get("parent_name") or cond.get("parent") required_val = str(cond.get("depends_value") if "depends_value" in cond else cond.get("value", "True")) parent_info = self.widgets.get(parent_name) if not parent_info: all_conditions_met = False break p_widget = parent_info["widget"] if isinstance(p_widget, QComboBox): curr_val = p_widget.currentText() elif isinstance(p_widget, QLineEdit): curr_val = p_widget.text() elif isinstance(p_widget, QSpinBox): curr_val = str(p_widget.value()) else: curr_val = str(p_widget) if curr_val != required_val: all_conditions_met = False break h_layout = child_info["h_layout"] for i in range(h_layout.count()): item = h_layout.itemAt(i).widget() if item: item.setEnabled(all_conditions_met) def _create_multiselect_dropdown(self, items, default_val=None, param_name=""): combo = FullClickComboBox() combo.setView(QListView()) model = QStandardItemModel() combo.setModel(model) combo.setEditable(True) combo.lineEdit().setReadOnly(True) combo.lineEdit().setPlaceholderText("Select...") dummy_item = QStandardItem("") dummy_item.setFlags(Qt.ItemIsEnabled) model.appendRow(dummy_item) toggle_item = QStandardItem("Toggle Select All") toggle_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) toggle_item.setData(Qt.Unchecked, Qt.CheckStateRole) model.appendRow(toggle_item) defaults = default_val if isinstance(default_val, list) else ([default_val] if default_val else []) if items: for item_text in items: standard_item = QStandardItem(item_text) standard_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) initial_state = Qt.Checked if item_text in defaults else Qt.Unchecked standard_item.setData(initial_state, Qt.CheckStateRole) model.appendRow(standard_item) combo.setInsertPolicy(QComboBox.NoInsert) def on_view_clicked(index): item = model.itemFromIndex(index) if item.isCheckable(): new_state = Qt.Checked if item.checkState() == Qt.Unchecked else Qt.Unchecked item.setCheckState(new_state) combo.view().pressed.connect(on_view_clicked) def on_item_changed(item): if self._updating_checkstates: return self._updating_checkstates = True normal_items = [model.item(i) for i in range(2, model.rowCount())] if item == toggle_item: all_checked = all(i.checkState() == Qt.Checked for i in normal_items) target_state = Qt.Unchecked if all_checked else Qt.Checked for i in normal_items: i.setCheckState(target_state) toggle_item.setCheckState(target_state) elif item != dummy_item: all_checked = all(i.checkState() == Qt.Checked for i in normal_items) toggle_item.setCheckState(Qt.Checked if all_checked else Qt.Unchecked) self._updating_checkstates = False if param_name: self.update_dropdown_label(param_name) self.check_if_changed(param_name) model.itemChanged.connect(on_item_changed) return combo def show_help_popup(self, param_name, text): msg = QMessageBox(self) msg.setWindowTitle(f"Parameter Info - {param_name} - {APP_NAME.upper()}") msg.setText(text) msg.exec() def get_param_values(self): values = {} for name, info in self.widgets.items(): widget = info["widget"] expected_type = info["type"] type_str = str(expected_type).lower() if expected_type == bool or "bool" in type_str: values[name] = widget.currentText() == "True" elif expected_type == list or "list" in type_str: if isinstance(widget, FullClickComboBox): values[name] = [x.strip() for x in widget.lineEdit().text().split(",") if x.strip() and x.strip() != ""] elif isinstance(widget, QComboBox): values[name] = widget.currentText() elif expected_type == range or "range" in type_str: values[name] = range(widget.value()) if isinstance(widget, QSpinBox) else range(15) elif expected_type == "json_file": values[name] = widget.text() else: raw_text = widget.text() try: if expected_type == int or "int" in type_str: values[name] = int(raw_text) elif expected_type == float or "float" in type_str: values[name] = float(raw_text) else: values[name] = raw_text except Exception: values[name] = raw_text return values def _get_checked_items(self, combo): checked = [] model = combo.model() for i in range(model.rowCount()): item = model.item(i) if item.text() in ("", "Toggle Select All"): continue if item.checkState() == Qt.Checked: checked.append(item.text()) return checked def update_dropdown_label(self, param_name): widget_info = self.widgets.get(param_name) if not widget_info: return widget = widget_info["widget"] if not isinstance(widget, FullClickComboBox): return selected = self._get_checked_items(widget) widget.lineEdit().setText(", ".join(selected) if selected else "") # def update_annotation_dropdown_from_loaded_files(self, bubble_widgets, button1): # file_paths = [bubble.file_path for bubble in bubble_widgets.values()] # if not file_paths: # return # # 1. Start the UI immediately # progress = QProgressDialog("Accessing Workers...", "Cancel", 0, len(file_paths), self) # progress.setWindowModality(Qt.WindowModality.WindowModal) # progress.setMinimumDuration(0) # progress.setValue(0) # # Force the UI to draw the window NOW before we start the loop # progress.show() # QApplication.processEvents() # annotation_sets = [] # # 2. Use the persistent executor (don't use 'with' here!) # for i, path in enumerate(file_paths): # progress.setValue(i) # progress.setLabelText(f"Reading file {i+1} of {len(file_paths)}...") # QApplication.processEvents() # Keeps the UI snappy # if progress.wasCanceled(): # break # # This call is now nearly instant because the process is already warm # future = self.file_executor.submit(_extract_annotations, path) # try: # labels_list = future.result() # if labels_list: # annotation_sets.append(set(labels_list)) # except Exception as e: # print(f"Worker Error: {e}") # progress.setValue(len(file_paths)) # # 3. Final Logic # if not annotation_sets: # self.update_dropdown_items("REMOVE_EVENTS", []) # button1.setVisible(False) # return # common = set.intersection(*annotation_sets) if len(annotation_sets) > 1 else annotation_sets[0] # self.update_dropdown_items("REMOVE_EVENTS", sorted(list(common))) class ProgressBubble(QWidget): """ A clickable widget displaying a progress bar made of colored rectangles and a label. Args: display_name (str): Text to display above the progress bar. file_path (str): Associated file path stored with the bubble. """ clicked = Signal(object) rightClicked = Signal(object, QPoint) def __init__(self, display_name, file_path): super().__init__() self.layout = QVBoxLayout() self.label = QLabel(display_name) self.loading_timer = QTimer(self) self.loading_timer.timeout.connect(self._rotate_spinner) self.spinner_frames = ["◐", "◓", "◑", "◒"] #cute self.spinner_idx = 0 self.is_loading = False self.base_text = display_name self.status_icon = "" self.suffix_text = "" self.label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.label.setStyleSheet(""" QLabel { border: 1px solid #888; border-radius: 10px; padding: 8px 12px; background-color: #e0f0ff; color: #000000; } """) self.progress_layout = QHBoxLayout() self.rects = [] for i in range(28): rect = QFrame() rect.setFixedSize(10, 18) rect.setStyleSheet("background-color: white; border: 1px solid gray;") stage_name = PIPELINE_STAGES[i] rect.setToolTip(f"Stage {i + 1}: {stage_name}") self.progress_layout.addWidget(rect) self.rects.append(rect) self.layout.addWidget(self.label) self.layout.addLayout(self.progress_layout) self.setLayout(self.layout) # Store the file path self.file_path = os.path.normpath(file_path) self.current_step = 0 # Make the bubble appear to the user as clickable self.setCursor(Qt.CursorShape.PointingHandCursor) # Resize policy to make bubbles responsive self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) def _update_label_text(self): """Combines base text, green checkmark/spinner, and metadata into one display label.""" text = self.base_text if self.status_icon: text += f" {self.status_icon}" if self.suffix_text: text += f" ({self.suffix_text})" self.label.setText(text) def set_loading_state(self, loading=True): self.is_loading = loading if loading: self.loading_timer.start(150) # Rotate every 150ms else: self.loading_timer.stop() # Transition to a green checkmark self.status_icon = "✔" self._update_label_text() def setSuffixText(self, suffix): """Updates the metadata text portion without destroying the checkmark.""" self.suffix_text = suffix if suffix else "" self._update_label_text() def update_progress(self, step_index, active=True): self.current_step = step_index for i, rect in enumerate(self.rects): if i < step_index: rect.setStyleSheet("background-color: green; border: 1px solid gray;") elif i == step_index: color = "yellow" if active else "white" rect.setStyleSheet(f"background-color: {color}; border: 1px solid gray;") else: rect.setStyleSheet("background-color: white; border: 1px solid gray;") def mark_cancelled(self): for i, rect in enumerate(self.rects): if i < self.current_step: rect.setStyleSheet("background-color: green; border: 1px solid gray;") elif i == self.current_step: rect.setStyleSheet("background-color: red; border: 1px solid gray;") else: rect.setStyleSheet("background-color: white; border: 1px solid gray;") def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: self.clicked.emit(self) elif event.button() == Qt.MouseButton.RightButton: self.rightClicked.emit(self, event.globalPosition().toPoint()) super().mousePressEvent(event) def _rotate_spinner(self): frame = self.spinner_frames[self.spinner_idx % len(self.spinner_frames)] # Using HTML in setText allows us to style the spinner specifically self.status_icon = f"{frame}" self.spinner_idx += 1 self._update_label_text() def reset(self): """Resets the bubble's visual state, progress bars, timers, and labels back to initial state.""" # Reset progress metrics and visual rectangles back to white self.current_step = 0 for rect in self.rects: rect.setStyleSheet("background-color: white; border: 1px solid gray;") class FlaresBaseWidget(QWidget): def __init__(self, caller): super().__init__() self.caller = caller self.haemo_dict = None self._updating_checkstates = False self.participant_map = {} self.show_all_events = True # These will be defined by the children, but we'll # initialize them as None so the code doesn't crash. self.participant_dropdown = None self.event_dropdown = None self.image_index_dropdown = None def _create_multiselect_dropdown( self, items: Sequence[str] ) -> FullClickComboBox: combo = FullClickComboBox() combo.setView(QListView()) model = QStandardItemModel() combo.setModel(model) combo.setEditable(True) combo.lineEdit().setReadOnly(True) combo.lineEdit().setPlaceholderText("Select...") # Setup internal items dummy = QStandardItem("") dummy.setFlags(Qt.ItemIsEnabled) model.appendRow(dummy) toggle = QStandardItem("Toggle Select All") toggle.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) toggle.setData(Qt.Unchecked, Qt.CheckStateRole) model.appendRow(toggle) for text in items: item = QStandardItem(text) item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) item.setData(Qt.Unchecked, Qt.CheckStateRole) model.appendRow(item) # Handle clicking the view directly def on_view_clicked(index): item = model.itemFromIndex(index) if item.isCheckable(): new_state = Qt.Checked if item.checkState() == Qt.Unchecked else Qt.Unchecked item.setCheckState(new_state) combo.view().pressed.connect(on_view_clicked) # Logic for "Select All" and Signal Propagation def on_item_changed(item): if getattr(self, '_updating_checkstates', False): return self._updating_checkstates = True normal_items = [model.item(i) for i in range(2, model.rowCount())] if item == toggle: state = toggle.checkState() for i in normal_items: i.setCheckState(state) else: all_checked = all(i.checkState() == Qt.Checked for i in normal_items) toggle.setCheckState(Qt.Checked if all_checked else Qt.Unchecked) # Trigger the widget's update logic via the existing signal combo.currentIndexChanged.emit(combo.currentIndex()) self._updating_checkstates = False model.itemChanged.connect(on_item_changed) combo.setInsertPolicy(QComboBox.NoInsert) return combo # def _get_checked_items(self, combo): # model = combo.model() # checked = [] # for i in range(2, model.rowCount()): # Start at 2 to skip dummy/toggle # item = model.item(i) # if item.checkState() == Qt.Checked: # checked.append(item.text()) # return checked def _get_checked_items( self, combo: QComboBox | None = None ) -> list[str]: target = combo if combo is not None else getattr(self, 'participant_dropdown', None) if target is None or target.model() is None: return [] model = target.model() checked_items = [] # Exclusion list: any item text that should never be treated as data forbidden = {"Toggle All", "Select All", "", "Toggle"} for row in range(model.rowCount()): item = model.item(row) if item.checkState() == Qt.CheckState.Checked: text = item.text() # Only add if it's not a 'UI control' item if text not in forbidden and not text.startswith("Toggle"): checked_items.append(text) return checked_items def update_participant_dropdown_label( self, combo: QComboBox | int | None = None ) -> None: """ Handles label updates for ANY participant dropdown. If 'combo' is None, it defaults to the standard self.participant_dropdown. """ if isinstance(combo, int): combo = None # 1. Figure out which dropdown we are talking to target_combo = combo if combo is not None else getattr(self, "participant_dropdown", None) if target_combo is None: return # Safety check: nothing to update # 2. Get the checked items and format the text selected = self._get_checked_items(target_combo) if not selected: target_combo.lineEdit().setText("") else: # Extract just "Participant N" selected_short = [s.split(" ")[0] + " " + s.split(" ")[1] for s in selected] target_combo.lineEdit().setText(", ".join(selected_short)) # 3. Conditional trigger for event updates # We only update events if we aren't in one of the excluded viewers excluded_viewers = { "ParticipantImage", "ParticipantFoldChannels", "ExportToCSV", } if getattr(self, "caller", None) not in excluded_viewers: self._update_event_dropdown() def update_image_index_dropdown_label(self): selected = self._get_checked_items(self.image_index_dropdown) if not selected: self.image_index_dropdown.lineEdit().setText("") else: # Only show the index part index_labels = [s.split(" ")[0] for s in selected] self.image_index_dropdown.lineEdit().setText(", ".join(index_labels)) def _update_event_dropdown(self): is_split_group = hasattr(self, 'participant_dropdown_a') and hasattr(self, 'participant_dropdown_b') bypass = False main_win = next((w for w in QApplication.topLevelWidgets() if w.objectName() == "MainApplication" or hasattr(w, "missing_events_bypass")), None) if main_win: bypass = getattr(main_win, "missing_events_bypass", False) if is_split_group: names_a = self._get_checked_items(self.participant_dropdown_a) names_b = self._get_checked_items(self.participant_dropdown_b) if not names_a or not names_b: self._clear_event_dropdown() return map_a = getattr(self, 'participant_map_a', {}) rev_a = {f"{l} ({os.path.basename(fp)})": fp for fp, l in map_a.items()} sets_a = [] for n in names_a: raw = self.haemo_dict.get(rev_a.get(n)) if raw and hasattr(raw, "annotations"): sets_a.append(set(raw.annotations.description)) map_b = getattr(self, 'participant_map_b', {}) rev_b = {f"{l} ({os.path.basename(fp)})": fp for fp, l in map_b.items()} sets_b = [] for n in names_b: raw = self.haemo_dict.get(rev_b.get(n)) if raw and hasattr(raw, "annotations"): sets_b.append(set(raw.annotations.description)) if not sets_a or not sets_b: self._clear_event_dropdown() return if not bypass: final_annotations = set.intersection(*(sets_a + sets_b)) else: all_events_a = {event for s in sets_a for event in s} all_events_b = {event for s in sets_b for event in s} valid_a = set() for event in all_events_a: count = sum(1 for s in sets_a if event in s) if count >= 2: valid_a.add(event) valid_b = set() for event in all_events_b: count = sum(1 for s in sets_b if event in s) if count >= 2: valid_b.add(event) final_annotations = valid_a.intersection(valid_b) else: names = self._get_checked_items(self.participant_dropdown) if not names: self._clear_event_dropdown() return map_single = getattr(self, 'participant_map', {}) rev_single = {f"{l} ({os.path.basename(fp)})": fp for fp, l in map_single.items()} all_sets = [] for n in names: raw = self.haemo_dict.get(rev_single.get(n)) if raw and hasattr(raw, "annotations"): all_sets.append(set(raw.annotations.description)) if not all_sets: self._clear_event_dropdown() return if not bypass: final_annotations = set.intersection(*all_sets) else: final_annotations = set.union(*all_sets) self.event_dropdown.clear() self.event_dropdown.addItem("") for ann in sorted(final_annotations): self.event_dropdown.addItem(ann) def _clear_event_dropdown(self): if hasattr(self, 'event_dropdown'): self.event_dropdown.clear() self.event_dropdown.addItem("") def _connect_select_all_toggle(self, toggle_item, model): """Helper function to connect the Select All functionality.""" normal_items = [model.item(i) for i in range(2, model.rowCount())] # skip dummy and toggle def on_item_changed(item): if self._updating_checkstates: return self._updating_checkstates = True if item == toggle_item: all_checked = all(i.checkState() == Qt.Checked for i in normal_items) if all_checked: for i in normal_items: i.setCheckState(Qt.Unchecked) toggle_item.setCheckState(Qt.Unchecked) else: for i in normal_items: i.setCheckState(Qt.Checked) toggle_item.setCheckState(Qt.Checked) else: # When normal items change, update toggle item all_checked = all(i.checkState() == Qt.Checked for i in normal_items) toggle_item.setCheckState(Qt.Checked if all_checked else Qt.Unchecked) if hasattr(self, 'participant_dropdown_a') and model == self.participant_dropdown_a.model(): self.update_participant_dropdown_label(self.participant_dropdown_a) elif hasattr(self, 'participant_dropdown_b') and model == self.participant_dropdown_b.model(): self.update_participant_dropdown_label(self.participant_dropdown_b) # Update label text immediately after change if self.participant_dropdown: self.update_participant_dropdown_label() self._updating_checkstates = False model.itemChanged.connect(on_item_changed) def update_participant_list_for_group(self, group_name=None, combo=None): target_combo = combo if combo is not None else getattr(self, "participant_dropdown", None) if not target_combo: return if isinstance(group_name, int) and combo is None: target_group = self.group_dropdown.currentText() elif group_name is not None: target_group = group_name else: # If we have no group_name, look up the text from the correct dropdown if hasattr(self, 'participant_dropdown_a') and target_combo is self.participant_dropdown_a: target_group = self.group_a_dropdown.currentText() elif hasattr(self, 'participant_dropdown_b') and target_combo is self.participant_dropdown_b: target_group = self.group_b_dropdown.currentText() else: target_group = self.group_dropdown.currentText() if hasattr(self, 'participant_dropdown_a') and target_combo is self.participant_dropdown_a: self.participant_map_a = {} active_map = self.participant_map_a elif hasattr(self, 'participant_dropdown_b') and target_combo is self.participant_dropdown_b: self.participant_map_b = {} active_map = self.participant_map_b else: self.participant_map = {} active_map = self.participant_map # 4. Refresh the Model model = target_combo.model() model.clear() for text in ["", "Toggle Select All"]: item = QStandardItem(str(text)) if text == "Toggle Select All": item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) item.setData(Qt.Unchecked, Qt.CheckStateRole) toggle_ref = item else: item.setFlags(Qt.ItemIsEnabled) model.appendRow(item) # 5. Populate Data if str(target_group) == "": target_combo.setEnabled(False) self.update_participant_dropdown_label(combo=target_combo) return target_combo.setEnabled(True) # Get file paths (handles target_group as int or str) group_file_paths = self.group_to_paths.get(target_group, []) for i, file_path in enumerate(group_file_paths, start=1): short_label = f"Participant {i}" display_label = f"{short_label} ({os.path.basename(file_path)})" active_map[file_path] = short_label item = QStandardItem(display_label) item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) item.setData(Qt.Unchecked, Qt.CheckStateRole) model.appendRow(item) self._connect_select_all_toggle(toggle_ref, model) self.update_participant_dropdown_label(combo=target_combo) class InterGroupUIMixin: participant_map: dict[str, str] def setup_inter_group_ui( self, index_texts: Sequence[str], placeholder_text: str = "" ) -> None: self.group_to_paths = {} for file_path, group_name in self.group_dict.items(): self.group_to_paths.setdefault(group_name, []).append(file_path) self.group_names = sorted(self.group_to_paths.keys()) self.main_layout = QVBoxLayout(self) self.top_bar = QHBoxLayout() self.main_layout.addLayout(self.top_bar) self.group_a_dropdown = QComboBox() self.group_a_dropdown.addItem("") self.group_a_dropdown.addItems(self.group_names) self.group_a_dropdown.currentIndexChanged.connect(self._update_group_a_options) self.group_b_dropdown = QComboBox() self.group_b_dropdown.addItem("") self.group_b_dropdown.addItems(self.group_names) self.group_b_dropdown.currentIndexChanged.connect(self._update_group_b_options) self.event_dropdown = QComboBox() self.event_dropdown.addItem("") self.participant_dropdown_a = self._create_multiselect_dropdown([]) line_edit = self.participant_dropdown_a.lineEdit() assert line_edit is not None, "Dropdown A must be editable to have a lineEdit" line_edit.setPlaceholderText("Select participants (Group A)") model = self.participant_dropdown_a.model() assert isinstance(model, QStandardItemModel), "Model must be QStandardItemModel" model.itemChanged.connect(self._on_participants_changed) self.participant_dropdown_b = self._create_multiselect_dropdown([]) line_edit = self.participant_dropdown_b.lineEdit() assert line_edit is not None, "Dropdown B must be editable to have a lineEdit" line_edit.setPlaceholderText("Select participants (Group B)") model = self.participant_dropdown_b.model() assert isinstance(model, QStandardItemModel), "Model must be QStandardItemModel" model.itemChanged.connect(self._on_participants_changed) self.index_texts = index_texts self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts) self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label) self.submit_button = QPushButton("Submit") self.submit_button.clicked.connect(self.process_request) self.top_bar.addWidget(QLabel("Group A:")) self.top_bar.addWidget(self.group_a_dropdown) self.top_bar.addWidget(QLabel("Participants (Group A):")) self.top_bar.addWidget(self.participant_dropdown_a) self.top_bar.addWidget(QLabel("Group B:")) self.top_bar.addWidget(self.group_b_dropdown) self.top_bar.addWidget(QLabel("Participants (Group B):")) self.top_bar.addWidget(self.participant_dropdown_b) self.top_bar.addWidget(QLabel("Event:")) self.top_bar.addWidget(self.event_dropdown) self.top_bar.addWidget(QLabel("Image Indexes:")) self.top_bar.addWidget(self.image_index_dropdown) self.top_bar.addWidget(self.submit_button) self.scroll_area = QScrollArea() self.scroll_area.setWidgetResizable(True) self.scroll_content = QWidget() self.grid_layout = QGridLayout(self.scroll_content) self.scroll_area.setWidget(self.scroll_content) self.placeholder_label = QLabel(placeholder_text) self.grid_layout.addWidget(self.placeholder_label, 0, 0) self.placeholder_label.setWordWrap(True) self.placeholder_label.setScaledContents(True) self.main_layout.addWidget(self.scroll_area) self.thumb_size = QSize(280, 180) self.showMaximized() def _update_group_b_options(self): """Triggered when Group B changes: Update Group A to exclude B's choice""" selected_b = self.group_b_dropdown.currentText() # Refresh Group A and exclude what was just picked in Group B self._refresh_group_dropdown(self.group_a_dropdown, exclude=selected_b) # Update the participants for Group B self.update_participant_list_for_group(selected_b, self.participant_dropdown_b) self._update_event_dropdown() def _update_group_a_options(self): """Triggered when Group A changes: Update Group B to exclude A's choice""" selected_a = self.group_a_dropdown.currentText() # Refresh Group B and exclude what was just picked in Group A self._refresh_group_dropdown(self.group_b_dropdown, exclude=selected_a) # Update the participants for Group A self.update_participant_list_for_group(selected_a, self.participant_dropdown_a) self._update_event_dropdown() def _on_participants_changed(self, item=None): self._update_event_dropdown() def _refresh_group_dropdown(self, dropdown, exclude): current = dropdown.currentText() dropdown.blockSignals(True) dropdown.clear() dropdown.addItem("") for group in self.group_names: if group != exclude: dropdown.addItem(group) # Restore previous selection if still valid if current != "" and current != exclude and dropdown.findText(current) != -1: dropdown.setCurrentText(current) else: dropdown.setCurrentIndex(0) # Reset to "" dropdown.blockSignals(False) def _get_file_paths_from_labels(self, labels, group_name): file_paths = [] if group_name == self.group_a_dropdown.currentText(): participant_map = self.participant_map_a elif group_name == self.group_b_dropdown.currentText(): participant_map = self.participant_map_b else: return [] # Reverse map: display label -> file path reverse_map = { f"{label} ({os.path.basename(fp)})": fp for fp, label in participant_map.items() } for label in labels: file_path = reverse_map.get(label) if file_path: file_paths.append(file_path) return file_paths def get_common_request_data( self, parameterized_indexes: dict[int, list[dict[str, Any]]], df_ind_dict: dict[str, DataFrame] | None = None, contrast_dfs: dict[str, dict[str, Any]] | None = None, ) -> tuple[str | None, list[str], list[str], list[str], list[int], dict[str, Any]] | None: selected_event = self.event_dropdown.currentText() if selected_event == "": selected_event = None participants_a = self._get_checked_items(self.participant_dropdown_a) file_paths_a = self._get_file_paths_from_labels( participants_a, self.group_a_dropdown.currentText() ) participants_b = self._get_checked_items(self.participant_dropdown_b) file_paths_b = self._get_file_paths_from_labels( participants_b, self.group_b_dropdown.currentText() ) selected_indexes = [ int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown) ] all_selected_paths = list(set(file_paths_a + file_paths_b)) if not all_selected_paths: print("No participants selected.") return None # Inject full_text for idx, params_list in parameterized_indexes.items(): full_text = self.index_texts[idx] for param in params_list: param["full_text"] = full_text indexes_needing_params = { idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes } dynamic_rois = [] if df_ind_dict: roi_set = set() for fp in all_selected_paths: df_roi = df_ind_dict.get(fp) if isinstance(df_roi, pd.DataFrame) and "ROI" in df_roi.columns: roi_set.update(df_roi["ROI"].dropna().unique()) if roi_set: dynamic_rois = sorted(list(roi_set)) # Fallback to prevent UI crashes if JSON file doesn't exist or is empty if not dynamic_rois: dynamic_rois = ["Option 1", "Option 2"] dynamic_contrasts = [] if contrast_dfs: contrast_set = set() for fp in all_selected_paths: # Get the contrasts dictionary associated with this file path file_contrasts = contrast_dfs.get(fp, {}) for contrast_name in file_contrasts.keys(): # If no event is selected, display all contrasts. # If an event is selected, only keep contrasts containing the event name as a substring. if selected_event is None or selected_event in contrast_name: contrast_set.add(contrast_name) # Sort them cleanly for the UI dynamic_contrasts = sorted(list(contrast_set)) # 2. Loop through the active parameters needing input and intercept 'roi_a' and 'roi_b' for idx, params_list in indexes_needing_params.items(): for param_info in params_list: if param_info["key"] == "roi_a": # Inject options list dynamically param_info["options"] = dynamic_rois # Default to the very first item param_info["default"] = dynamic_rois[0] if dynamic_rois else "" elif param_info["key"] == "roi_b": # Inject the same options list param_info["options"] = dynamic_rois # Default to the first item not taken (index 1), with safety fallbacks if len(dynamic_rois) > 1: param_info["default"] = dynamic_rois[1] elif len(dynamic_rois) == 1: param_info["default"] = dynamic_rois[0] else: param_info["default"] = "" elif param_info["key"] == "contrast_name": param_info["options"] = dynamic_contrasts param_info["default"] = dynamic_contrasts[0] if dynamic_contrasts else "" param_values = {} if indexes_needing_params: dialog = ParameterInputDialog(indexes_needing_params, parent=self) if dialog.exec() != QDialog.DialogCode.Accepted: return None param_values = dialog.get_values() if param_values is None: return None return ( selected_event, file_paths_a, file_paths_b, all_selected_paths, selected_indexes, param_values, ) class CSVUIMixin: def setup_csv_ui( self, index_texts: Sequence[str] ) -> None: # Create mappings: file_path -> participant label and dropdown display text self.participant_map: dict[str, str] = {} # file_path -> "Participant 1" self.participant_dropdown_items: list[str] = [] # "Participant 1 (filename)" for i, file_path in enumerate(self.haemo_dict.keys(), start=1): short_label = f"Participant {i}" display_label = f"{short_label} ({os.path.basename(file_path)})" self.participant_map[file_path] = short_label self.participant_dropdown_items.append(display_label) self.layout = QVBoxLayout(self) self.top_bar = QHBoxLayout() self.layout.addLayout(self.top_bar) self.participant_dropdown: FullClickComboBox = self._create_multiselect_dropdown(self.participant_dropdown_items) self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label) self.index_texts = index_texts self.image_index_dropdown: FullClickComboBox = self._create_multiselect_dropdown(self.index_texts) self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label) self.submit_button = QPushButton("Submit") self.submit_button.clicked.connect(self.process_request) self.top_bar.addWidget(QLabel("Participants:")) self.top_bar.addWidget(self.participant_dropdown) self.top_bar.addWidget(QLabel("Export Type:")) self.top_bar.addWidget(self.image_index_dropdown) self.top_bar.addWidget(self.submit_button) self.scroll = QScrollArea() self.scroll.setWidgetResizable(True) self.scroll_content = QWidget() self.grid_layout = QGridLayout(self.scroll_content) self.scroll.setWidget(self.scroll_content) self.placeholder_label = QLabel("") self.grid_layout.addWidget(self.placeholder_label, 0, 0) self.placeholder_label.setWordWrap(True) self.placeholder_label.setScaledContents(True) self.layout.addWidget(self.scroll) self.thumb_size = QSize(280, 180) self.showMaximized() class IntraGroupUIMixin: def setup_intra_group_ui( self, index_texts: Sequence[str], placeholder_text: str = "" ) -> None: self.show_all_events = True self._updating_checkstates = False # Create mappings: file_path -> participant label and dropdown display text self.participant_map: dict[str, str] = {} # file_path -> "Participant 1" self.participant_dropdown_items = [] # "Participant 1 (filename)" for i, file_path in enumerate(self.haemo_dict.keys(), start=1): short_label = f"Participant {i}" display_label = f"{short_label} ({os.path.basename(file_path)})" self.participant_map[file_path] = short_label self.participant_dropdown_items.append(display_label) self.layout = QVBoxLayout(self) self.top_bar = QHBoxLayout() self.layout.addLayout(self.top_bar) self.group_to_paths = {} for file_path, group_name in self.group_dict.items(): self.group_to_paths.setdefault(group_name, []).append(file_path) self.group_names = sorted(self.group_to_paths.keys()) self.group_dropdown = QComboBox() self.group_dropdown.addItem("") self.group_dropdown.addItems(self.group_names) self.group_dropdown.setCurrentIndex(0) self.group_dropdown.currentIndexChanged.connect(self.update_participant_list_for_group) self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items) self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label) self.participant_dropdown.setEnabled(False) self.event_dropdown = QComboBox() self.event_dropdown.addItem("") self.index_texts = index_texts self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts) self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label) self.submit_button = QPushButton("Submit") self.submit_button.clicked.connect(self.process_request) self.top_bar.addWidget(QLabel("Group:")) self.top_bar.addWidget(self.group_dropdown) self.top_bar.addWidget(QLabel("Participants:")) self.top_bar.addWidget(self.participant_dropdown) self.top_bar.addWidget(QLabel("Event:")) self.top_bar.addWidget(self.event_dropdown) self.top_bar.addWidget(QLabel("Image Indexes:")) self.top_bar.addWidget(self.image_index_dropdown) self.top_bar.addWidget(self.submit_button) self.scroll = QScrollArea() self.scroll.setWidgetResizable(True) self.scroll_content = QWidget() self.grid_layout = QGridLayout(self.scroll_content) self.scroll.setWidget(self.scroll_content) self.placeholder_label = QLabel(placeholder_text) self.grid_layout.addWidget(self.placeholder_label, 0, 0) self.placeholder_label.setWordWrap(True) self.placeholder_label.setScaledContents(True) self.layout.addWidget(self.scroll) self.thumb_size = QSize(280, 180) self.showMaximized() def get_common_request_data( self, parameterized_indexes: dict[int, list[dict[str, Any]]], df_ind_dict: dict[str, DataFrame] | None = None, contrast_dfs: dict[str, dict[str, Any]] | None = None, ) -> tuple[str | None, list[str], list[int], dict[str, Any]] | None: selected_event = self.event_dropdown.currentText() if selected_event == "": selected_event = None selected_display_names = self._get_checked_items(self.participant_dropdown) selected_file_paths = [] for display_name in selected_display_names: for fp, short_label in self.participant_map.items(): expected_display = f"{short_label} ({os.path.basename(fp)})" if display_name == expected_display: selected_file_paths.append(fp) break if selected_event: valid_paths = [] for fp in selected_file_paths: raw = self.haemo_dict.get(fp) # Check if this participant actually has the event in their annotations if raw is not None and hasattr(raw, "annotations"): if selected_event in raw.annotations.description: valid_paths.append(fp) selected_file_paths = valid_paths selected_indexes = [ int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown) ] if not selected_file_paths: print("No participants selected.") return # Inject full_text from index_texts for idx, params_list in parameterized_indexes.items(): full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)" for param_info in params_list: param_info["full_text"] = full_text indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes} dynamic_rois = [] if df_ind_dict: roi_set = set() for fp in selected_file_paths: df_roi = df_ind_dict.get(fp) if isinstance(df_roi, pd.DataFrame) and "ROI" in df_roi.columns: roi_set.update(df_roi["ROI"].dropna().unique()) if roi_set: dynamic_rois = sorted(list(roi_set)) # Fallback to prevent UI crashes if JSON file doesn't exist or is empty if not dynamic_rois: dynamic_rois = ["Option 1", "Option 2"] dynamic_contrasts = [] if contrast_dfs: contrast_set = set() for fp in selected_file_paths: # Get the contrasts dictionary associated with this file path file_contrasts = contrast_dfs.get(fp, {}) for contrast_name in file_contrasts.keys(): # If no event is selected, display all contrasts. # If an event is selected, only keep contrasts containing the event name as a substring. if selected_event is None or selected_event in contrast_name: contrast_set.add(contrast_name) # Sort them cleanly for the UI dynamic_contrasts = sorted(list(contrast_set)) # 2. Loop through the active parameters needing input and intercept 'roi_a' and 'roi_b' for idx, params_list in indexes_needing_params.items(): for param_info in params_list: if param_info["key"] == "roi_a": # Inject options list dynamically param_info["options"] = dynamic_rois # Default to the very first item param_info["default"] = dynamic_rois[0] if dynamic_rois else "" elif param_info["key"] == "roi_b": # Inject the same options list param_info["options"] = dynamic_rois # Default to the first item not taken (index 1), with safety fallbacks if len(dynamic_rois) > 1: param_info["default"] = dynamic_rois[1] elif len(dynamic_rois) == 1: param_info["default"] = dynamic_rois[0] else: param_info["default"] = "" elif param_info["key"] == "contrast_name": param_info["options"] = dynamic_contrasts param_info["default"] = dynamic_contrasts[0] if dynamic_contrasts else "" param_values = {} if indexes_needing_params: dialog = ParameterInputDialog(indexes_needing_params, parent=self) if dialog.exec_() == QDialog.Accepted: param_values = dialog.get_values() if param_values is None: return else: return return ( selected_event, selected_file_paths, selected_indexes, param_values, ) class ParticipantUIMixin: def setup_participant_ui( self, index_texts: Sequence[str], placeholder_text: str = "" ) -> None: # Create mappings: file_path -> participant label and dropdown display text self.participant_map: dict[str, str] = {} # file_path -> "Participant 1" self.participant_dropdown_items = [] # "Participant 1 (filename)" for i, file_path in enumerate(self.haemo_dict.keys(), start=1): short_label = f"Participant {i}" display_label = f"{short_label} ({os.path.basename(file_path)})" self.participant_map[file_path] = short_label self.participant_dropdown_items.append(display_label) self.main_layout = QVBoxLayout(self) self.top_bar = QHBoxLayout() self.main_layout.addLayout(self.top_bar) self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items) self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label) self.event_dropdown = QComboBox() self.event_dropdown.addItem("") self.index_texts = index_texts self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts) self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label) self.submit_button = QPushButton("Submit") self.submit_button.clicked.connect(self.process_request) self.top_bar.addWidget(QLabel("Participants:")) self.top_bar.addWidget(self.participant_dropdown) self.top_bar.addWidget(QLabel("Event:")) self.top_bar.addWidget(self.event_dropdown) self.top_bar.addWidget(QLabel("Image Indexes:")) self.top_bar.addWidget(self.image_index_dropdown) self.top_bar.addWidget(self.submit_button) self.scroll_area = QScrollArea() self.scroll_area.setWidgetResizable(True) self.scroll_content = QWidget() self.grid_layout = QGridLayout(self.scroll_content) self.scroll_area.setWidget(self.scroll_content) self.placeholder_label = QLabel(placeholder_text) self.grid_layout.addWidget(self.placeholder_label, 0, 0) self.placeholder_label.setWordWrap(True) self.placeholder_label.setScaledContents(True) self.main_layout.addWidget(self.scroll_area) self.thumb_size = QSize(280, 180) self.showMaximized() def get_common_request_data( self, parameterized_indexes: dict[int, list[dict[str, Any]]] ) -> tuple[str | None, list[str], list[int], dict[str, Any]] | None: selected_event = self.event_dropdown.currentText() if selected_event == "": selected_event = None selected_display_names = self._get_checked_items(self.participant_dropdown) selected_file_paths = [] for display_name in selected_display_names: for fp, short_label in self.participant_map.items(): expected_display = f"{short_label} ({os.path.basename(fp)})" if display_name == expected_display: selected_file_paths.append(fp) break selected_indexes = [ int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown) ] # Inject full_text from index_texts for idx, params_list in parameterized_indexes.items(): full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)" for param_info in params_list: param_info["full_text"] = full_text indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes} param_values = {} if indexes_needing_params: dialog = ParameterInputDialog(indexes_needing_params, parent=self) if dialog.exec_() == QDialog.Accepted: param_values = dialog.get_values() if param_values is None: return else: return return ( selected_event, selected_file_paths, selected_indexes, param_values, )