Files
flares/src/shared/flaresbasewidget.py
T
2026-08-27 17:50:23 -07:00

1996 lines
77 KiB
Python

"""
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 != "<None Selected>"]
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("<None Selected>")
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() != "<None Selected>"]
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 ("<None Selected>", "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 "<None Selected>")
# 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 = "<span style='color: green;'>✔</span>"
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"<span style='color: #555;'>{frame}</span>"
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("<None Selected>")
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", "<None Selected>", "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("<None Selected>")
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("<None Selected>")
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("<None Selected>")
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("<None Selected>")
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 ["<None Selected>", "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) == "<None Selected>":
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("<None Selected>")
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("<None Selected>")
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("<None Selected>")
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("<None Selected>")
for group in self.group_names:
if group != exclude:
dropdown.addItem(group)
# Restore previous selection if still valid
if current != "<None Selected>" and current != exclude and dropdown.findText(current) != -1:
dropdown.setCurrentText(current)
else:
dropdown.setCurrentIndex(0) # Reset to "<None Selected>"
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 == "<None Selected>":
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("<None Selected>")
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("<None Selected>")
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 == "<None Selected>":
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("<None Selected>")
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 == "<None Selected>":
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,
)