mainly whitespace, but also some generic qol improvements

This commit is contained in:
tyler committed 2026-10-14 20:17:23 -07:00
1 parent 2a5e3f5606
commit 3a8ffbdf70
26 files changed
+1871 -1224

No files matched your search

+18
View File
@@ -1,3 +1,21 @@
# Version 1.8.0
- Plugin Manager now supports checking and downloading plugin updates to ensure plugins can be updated
- Plugin Manager now properly uninstalls plugins when requested rather than doing nothing
- Installing plugins now displays a progress bar when downloading and no longer hangs the application when downloading a big plugin
- Project loading now displays a loading message when loading big projects and no longer hangs the application when loading
- New preferences options and menus are now available! All existing preferences have been moved into new sub categories
- The UI preference category now has the Show Advanced Parameters preference
- The Updates preference category has two ney options to prevent the automatic checking of application or plugin updates
- The Theme category has remain untouched and all other preferences have been moved to the Processing category
- Added a new preference in the Processing category called "Separate Events". This allows events to have their own values, and not be collapsed into a single value
- Export To CSV Viewer export options now perform the actions they claim rather than having no functionality
- Logs on a per participant basis are now available to be viewed from within the application
- They can be accessed by right clicking a participant bubble and selecting "Show Logs". Logs will be empty if they have not been processed
- Fixed a regression where opening a folder would no longer load all the files in that folder
- Improved code stability and functionality
# Version 1.7.1 # Version 1.7.1
- Added support for Plugins! Plugins can be downloaded from the official repository or from custom ones. Support for creating a plugin can be found on the wiki - Added support for Plugins! Plugins can be downloaded from the official repository or from custom ones. Support for creating a plugin can be found on the wiki
+366 -327
View File
File diff suppressed because it is too large. Load diff
+477 -302
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -25,11 +25,11 @@ main_test.py::test_save_project_actions_pass_correct_ask_parameter
main_test.py::test_main_window_opens main_test.py::test_main_window_opens
main_test.py::test_file_recent_submenus_exist main_test.py::test_file_recent_submenus_exist
main_test.py::test_view_reset_layout main_test.py::test_view_reset_layout
main_test.py::test_preferences_actions[2D Data Bypass-2d_data_bypass] main_test.py::test_preferences_actions[2D Data Bypass-2d_data_bypass]
main_test.py::test_preferences_actions[Incompatible Save Bypass-incompatible_save_bypass] main_test.py::test_preferences_actions[Incompatible Save Bypass-incompatible_save_bypass]
main_test.py::test_preferences_actions[Missing Events Bypass-missing_events_bypass] main_test.py::test_preferences_actions[Missing Events Bypass-missing_events_bypass]
main_test.py::test_preferences_actions[Analysis Clearing Bypass-analysis_clearing_bypass] main_test.py::test_preferences_actions[Analysis Clearing Bypass-analysis_clearing_bypass]
main_test.py::test_preferences_actions[Folding Bypass-folding_bypass] main_test.py::test_preferences_actions[Folding Bypass-folding_bypass]
main_test.py::test_preferences_actions[Show Advanced Parameters-advanced_parameters] main_test.py::test_preferences_actions[Show Advanced Parameters-advanced_parameters]
''' '''
+99 -4
View File
@@ -22,7 +22,7 @@ from typing import Any, cast
# External library imports # External library imports
import certifi import certifi
from PySide6.QtCore import QObject, QUrl, Signal from PySide6.QtCore import QObject, QThread, QUrl, Signal
from PySide6.QtGui import QDesktopServices from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import QMainWindow, QMenu, QMessageBox from PySide6.QtWidgets import QMainWindow, QMenu, QMessageBox
@@ -58,7 +58,7 @@ def parse_version(v_str: str) -> tuple[int, ...]:
class PluginManager(QObject): class PluginManager(QObject):
""" """
Handles plugin discovery, dynamic loading, menu building, Handles plugin discovery, dynamic loading, menu building,
and plugin lifecycle (install, toggle, uninstall). and plugin lifecycle (install, toggle, uninstall).
""" """
@@ -227,7 +227,7 @@ class PluginManager(QObject):
# Load local plugin.json if present # Load local plugin.json if present
manifest: dict[str, Any] = {} manifest: dict[str, Any] = {}
manifest_path = (entry / "plugin.json") if entry.is_dir() else None manifest_path = (entry / "plugin.json") if entry.is_dir() else None
if manifest_path and manifest_path.exists(): if manifest_path and manifest_path.exists():
try: try:
with open(manifest_path, "r", encoding="utf-8") as f: with open(manifest_path, "r", encoding="utf-8") as f:
@@ -328,4 +328,99 @@ class PluginManager(QObject):
"""Opens the local plugins folder in the operating system's file manager.""" """Opens the local plugins folder in the operating system's file manager."""
if self.plugins_dir.exists(): if self.plugins_dir.exists():
return QDesktopServices.openUrl(QUrl.fromLocalFile(str(self.plugins_dir))) return QDesktopServices.openUrl(QUrl.fromLocalFile(str(self.plugins_dir)))
return False return False
def check_for_updates(self, repo_urls: list[str]) -> list[dict[str, Any]]:
"""
Fetches remote repositories and compares server plugin versions
against installed plugins, returning the installed plugins list
enriched with update metadata.
"""
installed_plugins = self.get_installed_plugins_info()
remote_data, _ = self.fetch_remote_repositories(repo_urls)
# Index remote plugins by their ID (fallback to name if id is missing)
remote_map: dict[str, dict[str, Any]] = {}
for remote_plugin in remote_data:
plugin_id = remote_plugin.get("id") or remote_plugin.get("name")
if plugin_id:
remote_map[str(plugin_id)] = remote_plugin
# Compare versions and annotate each installed plugin dictionary
for plugin in installed_plugins:
plugin_id = str(plugin.get("id", ""))
local_version_str = str(plugin.get("version", "0.0.0"))
local_tuple = parse_version(local_version_str)
remote_plugin = remote_map.get(plugin_id)
if remote_plugin:
remote_version_str = str(remote_plugin.get("version", "0.0.0"))
remote_tuple = parse_version(remote_version_str)
if remote_tuple > local_tuple:
plugin["update_available"] = True
plugin["remote_version"] = remote_version_str
plugin["download_url"] = str(remote_plugin.get("download_url", ""))
else:
plugin["update_available"] = False
plugin["remote_version"] = remote_version_str
plugin["download_url"] = ""
else:
plugin["update_available"] = False
plugin["remote_version"] = None
plugin["download_url"] = ""
return installed_plugins
class StartupUpdateWorker(QThread):
updates_checked = Signal(list)
def __init__(self, manager: Any, repo_urls: list[str]) -> None:
super().__init__()
self.manager = manager
self.repo_urls = repo_urls
def run(self) -> None:
try:
print("[StartupWorker] Starting background update check...")
installed_plugins = self.manager.get_installed_plugins_info()
print(f"[StartupWorker] Found {len(installed_plugins)} installed local plugins.")
remote_data, all_failed = self.manager.fetch_remote_repositories(self.repo_urls)
print(f"[StartupWorker] Fetch remote complete. Failed: {all_failed}, Items fetched: {len(remote_data)}")
if all_failed or not remote_data:
print("[StartupWorker] Aborting: Remote fetch failed or returned empty data.")
return
remote_map: dict[str, dict[str, Any]] = {}
for raw_plugin in remote_data:
if isinstance(raw_plugin, dict):
remote_plugin = cast(dict[str, Any], raw_plugin)
plugin_id = remote_plugin.get("id") or remote_plugin.get("name")
if isinstance(plugin_id, str):
remote_map[plugin_id] = remote_plugin
outdated_plugins: list[dict[str, Any]] = []
for plugin in installed_plugins:
plugin_id = str(plugin.get("id", ""))
local_version_str = str(plugin.get("version", "0.0.0"))
local_tuple = parse_version(local_version_str)
remote_plugin = remote_map.get(plugin_id)
if remote_plugin:
remote_version_str = str(remote_plugin.get("version", "0.0.0"))
remote_tuple = parse_version(remote_version_str)
print(f"[StartupWorker] Comparing '{plugin.get('name')}': Local v{local_version_str} vs Remote v{remote_version_str}")
if remote_tuple > local_tuple:
plugin["remote_version"] = remote_version_str
plugin["download_url"] = str(remote_plugin.get("download_url", ""))
outdated_plugins.append(plugin)
print(f"[StartupWorker] Outdated plugins found: {len(outdated_plugins)}")
self.updates_checked.emit(outdated_plugins)
except Exception as e:
print(f"[StartupWorker] Error during startup update check: {e}")
+75 -16
View File
@@ -38,6 +38,23 @@ if TYPE_CHECKING:
from main import MainApplication from main import MainApplication
class LoadProjectThread(QThread):
finished_signal = Signal(object) # Emits the loaded project data dictionary
error_signal = Signal(str) # Emits the error message string
def __init__(self, filename: str) -> None:
super().__init__()
self.filename = filename
def run(self) -> None:
try:
with open(self.filename, "rb") as f:
project_data = pickle.load(f)
self.finished_signal.emit(project_data)
except Exception as e:
self.error_signal.emit(str(e))
class SaveProjectThread(QThread): class SaveProjectThread(QThread):
finished_signal = Signal(str) finished_signal = Signal(str)
error_signal = Signal(str) error_signal = Signal(str)
@@ -56,6 +73,25 @@ class SaveProjectThread(QThread):
self.error_signal.emit(str(e)) self.error_signal.emit(str(e))
class LoadingOverlay(QDialog):
def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
self.setModal(True)
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
layout = QVBoxLayout()
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
label = QLabel("Loading Project…")
label.setStyleSheet(
"font-size: 18px; color: white; background-color: rgba(0,0,0,150);"
" padding: 20px; border-radius: 10px;"
)
layout.addWidget(label)
self.setLayout(layout)
class SavingOverlay(QDialog): class SavingOverlay(QDialog):
def __init__(self, parent: Optional[QWidget] = None) -> None: def __init__(self, parent: Optional[QWidget] = None) -> None:
@@ -64,7 +100,7 @@ class SavingOverlay(QDialog):
self.setModal(True) self.setModal(True)
self.setWindowModality(Qt.WindowModality.ApplicationModal) self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
layout = QVBoxLayout() layout = QVBoxLayout()
layout.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
@@ -72,7 +108,7 @@ class SavingOverlay(QDialog):
label.setStyleSheet("font-size: 18px; color: white; background-color: rgba(0,0,0,150); padding: 20px; border-radius: 10px;") label.setStyleSheet("font-size: 18px; color: white; background-color: rgba(0,0,0,150); padding: 20px; border-radius: 10px;")
layout.addWidget(label) layout.addWidget(label)
self.setLayout(layout) self.setLayout(layout)
class ProjectManager: class ProjectManager:
""" """
@@ -118,7 +154,7 @@ class ProjectManager:
return f.read(8) == b"\x89HDF\r\n\x1a\n" return f.read(8) == b"\x89HDF\r\n\x1a\n"
except Exception: except Exception:
return False return False
def open_file_dialog(self) -> None: def open_file_dialog(self) -> None:
"""Opens dialog to pick a single .snirf file.""" """Opens dialog to pick a single .snirf file."""
file_path, _ = QFileDialog.getOpenFileName( file_path, _ = QFileDialog.getOpenFileName(
@@ -171,6 +207,8 @@ class ProjectManager:
) )
return return
self._load_files_into_pipeline(snirf_files)
def load_dropped_files(self, file_paths: List[str]) -> None: def load_dropped_files(self, file_paths: List[str]) -> None:
"""Public entry point for handling files or folders dropped onto the UI.""" """Public entry point for handling files or folders dropped onto the UI."""
if not file_paths: if not file_paths:
@@ -217,7 +255,7 @@ class ProjectManager:
# Delegate to internal pipeline # Delegate to internal pipeline
self._load_files_into_pipeline(valid_files) self._load_files_into_pipeline(valid_files)
def _load_files_into_pipeline(self, file_paths: List[str]) -> None: def _load_files_into_pipeline(self, file_paths: List[str]) -> None:
"""Loads .snirf files into UI using chunked batches and background workers.""" """Loads .snirf files into UI using chunked batches and background workers."""
app = self.app app = self.app
@@ -243,7 +281,7 @@ class ProjectManager:
# Filter out duplicates AND non-SNIRF files (including macOS ._ shadow files) # Filter out duplicates AND non-SNIRF files (including macOS ._ shadow files)
new_files = [ new_files = [
p for p in file_paths p for p in file_paths
if p not in app.selected_paths and self.is_valid_snirf(p) if p not in app.selected_paths and self.is_valid_snirf(p)
] ]
if not new_files: if not new_files:
@@ -339,12 +377,31 @@ class ProjectManager:
self.load_project(filename) self.load_project(filename)
def load_project(self, filename: str) -> None: def load_project(self, filename: str) -> None:
"""Loads a .flare project file into the application.""" """Triggers asynchronous loading of a .flare project file."""
app = self.app
# 1. Show the custom loading overlay matching your saver design
self.loading_overlay = LoadingOverlay(app)
self.loading_overlay.resize(app.size())
self.loading_overlay.move(app.pos())
self.loading_overlay.show()
# 2. Fire up the background thread for pickle loading
self.loader_thread = LoadProjectThread(filename)
self.loader_thread.finished_signal.connect(
lambda data: self._handle_loaded_data(filename, data)
)
self.loader_thread.error_signal.connect(self._handle_load_error)
self.loader_thread.start()
def _handle_loaded_data(self, filename: str, data: dict) -> None:
# Close overlay
if hasattr(self, "loading_overlay"):
self.loading_overlay.close()
self.loading_overlay.deleteLater()
app = self.app app = self.app
try: try:
with open(filename, "rb") as f:
data = pickle.load(f)
checks = [ checks = [
("version", "<=1.1.7"), ("version", "<=1.1.7"),
("file_metadata", "<=1.2.2"), ("file_metadata", "<=1.2.2"),
@@ -358,7 +415,7 @@ class ProjectManager:
f"This project was saved in an earlier version of {APP_NAME.upper()} ({ver_str}) " f"This project was saved in an earlier version of {APP_NAME.upper()} ({ver_str}) "
"and is potentially not compatible with this version. " "and is potentially not compatible with this version. "
) )
if getattr(app, "incompatible_save_bypass", False): if getattr(app, "processing_incompatible_save_bypass", False):
QMessageBox.warning( QMessageBox.warning(
app, f"Warning - {APP_NAME.upper()}", msg + "Attempting load." app, f"Warning - {APP_NAME.upper()}", msg + "Attempting load."
) )
@@ -438,16 +495,19 @@ class ProjectManager:
if hasattr(app, "button3"): if hasattr(app, "button3"):
app.button3.setVisible(has_data) app.button3.setVisible(has_data)
self.add_to_recent_projects(os.path.normpath(filename)) self.add_to_recent_projects(os.path.normpath(filename))
# Reset baselines cleanly
self.reset_all_dirty_states() self.reset_all_dirty_states()
QMessageBox.information(app, "Loaded", f"Project loaded from:\n{filename}") QMessageBox.information(app, "Loaded", f"Project loaded from:\n{filename}")
except Exception as e: except Exception as e:
QMessageBox.critical(app, "Error", f"Failed to load project:\n{e}") QMessageBox.critical(app, "Error", f"Failed to process loaded project data:\n{e}")
def _handle_load_error(self, error_msg: str) -> None:
if hasattr(self, "loading_overlay"):
self.loading_overlay.close()
self.loading_overlay.deleteLater()
QMessageBox.critical(self.app, "Error", f"Failed to read project file:\n{error_msg}")
# ========================================================================= # =========================================================================
# Project Saving (Save / Save As) # Project Saving (Save / Save As)
@@ -863,7 +923,6 @@ def _get_bids_demographics(snirf_path: str) -> dict[str, str]:
return {} return {}
def extract_metadata_worker(file_name: str) -> dict[str, Any]: def extract_metadata_worker(file_name: str) -> dict[str, Any]:
"""Runs in the separate worker process. Returns a clean dict.""" """Runs in the separate worker process. Returns a clean dict."""
@@ -921,7 +980,7 @@ def extract_metadata_worker(file_name: str) -> dict[str, Any]:
if "hand" in demographics: if "hand" in demographics:
snirf_info["BIDS - Handedness"] = demographics["hand"] snirf_info["BIDS - Handedness"] = demographics["hand"]
return snirf_info return snirf_info
except Exception as e: except Exception as e:
print(f"Worker safely caught failure on {file_name}: {str(e)}") print(f"Worker safely caught failure on {file_name}: {str(e)}")
return {'status': 'error', 'reason': str(e)} return {'status': 'error', 'reason': str(e)}
+80 -78
View File
@@ -12,6 +12,7 @@ import os
from typing import Any from typing import Any
# External library imports # External library imports
from mne import Epochs
from pandas import DataFrame from pandas import DataFrame
from mne.io.base import BaseRaw from mne.io.base import BaseRaw
@@ -25,8 +26,9 @@ from src.shared.shareddata import APP_NAME
class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget): class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
def __init__( def __init__(
self, self,
haemo_dict: dict[str, BaseRaw], haemo_dict: dict[str, BaseRaw],
epochs_dict: dict[str, Epochs],
cha_dict: dict[str, DataFrame], cha_dict: dict[str, DataFrame],
df_ind_dict: dict[str, DataFrame], df_ind_dict: dict[str, DataFrame],
design_matrix_dict: dict[str, DataFrame], design_matrix_dict: dict[str, DataFrame],
@@ -36,10 +38,11 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
fir_feature_dict: dict[str, dict[str, Any]], fir_feature_dict: dict[str, dict[str, Any]],
qc_dict: dict[str, dict[str, Any]], qc_dict: dict[str, dict[str, Any]],
) -> None: ) -> None:
super().__init__("ExportToCSV") super().__init__("ExportToCSV")
self.setWindowTitle(f"Export To CSV Viewer - {APP_NAME.upper()}") self.setWindowTitle(f"Export To CSV Viewer - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict self.haemo_dict = haemo_dict
self.epochs_dict = epochs_dict
self.cha_dict = cha_dict self.cha_dict = cha_dict
self.df_ind_dict = df_ind_dict self.df_ind_dict = df_ind_dict
self.design_matrix = design_matrix_dict self.design_matrix = design_matrix_dict
@@ -48,20 +51,19 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
self.config_dict = config_dict self.config_dict = config_dict
self.fir_feature_dict = fir_feature_dict self.fir_feature_dict = fir_feature_dict
self.qc_dict = qc_dict self.qc_dict = qc_dict
self.setup_csv_ui(["0 (Export Data to CSV)", "1 (CSV for SPARKS)", "2 (Export Configuration to CSV)", "3 (Paragraph of Configuration)", "4 (Export FIR Waveform Features to CSV)", self.setup_csv_ui(["0 (Export Data to CSV)", "1 (CSV for SPARKS)", "2 (Export Configuration to CSV)", "3 (Paragraph of Configuration)", "4 (Export FIR Waveform Features to CSV)",
"5 (Export Quality Control Metrics to CSV)", "5 (Export Quality Control Metrics to CSV)", "6 (Export Master Consolidated Matrix [All Participants into 1 CSV])"
"6 (Export Master Consolidated Matrix [All Participants into 1 CSV])"
]) ])
def process_request(self): def process_request(self):
selected_display_names = self._get_checked_items(self.participant_dropdown) selected_display_names = self._get_checked_items(self.participant_dropdown)
selected_file_paths: list[str] = [] selected_file_paths: list[str] = []
for display_name in selected_display_names: for display_name in selected_display_names:
for fp, short_label in self.participant_map.items(): for fp, short_label in self.participant_map.items():
expected_display = f"{short_label} ({os.path.basename(fp)})" expected_display = f"{short_label} ({os.path.basename(fp)})"
if display_name == expected_display: if display_name == expected_display:
selected_file_paths.append(fp) selected_file_paths.append(fp)
break break
@@ -74,7 +76,7 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
return return
output_dir = QFileDialog.getExistingDirectory(self, "Select Output Folder for CSV Exports") output_dir = QFileDialog.getExistingDirectory(self, "Select Output Folder for CSV Exports")
if not output_dir: if not output_dir:
print("Export cancelled: No folder selected.") print("Export cancelled: No folder selected.")
return return
@@ -120,78 +122,78 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
magic_string = self.gen_magic_str(first_params) magic_string = self.gen_magic_str(first_params)
self.placeholder_label.setText(magic_string) self.placeholder_label.setText(magic_string)
# elif idx == 4: elif idx == 4:
# # FIR Waveform Features Export # FIR Waveform Features Export
# fir_data = self.fir_feature_dict.get(file_path) fir_data = self.fir_feature_dict.get(file_path)
# if fir_data and isinstance(fir_data, dict): if fir_data:
# names = fir_data.get("feature_names", []) names = fir_data.get("feature_names", [])
# vals = fir_data.get("features", []) vals = fir_data.get("features", [])
# chans = fir_data.get("feature_channels", []) chans = fir_data.get("feature_channels", [])
# fir_df = DataFrame({ fir_df = DataFrame({
# "Feature_Name": names, "Feature_Name": names,
# "Channel": chans if len(chans) == len(names) else ["N/A"] * len(names), "Channel": chans if len(chans) == len(names) else ["N/A"] * len(names),
# "Value": vals "Value": vals
# }) })
# save_path = os.path.join(output_dir, f"{base_filename}_fir_features.csv") save_path = os.path.join(output_dir, f"{base_filename}_fir_features.csv")
# fir_df.to_csv(save_path, index=False) fir_df.to_csv(save_path, index=False)
# success_count += 1 success_count += 1
# elif idx == 5: elif idx == 5:
# # Quality Control (QC) Metrics Export # Quality Control (QC) Metrics Export
# qc_data = self.qc_dict.get(file_path) qc_data = self.qc_dict.get(file_path)
# if qc_data and isinstance(qc_data, dict): if qc_data and isinstance(qc_data, dict):
# qc_df = DataFrame(list(qc_data.items()), columns=["Metric", "Value"]) qc_df = DataFrame(list(qc_data.items()), columns=["Metric", "Value"])
# save_path = os.path.join(output_dir, f"{base_filename}_qc_metrics.csv") save_path = os.path.join(output_dir, f"{base_filename}_qc_metrics.csv")
# qc_df.to_csv(save_path, index=False) qc_df.to_csv(save_path, index=False)
# success_count += 1 success_count += 1
# elif idx == 6: elif idx == 6:
# # Master Consolidated Matrix (1 single CSV combining all selected participants) # Master Consolidated Matrix (1 single CSV combining all selected participants)
# save_path = os.path.join(output_dir, f"{APP_NAME}_master_consolidated.csv") save_path = os.path.join(output_dir, f"{APP_NAME}_master_consolidated.csv")
# if not os.path.exists(save_path): if not os.path.exists(save_path):
# master_rows: list[dict[str, Any]] = [] master_rows: list[dict[str, Any]] = []
# for fp in selected_file_paths: for fp in selected_file_paths:
# abs_path = os.path.abspath(fp) abs_path = os.path.abspath(fp)
# grp = self.group_dict.get(fp, "Unknown") grp = self.group_dict.get(fp, "Unknown")
# row: dict[str, Any] = {"Participant": abs_path, "Group": grp} row: dict[str, Any] = {"Participant": abs_path, "Group": grp}
# # QC Metrics # QC Metrics
# qc_info = self.qc_dict.get(fp, {}) qc_info = self.qc_dict.get(fp, {})
# if isinstance(qc_info, dict): if isinstance(qc_info, dict):
# for mk, mv in qc_info.items(): for mk, mv in qc_info.items():
# row[f"QC_{mk}"] = mv row[f"QC_{mk}"] = mv
# # FIR Features # FIR Features
# fir_info = self.fir_feature_dict.get(fp, {}) fir_info = self.fir_feature_dict.get(fp, {})
# print("1") print("1")
# if isinstance(fir_info, dict): if isinstance(fir_info, dict):
# print("2") print("2")
# f_names = fir_info.get("feature_names", []) f_names = fir_info.get("feature_names", [])
# f_vals = fir_info.get("features", []) f_vals = fir_info.get("features", [])
# print("3") print("3")
# if len(f_names) == len(f_vals): if len(f_names) == len(f_vals):
# print("4") print("4")
# for fn, fv in zip(f_names, f_vals): for fn, fv in zip(f_names, f_vals):
# row[f"FIR_{fn}"] = fv row[f"FIR_{fn}"] = fv
# master_rows.append(row) master_rows.append(row)
# if master_rows: if master_rows:
# df_master = DataFrame(master_rows) df_master = DataFrame(master_rows)
# df_master.to_csv(save_path, index=False) df_master.to_csv(save_path, index=False)
# success_count += 1 success_count += 1
else: else:
print(f"No method defined for index {idx}") print(f"No method defined for index {idx}")
except Exception as e: except Exception as e:
print(f"Failed to export {file_path} (Type {idx}): {e}") print(f"Failed to export {file_path} (Type {idx}): {e}")
# 4. Final Notification # 4. Final Notification
if success_count > 0: if success_count > 0:
QMessageBox.information(self, "Export Complete", f"Successfully saved {success_count} CSV files to:\n{output_dir}") QMessageBox.information(self, "Export Complete", f"Successfully saved {success_count} CSV files to:\n{output_dir}")
# # If SPARKS export was included, show the Event Window once at the end # # If SPARKS export was included, show the Event Window once at the end
# if 1 in selected_indexes: # if 1 in selected_indexes:
# win = UpdateEventsWindow( # win = UpdateEventsWindow(
@@ -203,62 +205,62 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
def gen_magic_str(self, all_params: dict[str, str]) -> str: def gen_magic_str(self, all_params: dict[str, str]) -> str:
magic_str = "To start, the data was loaded into the application. " magic_str = "To start, the data was loaded into the application. "
if all_params['DOWNSAMPLE']: if all_params['DOWNSAMPLE']:
magic_str += f"The data was downsampled to {all_params['DOWNSAMPLE_FREQUENCY']}hz. " magic_str += f"The data was downsampled to {all_params['DOWNSAMPLE_FREQUENCY']}hz. "
else: else:
magic_str += "The data was not downsampled and was retained at its original sampling frequency. " magic_str += "The data was not downsampled and was retained at its original sampling frequency. "
if all_params['TRIM']: if all_params['TRIM']:
magic_str += f"The data was then trimmed to only keep {all_params['SECONDS_TO_KEEP']} seconds before the first event, as the earlier data with no events would not be needed. " magic_str += f"The data was then trimmed to only keep {all_params['SECONDS_TO_KEEP']} seconds before the first event, as the earlier data with no events would not be needed. "
else: else:
magic_str += "The data was not trimmed and was retained at its original length. " magic_str += "The data was not trimmed and was retained at its original length. "
if all_params['OPTODE_PLACEMENT']: if all_params['OPTODE_PLACEMENT']:
magic_str += f"A visualization check of the optode locations in 3D space was performed to ensure that they were at the correct location. " magic_str += f"A visualization check of the optode locations in 3D space was performed to ensure that they were at the correct location. "
if all_params['SHORT_CHANNELS']: if all_params['SHORT_CHANNELS']:
magic_str += f"A short channel(s) was specified with the maximum length of a short channel being {all_params['SHORT_CHANNELS_THRESHOLD']} metres. Channels that were over this distance but under {all_params['LONG_CHANNELS_THRESHOLD']} metres were retained. " magic_str += f"A short channel(s) was specified with the maximum length of a short channel being {all_params['SHORT_CHANNELS_THRESHOLD']} metres. Channels that were over this distance but under {all_params['LONG_CHANNELS_THRESHOLD']} metres were retained. "
else: else:
magic_str += f"No short channel was specified. Channels between the lengths of {all_params['SHORT_CHANNELS_THRESHOLD']} and {all_params['LONG_CHANNELS_THRESHOLD']} meters were used with all other channles being discarded. " magic_str += f"No short channel was specified. Channels between the lengths of {all_params['SHORT_CHANNELS_THRESHOLD']} and {all_params['LONG_CHANNELS_THRESHOLD']} meters were used with all other channles being discarded. "
if all_params['HEART_RATE']: if all_params['HEART_RATE']:
magic_str += f"The heart rate of the participant was attempted to be calculated. {all_params['SECONDS_TO_STRIP_HR']} seconds were ignored from the start of the file to avoid motion artifacts. " magic_str += f"The heart rate of the participant was attempted to be calculated. {all_params['SECONDS_TO_STRIP_HR']} seconds were ignored from the start of the file to avoid motion artifacts. "
magic_str += f"The heart rate had hard limits of {all_params['MAX_LOW_HR']} to {all_params['MAX_HIGH_HR']} and was smoothed across {all_params['SMOOTHING_WINDOW_HR']} samples. " magic_str += f"The heart rate had hard limits of {all_params['MAX_LOW_HR']} to {all_params['MAX_HIGH_HR']} and was smoothed across {all_params['SMOOTHING_WINDOW_HR']} samples. "
else: else:
magic_str += "The heart rate of the participants was not attempted to be calculated. " magic_str += "The heart rate of the participants was not attempted to be calculated. "
if all_params['SCI']: if all_params['SCI']:
magic_str += f"The Scalp Coupling Index was calculated over {all_params['SCI_TIME_WINDOW']} second windows. The threshold value was {all_params['SCI_THRESHOLD']}. " magic_str += f"The Scalp Coupling Index was calculated over {all_params['SCI_TIME_WINDOW']} second windows. The threshold value was {all_params['SCI_THRESHOLD']}. "
else: else:
magic_str += "Scalp Coupling Index was not used. " magic_str += "Scalp Coupling Index was not used. "
if all_params['SNR']: if all_params['SNR']:
magic_str += f"The Signal to Noise Ratio was calculated. The threshold value was set to {all_params['SNR_THRESHOLD']}db. " magic_str += f"The Signal to Noise Ratio was calculated. The threshold value was set to {all_params['SNR_THRESHOLD']}db. "
else: else:
magic_str += "The Signal to Noise Ratio was not used. " magic_str += "The Signal to Noise Ratio was not used. "
if all_params['PSP']: if all_params['PSP']:
magic_str += f"Peak Spectral Power was calculated over {all_params['PSP_TIME_WINDOW']} second windows. The threshold value was {all_params['PSP_THRESHOLD']}. " magic_str += f"Peak Spectral Power was calculated over {all_params['PSP_TIME_WINDOW']} second windows. The threshold value was {all_params['PSP_THRESHOLD']}. "
else: else:
magic_str += "Peak Spectral Power was not used. " magic_str += "Peak Spectral Power was not used. "
if all_params['COEFF_VAR']: if all_params['COEFF_VAR']:
magic_str += f"The Coefficient of Variation was calculated. The threshold value was set to {all_params['COEFF_VAR_THRESHOLD']}. " magic_str += f"The Coefficient of Variation was calculated. The threshold value was set to {all_params['COEFF_VAR_THRESHOLD']}. "
else: else:
magic_str += "The Coefficient of Variation was not used. " magic_str += "The Coefficient of Variation was not used. "
if all_params['MAD']: if all_params['MAD']:
magic_str += f"Median Absolute Deviation was calculated. The threshold value was set to {all_params['MAD_THRESHOLD']}. " magic_str += f"Median Absolute Deviation was calculated. The threshold value was set to {all_params['MAD_THRESHOLD']}. "
else: else:
magic_str += "Median Absolute Deviation was not used. " magic_str += "Median Absolute Deviation was not used. "
if all_params['PSD_NOISE']: if all_params['PSD_NOISE']:
magic_str += f"Power Spectral Density Noise was calculated with a target frequency divisor of {all_params['TARGET_FREQ_DIV']} and a decibal limit of {all_params['DB_LIMIT']}. " magic_str += f"Power Spectral Density Noise was calculated with a target frequency divisor of {all_params['TARGET_FREQ_DIV']} and a decibal limit of {all_params['DB_LIMIT']}. "
else: else:
magic_str += f"Power Spectral Density Noise was not used. " magic_str += f"Power Spectral Density Noise was not used. "
magic_str += "More coming soon." magic_str += "More coming soon."
return magic_str return magic_str
+4 -4
View File
@@ -68,13 +68,13 @@ DESCRIPTION = """\n1. Group Contrast 2D/3D (plot_2d_3d_contrasts_between_groups)
class InterGroupBrainImageWidget(InterGroupUIMixin, FlaresBaseWidget): class InterGroupBrainImageWidget(InterGroupUIMixin, FlaresBaseWidget):
def __init__( def __init__(
self, self,
haemo_dict: dict[str, BaseRaw], haemo_dict: dict[str, BaseRaw],
df_ind_dict: dict[str, DataFrame], df_ind_dict: dict[str, DataFrame],
design_matrix_dict: dict[str, DataFrame], design_matrix_dict: dict[str, DataFrame],
contrast_results_dict: dict[str, dict[str, Any]], contrast_results_dict: dict[str, dict[str, Any]],
group_dict: dict[str, str], group_dict: dict[str, str],
) -> None: ) -> None:
super().__init__("InterGroupBrainImage") super().__init__("InterGroupBrainImage")
self.setWindowTitle(f"Inter-Group Brain & Image Viewer - {APP_NAME.upper()}") self.setWindowTitle(f"Inter-Group Brain & Image Viewer - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict self.haemo_dict = haemo_dict
@@ -87,7 +87,7 @@ class InterGroupBrainImageWidget(InterGroupUIMixin, FlaresBaseWidget):
def process_request(self): def process_request(self):
request = self.get_common_request_data(PARAMETERIZED_INDEXES) request = self.get_common_request_data(PARAMETERIZED_INDEXES)
if request is None: if request is None:
return return
@@ -122,7 +122,7 @@ class InterGroupBrainImageWidget(InterGroupUIMixin, FlaresBaseWidget):
print("contrast_df_b empty?", contrast_df_b.empty) print("contrast_df_b empty?", contrast_df_b.empty)
all_raw_objs = [self.haemo_dict.get(fp) for fp in all_selected_paths if self.haemo_dict.get(fp)] all_raw_objs = [self.haemo_dict.get(fp) for fp in all_selected_paths if self.haemo_dict.get(fp)]
if len(all_raw_objs) > 1: if len(all_raw_objs) > 1:
processed_raw = aggregate_fnirs_group_geometry(all_raw_objs) processed_raw = aggregate_fnirs_group_geometry(all_raw_objs)
elif len(all_raw_objs) == 1 and all_raw_objs[0] is not None: elif len(all_raw_objs) == 1 and all_raw_objs[0] is not None:
+21 -21
View File
@@ -125,7 +125,7 @@ PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
}, },
], ],
} }
DESCRIPTION = """0. Raw ROI Comparison (run_inter_group_second_level_analysis) DESCRIPTION = """0. Raw ROI Comparison (run_inter_group_second_level_analysis)
\nCompares one ROI's raw response magnitude between two independent groups (e.g. control vs. target) for a given condition, using Welch's t-test. A significant result means the two populations differ in this ROI's response magnitude for this condition. It does not tell you whether that difference is a real, localized, task-specific effect or a generic between-population difference - different overall vascular reactivity, arousal, or skull/scalp optical properties can produce the exact same statistical signature, and two independently recruited groups (especially patients vs. healthy controls) are considerably more likely to differ this way than two subsets of one study population. \nCompares one ROI's raw response magnitude between two independent groups (e.g. control vs. target) for a given condition, using Welch's t-test. A significant result means the two populations differ in this ROI's response magnitude for this condition. It does not tell you whether that difference is a real, localized, task-specific effect or a generic between-population difference - different overall vascular reactivity, arousal, or skull/scalp optical properties can produce the exact same statistical signature, and two independently recruited groups (especially patients vs. healthy controls) are considerably more likely to differ this way than two subsets of one study population.
@@ -145,8 +145,8 @@ DESCRIPTION = """0. Raw ROI Comparison (run_inter_group_second_level_analysis)
class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget): class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
def __init__( def __init__(
self, self,
haemo_dict: dict[str, BaseRaw], haemo_dict: dict[str, BaseRaw],
cha_dict: dict[str, DataFrame], cha_dict: dict[str, DataFrame],
df_ind_dict: dict[str, DataFrame], df_ind_dict: dict[str, DataFrame],
design_matrix_dict: dict[str, DataFrame], design_matrix_dict: dict[str, DataFrame],
@@ -154,7 +154,7 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
roi_channel_map_dict: dict[str, dict[str, str]], roi_channel_map_dict: dict[str, dict[str, str]],
group_dict: dict[str, str], group_dict: dict[str, str],
) -> None: ) -> None:
super().__init__("InterGroupStats") super().__init__("InterGroupStats")
self.setWindowTitle(f"Inter-Group Stats Viewer - {APP_NAME.upper()}") self.setWindowTitle(f"Inter-Group Stats Viewer - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict self.haemo_dict = haemo_dict
@@ -166,7 +166,7 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
self.group_dict = group_dict self.group_dict = group_dict
self.setup_inter_group_ui(["0 (Raw ROI Comparison)", "1 (Laterality Comparison)", "2 (Contrast Comparison)",], placeholder_text=DESCRIPTION) self.setup_inter_group_ui(["0 (Raw ROI Comparison)", "1 (Laterality Comparison)", "2 (Contrast Comparison)",], placeholder_text=DESCRIPTION)
def process_request(self): def process_request(self):
request = self.get_common_request_data(PARAMETERIZED_INDEXES, self.df_ind_dict, self.contrast_results_dict) request = self.get_common_request_data(PARAMETERIZED_INDEXES, self.df_ind_dict, self.contrast_results_dict)
@@ -185,7 +185,7 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
valid_chas = [df for df in self.cha_dict.values() if not df.empty] valid_chas = [df for df in self.cha_dict.values() if not df.empty]
cha_combined = pd.concat(valid_chas, ignore_index=True) if valid_chas else pd.DataFrame() cha_combined = pd.concat(valid_chas, ignore_index=True) if valid_chas else pd.DataFrame()
sample_path = file_paths_a[0] sample_path = file_paths_a[0]
p_haemo = self.haemo_dict.get(sample_path) p_haemo = self.haemo_dict.get(sample_path)
@@ -226,7 +226,7 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
print("Laterality comparison requires a specific event/condition " print("Laterality comparison requires a specific event/condition "
"to be selected first.") "to be selected first.")
continue continue
params = param_values.get(idx, {}) params = param_values.get(idx, {})
p_threshold = params.get("p_threshold", 0.05) p_threshold = params.get("p_threshold", 0.05)
min_subjects = params.get("min_subjects", 3) min_subjects = params.get("min_subjects", 3)
@@ -234,36 +234,36 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
target_chroma = params.get("target_chroma", "hbo") target_chroma = params.get("target_chroma", "hbo")
roi_a: str = params.get("roi_a", "").strip() roi_a: str = params.get("roi_a", "").strip()
roi_b: str = params.get("roi_b", "").strip() roi_b: str = params.get("roi_b", "").strip()
if not roi_a or not roi_b: if not roi_a or not roi_b:
print("Both a contralateral and ipsilateral ROI name must be specified.") print("Both a contralateral and ipsilateral ROI name must be specified.")
continue continue
if correction_method == "None": if correction_method == "None":
correction_method = None correction_method = None
# Build each group's dataframe directly from the dict using # Build each group's dataframe directly from the dict using
# the file-path lists as keys - no ID cleaning/matching needed. # the file-path lists as keys - no ID cleaning/matching needed.
def _build_group_df( def _build_group_df(
file_paths: list[str], file_paths: list[str],
dict_source: dict[str, DataFrame] dict_source: dict[str, DataFrame]
) -> DataFrame: ) -> DataFrame:
valid_dfs = [ valid_dfs = [
dict_source[fp] for fp in file_paths dict_source[fp] for fp in file_paths
if fp in dict_source and not dict_source[fp].empty if fp in dict_source and not dict_source[fp].empty
] ]
return pd.concat(valid_dfs, ignore_index=True) if valid_dfs else pd.DataFrame() return pd.concat(valid_dfs, ignore_index=True) if valid_dfs else pd.DataFrame()
df_roi_a = _build_group_df(file_paths_a, self.df_ind_dict) df_roi_a = _build_group_df(file_paths_a, self.df_ind_dict)
df_roi_b = _build_group_df(file_paths_b, self.df_ind_dict) df_roi_b = _build_group_df(file_paths_b, self.df_ind_dict)
if df_roi_a.empty or df_roi_b.empty: if df_roi_a.empty or df_roi_b.empty:
print("No ROI data (df_ind) found for one or both groups.") print("No ROI data (df_ind) found for one or both groups.")
continue continue
run_inter_group_laterality_analysis( run_inter_group_laterality_analysis(
df_roi_all_a=df_roi_a, df_roi_all_a=df_roi_a,
df_roi_all_b=df_roi_b, df_roi_all_b=df_roi_b,
@@ -278,7 +278,7 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
roi_contra_label=roi_a, roi_contra_label=roi_a,
roi_ipsi_label=roi_b, roi_ipsi_label=roi_b,
) )
elif idx == 2: elif idx == 2:
params = param_values.get(idx, {}) params = param_values.get(idx, {})
p_threshold = params.get("p_threshold", 0.05) p_threshold = params.get("p_threshold", 0.05)
@@ -286,11 +286,11 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
correction_method = params.get("correction_method", "fdr_bh") correction_method = params.get("correction_method", "fdr_bh")
target_chroma = params.get("target_chroma", "hbo") target_chroma = params.get("target_chroma", "hbo")
contrast_name = params.get("contrast_name", "") contrast_name = params.get("contrast_name", "")
if not contrast_name: if not contrast_name:
print("A contrast name must be specified.") print("A contrast name must be specified.")
continue continue
# Build each group's channel-level contrast dataframe # Build each group's channel-level contrast dataframe
# directly from contrast_results_dict, keyed by file path - # directly from contrast_results_dict, keyed by file path -
# same dict-key approach as the laterality patch, avoids # same dict-key approach as the laterality patch, avoids
@@ -300,7 +300,7 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
contrast_dict: dict[str, dict[str, pd.DataFrame]], contrast_dict: dict[str, dict[str, pd.DataFrame]],
name: str, name: str,
) -> pd.DataFrame: ) -> pd.DataFrame:
all_rows: list[DataFrame] = [] all_rows: list[DataFrame] = []
for fp in file_paths: for fp in file_paths:
condition_dfs = contrast_dict.get(fp) condition_dfs = contrast_dict.get(fp)
@@ -315,10 +315,10 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
else: else:
print(f" [MISSING CONTRAST] '{name}' not available for '{fp}'.") print(f" [MISSING CONTRAST] '{name}' not available for '{fp}'.")
return pd.concat(all_rows, ignore_index=True) if all_rows else pd.DataFrame() return pd.concat(all_rows, ignore_index=True) if all_rows else pd.DataFrame()
df_contrasts_a = _build_group_contrast_df(file_paths_a, self.contrast_results_dict, contrast_name) df_contrasts_a = _build_group_contrast_df(file_paths_a, self.contrast_results_dict, contrast_name)
df_contrasts_b = _build_group_contrast_df(file_paths_b, self.contrast_results_dict, contrast_name) df_contrasts_b = _build_group_contrast_df(file_paths_b, self.contrast_results_dict, contrast_name)
if df_contrasts_a.empty or df_contrasts_b.empty: if df_contrasts_a.empty or df_contrasts_b.empty:
print("No contrast data found for one or both groups.") print("No contrast data found for one or both groups.")
continue continue
@@ -350,6 +350,6 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
p_threshold=p_threshold, p_threshold=p_threshold,
correction_method=correction_method, correction_method=correction_method,
) )
else: else:
print("no") print("no")
+8 -8
View File
@@ -78,7 +78,7 @@ DESCRIPTION = """0. FIR Model Results (plot_fir_model_results)
class IntraGroupBrainImageWidget(IntraGroupUIMixin, FlaresBaseWidget): class IntraGroupBrainImageWidget(IntraGroupUIMixin, FlaresBaseWidget):
def __init__( def __init__(
self, self,
haemo_dict: dict[str, BaseRaw], haemo_dict: dict[str, BaseRaw],
cha_dict: dict[str, DataFrame], cha_dict: dict[str, DataFrame],
df_ind_dict: dict[str, DataFrame], df_ind_dict: dict[str, DataFrame],
design_matrix_dict: dict[str, DataFrame], design_matrix_dict: dict[str, DataFrame],
@@ -94,7 +94,7 @@ class IntraGroupBrainImageWidget(IntraGroupUIMixin, FlaresBaseWidget):
self.design_matrix_dict = design_matrix_dict self.design_matrix_dict = design_matrix_dict
self.contrast_results_dict = contrast_results_dict self.contrast_results_dict = contrast_results_dict
self.group_dict = group_dict self.group_dict = group_dict
self.setup_intra_group_ui(["0 (GLM Results)", "1 (Brain Activity Visualization)"], placeholder_text=DESCRIPTION) self.setup_intra_group_ui(["0 (GLM Results)", "1 (Brain Activity Visualization)"], placeholder_text=DESCRIPTION)
@@ -116,18 +116,18 @@ class IntraGroupBrainImageWidget(IntraGroupUIMixin, FlaresBaseWidget):
if selected_event: if selected_event:
raw_annotations = getattr(haemo_obj, "annotations", None) raw_annotations = getattr(haemo_obj, "annotations", None)
if raw_annotations is not None: if raw_annotations is not None:
annotations = cast(Annotations, raw_annotations) annotations = cast(Annotations, raw_annotations)
descriptions = cast(list[str], list(annotations.description)) descriptions = cast(list[str], list(annotations.description))
participant_events: set[str] = set(descriptions) participant_events: set[str] = set(descriptions)
else: else:
participant_events: set[str] = set() participant_events: set[str] = set()
if selected_event not in participant_events: if selected_event not in participant_events:
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.") print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
continue continue
cha_df = self.cha_dict.get(file_path) cha_df = self.cha_dict.get(file_path)
if cha_df is not None: if cha_df is not None:
all_cha = pd.concat([all_cha, cha_df], ignore_index=True) all_cha = pd.concat([all_cha, cha_df], ignore_index=True)
@@ -144,7 +144,7 @@ class IntraGroupBrainImageWidget(IntraGroupUIMixin, FlaresBaseWidget):
df = self.df_ind_dict.get(file_path) df = self.df_ind_dict.get(file_path)
if df is not None: if df is not None:
df_group = pd.concat([df_group, df], ignore_index=True) df_group = pd.concat([df_group, df], ignore_index=True)
for idx in selected_indexes: for idx in selected_indexes:
if idx == 0: if idx == 0:
@@ -170,7 +170,7 @@ class IntraGroupBrainImageWidget(IntraGroupUIMixin, FlaresBaseWidget):
continue continue
all_raw_objs = [self.haemo_dict.get(fp) for fp in selected_file_paths if self.haemo_dict.get(fp)] all_raw_objs = [self.haemo_dict.get(fp) for fp in selected_file_paths if self.haemo_dict.get(fp)]
if len(all_raw_objs) > 1: if len(all_raw_objs) > 1:
processed_raw = aggregate_fnirs_group_geometry(all_raw_objs) processed_raw = aggregate_fnirs_group_geometry(all_raw_objs)
elif len(all_raw_objs) == 1 and all_raw_objs[0] is not None: elif len(all_raw_objs) == 1 and all_raw_objs[0] is not None:
@@ -180,6 +180,6 @@ class IntraGroupBrainImageWidget(IntraGroupUIMixin, FlaresBaseWidget):
processed_raw = None processed_raw = None
brain_3d_visualization(processed_raw, all_cha, selected_event, t_or_theta=t_or_theta, show_optodes=show_optodes, show_text=show_text, brain_bounds=brain_bounds) brain_3d_visualization(processed_raw, all_cha, selected_event, t_or_theta=t_or_theta, show_optodes=show_optodes, show_text=show_text, brain_bounds=brain_bounds)
else: else:
print(f"No method defined for index {idx}") print(f"No method defined for index {idx}")
@@ -64,7 +64,7 @@ class IntraGroupFunctionalConnectivityWidget(IntraGroupUIMixin, FlaresBaseWidget
epochs_dict: dict[str, Epochs], epochs_dict: dict[str, Epochs],
group_dict: dict[str, str], group_dict: dict[str, str],
) -> None: ) -> None:
super().__init__("IntraGroupFunctionalConnectivity") super().__init__("IntraGroupFunctionalConnectivity")
self.setWindowTitle(f"Intra-Group Functional Connectivity Viewer [BETA] - {APP_NAME.upper()}") self.setWindowTitle(f"Intra-Group Functional Connectivity Viewer [BETA] - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict self.haemo_dict = haemo_dict
+16 -17
View File
@@ -157,8 +157,8 @@ DESCRIPTION = """0. ROI vs. Zero (run_roi_second_level_analysis)
class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget): class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget):
def __init__( def __init__(
self, self,
haemo_dict: dict[str, BaseRaw], haemo_dict: dict[str, BaseRaw],
cha_dict: dict[str, DataFrame], cha_dict: dict[str, DataFrame],
df_ind_dict: dict[str, DataFrame], df_ind_dict: dict[str, DataFrame],
design_matrix_dict: dict[str, DataFrame], design_matrix_dict: dict[str, DataFrame],
@@ -166,7 +166,7 @@ class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget):
roi_channel_map_dict: dict[str, dict[str, str]], roi_channel_map_dict: dict[str, dict[str, str]],
group_dict: dict[str, str], group_dict: dict[str, str],
) -> None: ) -> None:
super().__init__("IntraGroupStats") super().__init__("IntraGroupStats")
self.setWindowTitle(f"Intra-Group Stats Viewer - {APP_NAME.upper()}") self.setWindowTitle(f"Intra-Group Stats Viewer - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict self.haemo_dict = haemo_dict
@@ -185,7 +185,7 @@ class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget):
if request is None: if request is None:
return return
(selected_event, selected_file_paths, selected_indexes, raw_params) = request (selected_event, selected_file_paths, selected_indexes, raw_params) = request
param_values = cast(dict[int | str, dict[str, Any]], raw_params) param_values = cast(dict[int | str, dict[str, Any]], raw_params)
@@ -198,20 +198,19 @@ class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget):
if selected_event: if selected_event:
raw_annotations = getattr(haemo_obj, "annotations", None) raw_annotations = getattr(haemo_obj, "annotations", None)
if raw_annotations is not None: if raw_annotations is not None:
annotations = cast(Annotations, raw_annotations) annotations = cast(Annotations, raw_annotations)
descriptions = cast(list[str], list(annotations.description)) descriptions = cast(list[str], list(annotations.description))
participant_events: set[str] = set(descriptions) participant_events: set[str] = set(descriptions)
else: else:
participant_events: set[str] = set() participant_events: set[str] = set()
if selected_event not in participant_events: if selected_event not in participant_events:
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.") print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
continue continue
cha_df = self.cha_dict.get(file_path) cha_df = self.cha_dict.get(file_path)
if cha_df is not None: if cha_df is not None:
all_cha = pd.concat([all_cha, cha_df], ignore_index=True) all_cha = pd.concat([all_cha, cha_df], ignore_index=True)
@@ -288,23 +287,23 @@ class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget):
target_chroma = params.get("target_chroma", "hbo") target_chroma = params.get("target_chroma", "hbo")
roi_a = params.get("roi_a", "").strip() roi_a = params.get("roi_a", "").strip()
roi_b = params.get("roi_b", "").strip() roi_b = params.get("roi_b", "").strip()
if not selected_event: if not selected_event:
print("Paired ROI contrast requires a specific event/condition " print("Paired ROI contrast requires a specific event/condition "
"to be selected - pick one from the Event dropdown first.") "to be selected - pick one from the Event dropdown first.")
continue continue
if df_group.empty: if df_group.empty:
print("No ROI data (df_ind) found for selected participants.") print("No ROI data (df_ind) found for selected participants.")
continue continue
if correction_method == "None": if correction_method == "None":
correction_method = None correction_method = None
if not roi_a or not roi_b: if not roi_a or not roi_b:
print("Both ROI A and ROI B must be specified.") print("Both ROI A and ROI B must be specified.")
continue continue
run_roi_paired_contrast_analysis( run_roi_paired_contrast_analysis(
df_roi_all=df_group, df_roi_all=df_group,
roi_pairs=(roi_a, roi_b), roi_pairs=(roi_a, roi_b),
@@ -351,12 +350,12 @@ class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget):
else: else:
print(f" [MISSING CONTRAST] '{contrast_name}' not " print(f" [MISSING CONTRAST] '{contrast_name}' not "
f"available for {self.participant_map.get(fp, fp)}.") f"available for {self.participant_map.get(fp, fp)}.")
if not all_contrasts: if not all_contrasts:
print(f"No contrast data found for '{contrast_name}' " print(f"No contrast data found for '{contrast_name}' "
f"across selected participants.") f"across selected participants.")
continue continue
df_contrasts = pd.concat(all_contrasts, ignore_index=True) df_contrasts = pd.concat(all_contrasts, ignore_index=True)
selected_roi_maps = { selected_roi_maps = {
@@ -378,12 +377,12 @@ class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget):
except Exception as e: except Exception as e:
print(f"Failed to aggregate contrasts to ROI: {e}") print(f"Failed to aggregate contrasts to ROI: {e}")
continue continue
if roi_theta.empty: if roi_theta.empty:
print("No ROI-level contrast values could be computed " print("No ROI-level contrast values could be computed "
"(check regions.json channel names against this montage).") "(check regions.json channel names against this montage).")
continue continue
run_roi_second_level_analysis( run_roi_second_level_analysis(
df_roi_all=roi_theta, df_roi_all=roi_theta,
condition=contrast_name, condition=contrast_name,
@@ -395,6 +394,6 @@ class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget):
target_chroma=target_chroma, target_chroma=target_chroma,
graph_bounds=graph_bounds if graph_bounds > 0.0 else None, graph_bounds=graph_bounds if graph_bounds > 0.0 else None,
) )
else: else:
print(f"No method defined for index {idx}") print(f"No method defined for index {idx}")
+6 -6
View File
@@ -67,11 +67,11 @@ PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
class ParticipantBrainViewerWidget(ParticipantUIMixin, FlaresBaseWidget): class ParticipantBrainViewerWidget(ParticipantUIMixin, FlaresBaseWidget):
def __init__( def __init__(
self, self,
haemo_dict: dict[str, BaseRaw], haemo_dict: dict[str, BaseRaw],
cha_dict: dict[str, DataFrame], cha_dict: dict[str, DataFrame],
) -> None: ) -> None:
super().__init__("ParticipantBrain") super().__init__("ParticipantBrain")
self.setWindowTitle(f"Participant Brain Viewer - {APP_NAME.upper()}") self.setWindowTitle(f"Participant Brain Viewer - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict self.haemo_dict = haemo_dict
@@ -86,7 +86,7 @@ class ParticipantBrainViewerWidget(ParticipantUIMixin, FlaresBaseWidget):
if request is None: if request is None:
return return
(selected_event, selected_file_paths, selected_indexes, raw_params) = request (selected_event, selected_file_paths, selected_indexes, raw_params) = request
param_values = cast(dict[int | str, dict[str, Any]], raw_params) param_values = cast(dict[int | str, dict[str, Any]], raw_params)
@@ -99,14 +99,14 @@ class ParticipantBrainViewerWidget(ParticipantUIMixin, FlaresBaseWidget):
if selected_event: if selected_event:
raw_annotations = getattr(haemo_obj, "annotations", None) raw_annotations = getattr(haemo_obj, "annotations", None)
if raw_annotations is not None: if raw_annotations is not None:
annotations = cast(Annotations, raw_annotations) annotations = cast(Annotations, raw_annotations)
descriptions = cast(list[str], list(annotations.description)) descriptions = cast(list[str], list(annotations.description))
participant_events: set[str] = set(descriptions) participant_events: set[str] = set(descriptions)
else: else:
participant_events: set[str] = set() participant_events: set[str] = set()
if selected_event not in participant_events: if selected_event not in participant_events:
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.") print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
continue continue
+147 -149
View File
@@ -40,7 +40,7 @@ class MultiProgressDialog(QDialog):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("fOLD Analysis Progress") self.setWindowTitle("fOLD Analysis Progress")
self.setFixedWidth(400) self.setFixedWidth(400)
self.setWindowModality(Qt.WindowModality.NonModal) self.setWindowModality(Qt.WindowModality.NonModal)
self.main_layout = QVBoxLayout(self) self.main_layout = QVBoxLayout(self)
self.bars: Dict[str, QProgressBar] = {} self.bars: Dict[str, QProgressBar] = {}
self.allow_closing = False self.allow_closing = False
@@ -52,7 +52,7 @@ class MultiProgressDialog(QDialog):
pbar.setMinimum(0) pbar.setMinimum(0)
pbar.setMaximum(int(total_steps)) # Ensure this is a strict integer pbar.setMaximum(int(total_steps)) # Ensure this is a strict integer
pbar.setValue(0) pbar.setValue(0)
self.main_layout.addWidget(label_widget) self.main_layout.addWidget(label_widget)
self.main_layout.addWidget(pbar) self.main_layout.addWidget(pbar)
self.bars[clean_key] = pbar self.bars[clean_key] = pbar
@@ -72,7 +72,7 @@ class MultiProgressDialog(QDialog):
def force_close(self): def force_close(self):
self.allow_closing = True self.allow_closing = True
self.close() self.close()
def single_participant_worker( def single_participant_worker(
file_path: str, file_path: str,
@@ -86,7 +86,7 @@ def single_participant_worker(
try: try:
# Perform the heavy fold_channels logic # Perform the heavy fold_channels logic
channel_results = fold_channels(raw=raw_data, p_name=p_name, progress_queue=progress_queue) channel_results = fold_channels(raw=raw_data, p_name=p_name, progress_queue=progress_queue)
# Hand back results and signal completion # Hand back results and signal completion
result_queue.put({file_path: channel_results}) result_queue.put({file_path: channel_results})
progress_queue.put(p_name) progress_queue.put(p_name)
@@ -117,11 +117,11 @@ def get_landmark_color_map() -> Dict[str, Tuple[float, float, float, float]]:
] ]
# Sort logically # Sort logically
landmarks.sort(key=lambda x: (int(x.split(" - ")[0]) if x.split(" - ")[0].isdigit() else float('inf'))) landmarks.sort(key=lambda x: (int(x.split(" - ")[0]) if x.split(" - ")[0].isdigit() else float('inf')))
cmap1 = plt.get_cmap('tab20') cmap1 = plt.get_cmap('tab20')
cmap2 = plt.get_cmap('tab20b') cmap2 = plt.get_cmap('tab20b')
colors = [cmap1(i) for i in range(20)] + [cmap2(i) for i in range(20)] colors = [cmap1(i) for i in range(20)] + [cmap2(i) for i in range(20)]
return {landmark: colors[i % len(colors)] for i, landmark in enumerate(landmarks)} return {landmark: colors[i % len(colors)] for i, landmark in enumerate(landmarks)}
@@ -137,16 +137,16 @@ class StaticChannelCanvas(FigureCanvas):
image_path: Optional[str] = None, image_path: Optional[str] = None,
parent: Optional[QWidget] = None, parent: Optional[QWidget] = None,
) -> None: ) -> None:
self.fig = Figure(figsize=(11.0, 5.5)) self.fig = Figure(figsize=(11.0, 5.5))
self.ax = self.fig.subplots(1, 2) self.ax = self.fig.subplots(1, 2)
super().__init__(self.fig) super().__init__(self.fig)
self.setParent(parent) self.setParent(parent)
self.setMouseTracking(True) self.setMouseTracking(True)
# --- 1. DATA PREPARATION --- # --- 1. DATA PREPARATION ---
self.wedge_data_list = list(data_list) self.wedge_data_list = list(data_list)
total_specificity = sum(d['Specificity'] for d in self.wedge_data_list) total_specificity = sum(d['Specificity'] for d in self.wedge_data_list)
@@ -157,7 +157,7 @@ class StaticChannelCanvas(FigureCanvas):
'Landmark': 'Other / Unclassified Regions', 'Landmark': 'Other / Unclassified Regions',
'Specificity': remainder 'Specificity': remainder
}) })
self.specificities = [d['Specificity'] for d in self.wedge_data_list] self.specificities = [d['Specificity'] for d in self.wedge_data_list]
self.landmarks = [d['Landmark'] for d in self.wedge_data_list] self.landmarks = [d['Landmark'] for d in self.wedge_data_list]
self.colors = [color_map.get(lm, '#ccc') if 'Other' not in lm else '#d3d3d3' for lm in self.landmarks] self.colors = [color_map.get(lm, '#ccc') if 'Other' not in lm else '#d3d3d3' for lm in self.landmarks]
@@ -175,7 +175,7 @@ class StaticChannelCanvas(FigureCanvas):
labeldistance=1.1 labeldistance=1.1
) )
self.ax[0].axis('equal') self.ax[0].axis('equal')
# --- 3. RIGHT SUBPLOT: PNG IMAGE DISPLAY --- # --- 3. RIGHT SUBPLOT: PNG IMAGE DISPLAY ---
# Note we explicitly target self.ax[1] now # Note we explicitly target self.ax[1] now
if image_path: if image_path:
@@ -189,14 +189,14 @@ class StaticChannelCanvas(FigureCanvas):
# Fallback message if no image path is passed down # Fallback message if no image path is passed down
self.ax[1].text(0.5, 0.5, "No Reference Image\nProvided", self.ax[1].text(0.5, 0.5, "No Reference Image\nProvided",
ha='center', va='center', fontsize=12, fontweight='bold', color='#777') ha='center', va='center', fontsize=12, fontweight='bold', color='#777')
# Completely hide the background grid, spines, and axis lines for the image box # Completely hide the background grid, spines, and axis lines for the image box
self.ax[1].axis('off') self.ax[1].axis('off')
# --- 4. CANVAS TEXT OVERLAY --- # --- 4. CANVAS TEXT OVERLAY ---
# Main Title centered globally over both subplots # Main Title centered globally over both subplots
self.fig.suptitle(channel_name, fontsize=16, fontweight='bold', y=0.97) self.fig.suptitle(channel_name, fontsize=16, fontweight='bold', y=0.97)
# Shared info box text overlay centered horizontally across the whole window figure # Shared info box text overlay centered horizontally across the whole window figure
self.info_text = self.ax[0].text( self.info_text = self.ax[0].text(
0.5, 0.04, "", 0.5, 0.04, "",
@@ -206,9 +206,9 @@ class StaticChannelCanvas(FigureCanvas):
bbox=dict(boxstyle="round,pad=0.5", facecolor="#fdfdfd", edgecolor="#bbb", alpha=0.95) bbox=dict(boxstyle="round,pad=0.5", facecolor="#fdfdfd", edgecolor="#bbb", alpha=0.95)
) )
self.info_text.set_visible(False) self.info_text.set_visible(False)
self.currently_exploded_idx = None self.currently_exploded_idx = None
# Layout space optimization # Layout space optimization
self.fig.subplots_adjust(left=0.05, bottom=0.1, right=0.95, top=0.85, wspace=0.2) self.fig.subplots_adjust(left=0.05, bottom=0.1, right=0.95, top=0.85, wspace=0.2)
self.draw() self.draw()
@@ -237,10 +237,10 @@ class StaticChannelCanvas(FigureCanvas):
if self.currently_exploded_idx != hovered_index: if self.currently_exploded_idx != hovered_index:
self.currently_exploded_idx = hovered_index self.currently_exploded_idx = hovered_index
self._explode_wedge(hovered_index) self._explode_wedge(hovered_index)
displayed_pct = self.autotexts[hovered_index].get_text() displayed_pct = self.autotexts[hovered_index].get_text()
full_desc = self.landmarks[hovered_index] full_desc = self.landmarks[hovered_index]
self.info_text.set_text(f"{full_desc} | {displayed_pct}") self.info_text.set_text(f"{full_desc} | {displayed_pct}")
self.info_text.set_visible(True) self.info_text.set_visible(True)
self.draw_idle() self.draw_idle()
@@ -250,7 +250,7 @@ class StaticChannelCanvas(FigureCanvas):
self.info_text.set_visible(False) self.info_text.set_visible(False)
self.currently_exploded_idx = None self.currently_exploded_idx = None
self.draw_idle() self.draw_idle()
except Exception as err: except Exception as err:
print(f"[ERROR] Internal failure inside _on_hover loop: {err}") print(f"[ERROR] Internal failure inside _on_hover loop: {err}")
traceback.print_exc() traceback.print_exc()
@@ -281,7 +281,6 @@ class StaticChannelCanvas(FigureCanvas):
changed = True changed = True
if changed: if changed:
self.draw_idle() self.draw_idle()
class StandaloneLegendDialog(QWidget): class StandaloneLegendDialog(QWidget):
@@ -290,10 +289,10 @@ class StandaloneLegendDialog(QWidget):
self.setWindowTitle("Full View - Brodmann Legend") self.setWindowTitle("Full View - Brodmann Legend")
self.setMinimumSize(500, 600) self.setMinimumSize(500, 600)
self.resize(500, 900) self.resize(500, 900)
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(10, 10, 10, 10) layout.setContentsMargins(10, 10, 10, 10)
# Reuse your exact card creation method to render inside the popup window # Reuse your exact card creation method to render inside the popup window
legend_card = canvas_engine.create_legend_card(title_prefix) legend_card = canvas_engine.create_legend_card(title_prefix)
layout.addWidget(legend_card) layout.addWidget(legend_card)
@@ -304,40 +303,40 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
Dynamically scales row and column configurations to maintain a crisp 16:9 layout orientation. Dynamically scales row and column configurations to maintain a crisp 16:9 layout orientation.
""" """
def __init__( def __init__(
self, self,
channels_data: dict[str, list[dict[str, Any]]], channels_data: dict[str, list[dict[str, Any]]],
color_map: dict[str, str], color_map: dict[str, str],
is_fullscreen_copy: bool = False, is_fullscreen_copy: bool = False,
parent: QWidget | None = None parent: QWidget | None = None
) -> None: ) -> None:
self.channels_data = channels_data self.channels_data = channels_data
self.color_map = color_map self.color_map = color_map
self.is_fullscreen_copy = is_fullscreen_copy self.is_fullscreen_copy = is_fullscreen_copy
num_channels = len(channels_data) num_channels = len(channels_data)
# --- FIX: DYNAMICALLY CALCULATE OPTIMAL 16:9 COLUMNS --- # Dynamically calculate best ratio for 16:9
target_ratio = 16 / 9 target_ratio = 16 / 9
best_cols = 4 best_cols = 4
min_ratio_error = float('inf') min_ratio_error = float('inf')
# Test configurations from 4 columns up to the total number of channels # Test configurations from 4 columns up to the total number of channels
for test_cols in range(4, num_channels + 1): for test_cols in range(4, num_channels + 1):
test_rows = (num_channels + test_cols - 1) // test_cols test_rows = (num_channels + test_cols - 1) // test_cols
# Approximate the visual aspect ratio based on cell dimensions # Approximate the visual aspect ratio based on cell dimensions
# Mini charts are slightly wider than tall, roughly 1.15 to 1.0 factor # Mini charts are slightly wider than tall, roughly 1.15 to 1.0 factor
current_ratio = (test_cols * 1.15) / (test_rows * 1.0) current_ratio = (test_cols * 1.15) / (test_rows * 1.0)
error = abs(current_ratio - target_ratio) error = abs(current_ratio - target_ratio)
if error < min_ratio_error: if error < min_ratio_error:
min_ratio_error = error min_ratio_error = error
best_cols = test_cols best_cols = test_cols
cols = best_cols cols = best_cols
rows = (num_channels + cols - 1) // cols rows = (num_channels + cols - 1) // cols
# Base figure sizing dynamically scales off the optimal matrix constraints # Base figure sizing dynamically scales off the optimal matrix constraints
if is_fullscreen_copy: if is_fullscreen_copy:
# Maximized views stretch cleanly across standard display panels # Maximized views stretch cleanly across standard display panels
@@ -345,17 +344,17 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
else: else:
# Standard thumbnail views scaled down for participant cards # Standard thumbnail views scaled down for participant cards
figsize = (7.5, 7.5 / target_ratio) figsize = (7.5, 7.5 / target_ratio)
self.fig = Figure(figsize=figsize) self.fig = Figure(figsize=figsize)
super().__init__(self.fig) super().__init__(self.fig)
self.setParent(parent) self.setParent(parent)
self.axes_data_registry = {} self.axes_data_registry = {}
for idx, (channel_name, data_list) in enumerate(channels_data.items()): for idx, (channel_name, data_list) in enumerate(channels_data.items()):
ax = self.fig.add_subplot(rows, cols, idx + 1) ax = self.fig.add_subplot(rows, cols, idx + 1)
padded_data_list = list(data_list) padded_data_list = list(data_list)
total_specificity = sum(d['Specificity'] for d in padded_data_list) total_specificity = sum(d['Specificity'] for d in padded_data_list)
if total_specificity < 100.0: if total_specificity < 100.0:
@@ -381,15 +380,15 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
title_sz = 6 if num_channels > 30 else (9 if is_fullscreen_copy else 7) title_sz = 6 if num_channels > 30 else (9 if is_fullscreen_copy else 7)
ax.pie( ax.pie(
specificities, specificities,
startangle=90, startangle=90,
colors=colors, colors=colors,
labels=labels, labels=labels,
textprops={'fontsize': font_sz, 'fontweight': 'bold'}, textprops={'fontsize': font_sz, 'fontweight': 'bold'},
labeldistance=1.05, labeldistance=1.05,
radius=0.75 radius=0.75
) )
ax.set_title(channel_name, fontsize=title_sz, fontweight='bold', pad=0, y=1.04) ax.set_title(channel_name, fontsize=title_sz, fontweight='bold', pad=0, y=1.04)
ax.axis('equal') ax.axis('equal')
@@ -397,12 +396,12 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
# Large multi-column plots require less spacing overhead to prevent clipping label masks # Large multi-column plots require less spacing overhead to prevent clipping label masks
h_sp = 0.35 if num_channels > 30 else 0.18 h_sp = 0.35 if num_channels > 30 else 0.18
w_sp = 0.25 if num_channels > 30 else 0.10 w_sp = 0.25 if num_channels > 30 else 0.10
if is_fullscreen_copy: if is_fullscreen_copy:
self.fig.subplots_adjust(left=0.02, bottom=0.02, right=0.98, top=0.95, hspace=h_sp, wspace=w_sp) self.fig.subplots_adjust(left=0.02, bottom=0.02, right=0.98, top=0.95, hspace=h_sp, wspace=w_sp)
else: else:
self.fig.set_layout_engine('constrained') self.fig.set_layout_engine('constrained')
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.draw() self.draw()
@@ -425,28 +424,28 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
background-color: #f8f9fa; background-color: #f8f9fa;
} }
""") """)
card_layout = QVBoxLayout(card_frame) card_layout = QVBoxLayout(card_frame)
card_layout.setContentsMargins(6, 6, 6, 6) card_layout.setContentsMargins(6, 6, 6, 6)
card_layout.setSpacing(4) card_layout.setSpacing(4)
# 2. Add header matching the summary card type architecture # 2. Add header matching the summary card type architecture
header = QLabel(f"{title_prefix} - Channels Matrix") header = QLabel(f"{title_prefix} - Channels Matrix")
header.setStyleSheet("font-weight: bold; font-size: 10pt; border: none; color: #212529; background: transparent;") header.setStyleSheet("font-weight: bold; font-size: 10pt; border: none; color: #212529; background: transparent;")
header.setAlignment(Qt.AlignmentFlag.AlignCenter) header.setAlignment(Qt.AlignmentFlag.AlignCenter)
card_layout.addWidget(header) card_layout.addWidget(header)
# 3. Nest this canvas instance cleanly inside the card frame layout # 3. Nest this canvas instance cleanly inside the card frame layout
self.setParent(card_frame) self.setParent(card_frame)
card_layout.addWidget(self) card_layout.addWidget(self)
card_layout.addStretch(0) card_layout.addStretch(0)
# 4. Make the remaining empty whitespace frame areas trigger the maximization loop # 4. Make the remaining empty whitespace frame areas trigger the maximization loop
card_frame.mouseReleaseEvent = lambda event: self._open_fullscreen_grid() if event.button() == Qt.MouseButton.LeftButton else None card_frame.mouseReleaseEvent = lambda event: self._open_fullscreen_grid() if event.button() == Qt.MouseButton.LeftButton else None
# Ensure underlying child mouse hits tunnel downstream properly to our parent container frame # Ensure underlying child mouse hits tunnel downstream properly to our parent container frame
header.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) header.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
layout_to_attach_to.addWidget(card_frame) layout_to_attach_to.addWidget(card_frame)
return card_frame return card_frame
@@ -460,7 +459,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
clicked_subplot_data = self.axes_data_registry.get(event.inaxes) clicked_subplot_data = self.axes_data_registry.get(event.inaxes)
if clicked_subplot_data: if clicked_subplot_data:
self._open_expanded_view( self._open_expanded_view(
clicked_subplot_data['channel_name'], clicked_subplot_data['channel_name'],
clicked_subplot_data['data_list'] clicked_subplot_data['data_list']
) )
@@ -471,30 +470,30 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
fullscreen_window = QWidget(None) fullscreen_window = QWidget(None)
fullscreen_window.setWindowTitle("Participant Grid Monitor - Maximized View") fullscreen_window.setWindowTitle("Participant Grid Monitor - Maximized View")
fullscreen_window.setWindowFlags( fullscreen_window.setWindowFlags(
Qt.WindowType.Window | Qt.WindowType.Window |
Qt.WindowType.WindowMinMaxButtonsHint | Qt.WindowType.WindowMinMaxButtonsHint |
Qt.WindowType.WindowCloseButtonHint Qt.WindowType.WindowCloseButtonHint
) )
layout = QVBoxLayout(fullscreen_window) layout = QVBoxLayout(fullscreen_window)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
# Instantiate the copy # Instantiate the copy
large_grid_canvas = InteractiveParticipantGridCanvas( large_grid_canvas = InteractiveParticipantGridCanvas(
self.channels_data, self.channels_data,
self.color_map, self.color_map,
is_fullscreen_copy=True, is_fullscreen_copy=True,
parent=fullscreen_window parent=fullscreen_window
) )
# Explicitly tag the new canvas object internally to block further clicks # Explicitly tag the new canvas object internally to block further clicks
large_grid_canvas._is_fullscreen_flag_set = True large_grid_canvas._is_fullscreen_flag_set = True
layout.addWidget(large_grid_canvas) layout.addWidget(large_grid_canvas)
# Open non-modally so it populates the taskbar and matches OS window behaviors # Open non-modally so it populates the taskbar and matches OS window behaviors
fullscreen_window.showMaximized() fullscreen_window.showMaximized()
# Keep a reference alive on the source canvas so Python doesn't garbage collect the window # Keep a reference alive on the source canvas so Python doesn't garbage collect the window
if not hasattr(self, '_fullscreen_refs'): if not hasattr(self, '_fullscreen_refs'):
self._fullscreen_refs = [] self._fullscreen_refs = []
@@ -506,7 +505,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
"""Sums and normalizes the specificity profile across all channels.""" """Sums and normalizes the specificity profile across all channels."""
totals = {} totals = {}
num_channels = len(channels_data) num_channels = len(channels_data)
if num_channels == 0: if num_channels == 0:
return [] return []
@@ -543,16 +542,16 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
def _open_expanded_view(self, channel_name: str, data_list: list[dict[str, Any]]) -> None: def _open_expanded_view(self, channel_name: str, data_list: list[dict[str, Any]]) -> None:
# 1. Create a plain QWidget with NO parent (None) # 1. Create a plain QWidget with NO parent (None)
# This instantly makes it a top-level desktop window # This instantly makes it a top-level desktop window
popup = QWidget(None) popup = QWidget(None)
popup.setWindowTitle(f"Channel Specificity Detail - {channel_name}") popup.setWindowTitle(f"Channel Specificity Detail - {channel_name}")
# 2. Add standard window control behaviors # 2. Add standard window control behaviors
popup.setWindowFlags( popup.setWindowFlags(
Qt.WindowType.Window | Qt.WindowType.Window |
Qt.WindowType.WindowMinMaxButtonsHint | Qt.WindowType.WindowMinMaxButtonsHint |
Qt.WindowType.WindowCloseButtonHint Qt.WindowType.WindowCloseButtonHint
) )
# 3. Build layout out exactly as before # 3. Build layout out exactly as before
layout = QVBoxLayout(popup) layout = QVBoxLayout(popup)
layout.setContentsMargins(0, 0, 0, 0) # Strip extra outer layout spacing layout.setContentsMargins(0, 0, 0, 0) # Strip extra outer layout spacing
@@ -560,23 +559,23 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
target_png_path = resource_path("images/brain.png") target_png_path = resource_path("images/brain.png")
expanded_canvas = StaticChannelCanvas( expanded_canvas = StaticChannelCanvas(
channel_name, channel_name,
data_list, data_list,
self.color_map, self.color_map,
image_path=target_png_path, image_path=target_png_path,
parent=popup parent=popup
) )
layout.addWidget(expanded_canvas) layout.addWidget(expanded_canvas)
popup.resize(900, 520) popup.resize(900, 520)
# 4. Display non-modally # 4. Display non-modally
popup.show() popup.show()
# 5. Keep the reference alive so Python doesn't garbage collect it # 5. Keep the reference alive so Python doesn't garbage collect it
if not hasattr(self, '_open_popups'): if not hasattr(self, '_open_popups'):
self._open_popups = [] self._open_popups = []
# Clean up closed windows from our tracking list to save memory # Clean up closed windows from our tracking list to save memory
self._open_popups = [w for w in self._open_popups if w.isVisible()] self._open_popups = [w for w in self._open_popups if w.isVisible()]
self._open_popups.append(popup) self._open_popups.append(popup)
@@ -586,7 +585,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
"""Generates a highly compact, clickable embedded card on the main window showing aggregated data.""" """Generates a highly compact, clickable embedded card on the main window showing aggregated data."""
# 1. Calculate the normalized profile data payload using the instance's own data # 1. Calculate the normalized profile data payload using the instance's own data
summary_data = self._calculate_total_brodmann_profile(self.channels_data) summary_data = self._calculate_total_brodmann_profile(self.channels_data)
# 2. Create a styled container card frame # 2. Create a styled container card frame
card_frame = QFrame() card_frame = QFrame()
card_frame.setFrameShape(QFrame.Shape.StyledPanel) card_frame.setFrameShape(QFrame.Shape.StyledPanel)
@@ -601,19 +600,19 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
background-color: #f8f9fa; /* Slightly shifts background color on hover */ background-color: #f8f9fa; /* Slightly shifts background color on hover */
} }
""") """)
card_layout = QVBoxLayout(card_frame) card_layout = QVBoxLayout(card_frame)
card_layout.setContentsMargins(4, 4, 4, 4) card_layout.setContentsMargins(4, 4, 4, 4)
card_layout.setSpacing(2) card_layout.setSpacing(2)
# Add a clear section header label containing the specific participant identity # Add a clear section header label containing the specific participant identity
header = QLabel(f"{title_prefix} - Total Profile") header = QLabel(f"{title_prefix} - Total Profile")
header.setStyleSheet("font-weight: bold; font-size: 10pt; border: none; color: #212529;") header.setStyleSheet("font-weight: bold; font-size: 10pt; border: none; color: #212529;")
header.setAlignment(Qt.AlignmentFlag.AlignCenter) header.setAlignment(Qt.AlignmentFlag.AlignCenter)
card_layout.addWidget(header) card_layout.addWidget(header)
target_png_path = resource_path("images/brain.png") target_png_path = resource_path("images/brain.png")
# 3. Instantiate the canvas with a custom size flag or constraint # 3. Instantiate the canvas with a custom size flag or constraint
# Adjust your StaticChannelCanvas __init__ to check if it should render in 'compact' mode # Adjust your StaticChannelCanvas __init__ to check if it should render in 'compact' mode
summary_canvas = StaticChannelCanvas( summary_canvas = StaticChannelCanvas(
@@ -623,7 +622,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
image_path=target_png_path, image_path=target_png_path,
parent=card_frame, parent=card_frame,
) )
# --- CRITICAL: SHRINK MATPLOTLIB FIGURE ELEMENTS FOR THE EMBEDDED VIEWER --- # --- CRITICAL: SHRINK MATPLOTLIB FIGURE ELEMENTS FOR THE EMBEDDED VIEWER ---
# Scale down the underlying canvas container so it doesn't balloon the layout grid # Scale down the underlying canvas container so it doesn't balloon the layout grid
if hasattr(summary_canvas, 'fig'): if hasattr(summary_canvas, 'fig'):
@@ -633,7 +632,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
for text in ax.texts: for text in ax.texts:
text.set_fontsize(6) text.set_fontsize(6)
summary_canvas.draw() summary_canvas.draw()
summary_canvas.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) summary_canvas.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
card_layout.addWidget(summary_canvas) card_layout.addWidget(summary_canvas)
card_layout.addStretch(0) card_layout.addStretch(0)
@@ -642,9 +641,9 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
# Only trigger expansion if it's a primary left-click action # Only trigger expansion if it's a primary left-click action
if event.button() == Qt.MouseButton.LeftButton: if event.button() == Qt.MouseButton.LeftButton:
self._open_expanded_summary_window(title_prefix, summary_data) self._open_expanded_summary_window(title_prefix, summary_data)
card_frame.mouseReleaseEvent = handle_card_click card_frame.mouseReleaseEvent = handle_card_click
# Prevent clicks on the text/child elements from being swallowed up instead of passing to frame # Prevent clicks on the text/child elements from being swallowed up instead of passing to frame
header.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) header.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
summary_canvas.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) summary_canvas.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
@@ -654,21 +653,21 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
return card_frame return card_frame
def create_legend_card(self, title_prefix: str) -> QFrame: def create_legend_card(self, title_prefix: str) -> QFrame:
card = QFrame() card = QFrame()
card.setStyleSheet("QFrame { background-color: #ffffff; border-radius: 8px; border: 1px solid #e9ecef; }") card.setStyleSheet("QFrame { background-color: #ffffff; border-radius: 8px; border: 1px solid #e9ecef; }")
layout = QVBoxLayout(card) layout = QVBoxLayout(card)
layout.setContentsMargins(20, 20, 20, 20) layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(10) layout.setSpacing(10)
header_label = QLabel(f"{title_prefix}\nLandmarks") header_label = QLabel(f"{title_prefix}\nLandmarks")
header_label.setAlignment(Qt.AlignmentFlag.AlignCenter) header_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
header_label.setStyleSheet("font-size: 14px; font-weight: bold; color: #1a252f; border: none;") header_label.setStyleSheet("font-size: 14px; font-weight: bold; color: #1a252f; border: none;")
layout.addWidget(header_label) layout.addWidget(header_label)
layout.addSpacing(10) layout.addSpacing(10)
scroll_area = QScrollArea() scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True) scroll_area.setWidgetResizable(True)
scroll_area.setStyleSheet("QScrollArea { border: none; background: transparent; }") scroll_area.setStyleSheet("QScrollArea { border: none; background: transparent; }")
@@ -677,76 +676,75 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
scroll_layout = QVBoxLayout(scroll_content) scroll_layout = QVBoxLayout(scroll_content)
scroll_layout.setSpacing(6) scroll_layout.setSpacing(6)
scroll_layout.setContentsMargins(0, 0, 0, 0) scroll_layout.setContentsMargins(0, 0, 0, 0)
true_color_map = get_landmark_color_map() true_color_map = get_landmark_color_map()
# Iterate over the sorted keys directly from your method # Iterate over the sorted keys directly from your method
for landmark_text in true_color_map.keys(): for landmark_text in true_color_map.keys():
item_row = QHBoxLayout() item_row = QHBoxLayout()
item_row.setSpacing(12) item_row.setSpacing(12)
# Extract the RGBA tuple value assigned by matplotlib # Extract the RGBA tuple value assigned by matplotlib
rgba = true_color_map[landmark_text] rgba = true_color_map[landmark_text]
# Convert float tuple components (0.0 - 1.0) to standard CSS integer scales (0 - 255) # Convert float tuple components (0.0 - 1.0) to standard CSS integer scales (0 - 255)
r, g, b = int(rgba[0] * 255), int(rgba[1] * 255), int(rgba[2] * 255) r, g, b = int(rgba[0] * 255), int(rgba[1] * 255), int(rgba[2] * 255)
color_hex = f"rgb({r}, {g}, {b})" color_hex = f"rgb({r}, {g}, {b})"
# Format display string nicely: "1 — Primary Somatosensory Cortex" # Format display string nicely: "1 — Primary Somatosensory Cortex"
if " - " in landmark_text: if " - " in landmark_text:
num, name = landmark_text.split(" - ", 1) num, name = landmark_text.split(" - ", 1)
display_string = f"<b>{num}</b> — {name}" display_string = f"<b>{num}</b> — {name}"
else: else:
display_string = f"<b>{landmark_text}</b>" display_string = f"<b>{landmark_text}</b>"
dot = QLabel() dot = QLabel()
dot.setFixedSize(14, 14) dot.setFixedSize(14, 14)
dot.setStyleSheet(f"background-color: {color_hex}; border-radius: 7px; border: none;") dot.setStyleSheet(f"background-color: {color_hex}; border-radius: 7px; border: none;")
label = QLabel(display_string) label = QLabel(display_string)
label.setStyleSheet("font-size: 12px; color: #343a40; border: none;") label.setStyleSheet("font-size: 12px; color: #343a40; border: none;")
item_row.addWidget(dot) item_row.addWidget(dot)
item_row.addWidget(label, 1) item_row.addWidget(label, 1)
scroll_layout.addLayout(item_row) scroll_layout.addLayout(item_row)
scroll_area.setWidget(scroll_content) scroll_area.setWidget(scroll_content)
layout.addWidget(scroll_area) layout.addWidget(scroll_area)
return card return card
def _open_expanded_summary_window(self, title_prefix: str, summary_data: List[Any]) -> None: def _open_expanded_summary_window(self, title_prefix: str, summary_data: List[Any]) -> None:
"""Pops open a beautifully scaled, independent large window when the card is clicked.""" """Pops open a beautifully scaled, independent large window when the card is clicked."""
popup = QWidget(None) popup = QWidget(None)
popup.setWindowTitle(f"Grand Total Profile Details - {title_prefix}") popup.setWindowTitle(f"Grand Total Profile Details - {title_prefix}")
popup.setWindowFlags( popup.setWindowFlags(
Qt.WindowType.Window | Qt.WindowType.Window |
Qt.WindowType.WindowMinMaxButtonsHint | Qt.WindowType.WindowMinMaxButtonsHint |
Qt.WindowType.WindowCloseButtonHint Qt.WindowType.WindowCloseButtonHint
) )
layout = QVBoxLayout(popup) layout = QVBoxLayout(popup)
layout.setContentsMargins(10, 10, 10, 10) layout.setContentsMargins(10, 10, 10, 10)
target_png_path = resource_path("images/brain.png") target_png_path = resource_path("images/brain.png")
# This one renders full size (900x520) for analytical reading # This one renders full size (900x520) for analytical reading
expanded_canvas = StaticChannelCanvas( expanded_canvas = StaticChannelCanvas(
f"{title_prefix} - All Channels Aggregated", f"{title_prefix} - All Channels Aggregated",
summary_data, summary_data,
self.color_map, self.color_map,
image_path=target_png_path, image_path=target_png_path,
parent=popup, parent=popup,
) )
layout.addWidget(expanded_canvas) layout.addWidget(expanded_canvas)
popup.resize(950, 550) popup.resize(950, 550)
popup.show() popup.show()
if not hasattr(self, '_summary_popups'): if not hasattr(self, '_summary_popups'):
self._summary_popups = [] self._summary_popups = []
self._summary_popups.append(popup) self._summary_popups.append(popup)
class ProcessOrchestrator(QObject): class ProcessOrchestrator(QObject):
# Fires when Manager + Processes are completely ready # Fires when Manager + Processes are completely ready
@@ -760,7 +758,7 @@ class ProcessOrchestrator(QObject):
haemo_dict: dict[str, Any], haemo_dict: dict[str, Any],
worker_func: Callable[..., Any] worker_func: Callable[..., Any]
) -> None: ) -> None:
super().__init__() super().__init__()
self.selected_files = selected_files self.selected_files = selected_files
self.haemo_dict = haemo_dict self.haemo_dict = haemo_dict
@@ -795,7 +793,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
haemo_dict: dict[str | Path, BaseRaw], haemo_dict: dict[str | Path, BaseRaw],
cha_dict: dict[str, DataFrame] cha_dict: dict[str, DataFrame]
) -> None: ) -> None:
super().__init__("ParticipantFoldChannels") super().__init__("ParticipantFoldChannels")
self.setWindowTitle(f"Participant Fold Channels Viewer - {APP_NAME.upper()}") self.setWindowTitle(f"Participant Fold Channels Viewer - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict self.haemo_dict = haemo_dict
@@ -816,7 +814,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items) self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label) self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
self.index_texts = [ self.index_texts = [
"0 (Fold Channels)", "0 (Fold Channels)",
] ]
@@ -841,7 +839,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
# 2. Create the central canvas widget that inside the scroll block # 2. Create the central canvas widget that inside the scroll block
self.scroll_content_widget = QWidget() self.scroll_content_widget = QWidget()
# 3. Establish the strict 3-column layout grid engine # 3. Establish the strict 3-column layout grid engine
self.grid_layout = QGridLayout(self.scroll_content_widget) self.grid_layout = QGridLayout(self.scroll_content_widget)
self.grid_layout.setContentsMargins(12, 12, 12, 12) self.grid_layout.setContentsMargins(12, 12, 12, 12)
@@ -850,7 +848,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
self.grid_layout.setColumnStretch(0, 1) self.grid_layout.setColumnStretch(0, 1)
self.grid_layout.setColumnStretch(1, 1) self.grid_layout.setColumnStretch(1, 1)
self.grid_layout.setColumnStretch(2, 1) self.grid_layout.setColumnStretch(2, 1)
# 2. Force a uniform structural minimum width per column # 2. Force a uniform structural minimum width per column
# This blocks the dense matrices from hogging space and compressing the summary cards # This blocks the dense matrices from hogging space and compressing the summary cards
self.grid_layout.setColumnMinimumWidth(0, 400) self.grid_layout.setColumnMinimumWidth(0, 400)
@@ -860,7 +858,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
# Bind them together # Bind them together
self.scroll_area.setWidget(self.scroll_content_widget) self.scroll_area.setWidget(self.scroll_content_widget)
# Add the self.scroll_area widget to your root layout view frame panel # Add the self.scroll_area widget to your root layout view frame panel
self.main_layout.addWidget(self.scroll_area) self.main_layout.addWidget(self.scroll_area)
@@ -871,12 +869,12 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
def show_fold_images(self): def show_fold_images(self):
selected_display_names = self._get_checked_items(self.participant_dropdown) selected_display_names = self._get_checked_items(self.participant_dropdown)
selected_indexes = [int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)] selected_indexes = [int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)]
if not selected_display_names or 0 not in selected_indexes: if not selected_display_names or 0 not in selected_indexes:
return return
selected_files = [path for path, label in self.participant_map.items() selected_files = [path for path, label in self.participant_map.items()
if f"{label} ({os.path.basename(path)})" in selected_display_names] if f"{label} ({os.path.basename(path)})" in selected_display_names]
while self.grid_layout.count(): while self.grid_layout.count():
item = self.grid_layout.takeAt(0) item = self.grid_layout.takeAt(0)
widget = item.widget() widget = item.widget()
@@ -891,7 +889,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
# Dig out the exact channels list length matching your loop engine logic # Dig out the exact channels list length matching your loop engine logic
hbo_channels = getattr(raw_data.copy().pick(picks='hbo'), "ch_names", []) hbo_channels = getattr(raw_data.copy().pick(picks='hbo'), "ch_names", [])
total_channels = len(hbo_channels) if hbo_channels else 1 total_channels = len(hbo_channels) if hbo_channels else 1
self.multi_progress.add_participant(os.path.basename(file_path), total_channels) self.multi_progress.add_participant(os.path.basename(file_path), total_channels)
self.multi_progress.show() self.multi_progress.show()
@@ -917,13 +915,13 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
self.orchestrator_thread.start() self.orchestrator_thread.start()
def on_orchestration_success( def on_orchestration_success(
self, self,
manager: Any, manager: Any,
result_queue: Any, result_queue: Any,
progress_queue: Any, progress_queue: Any,
active_processes: List[Any] active_processes: List[Any]
) -> None: ) -> None:
""" Executed on the Main GUI Thread once background process setup finishes """ """ Executed on the Main GUI Thread once background process setup finishes """
self.manager = manager self.manager = manager
self.result_queue = result_queue self.result_queue = result_queue
@@ -949,12 +947,12 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
while not self.progress_queue.empty(): while not self.progress_queue.empty():
msg = self.progress_queue.get() msg = self.progress_queue.get()
# CASE 1: Micro-step channel increment (Tuple tracking) # CASE 1: Micro-step channel increment (Tuple tracking)
if isinstance(msg, tuple): if isinstance(msg, tuple):
p_name, completed_channels = msg p_name, completed_channels = msg
clean_key = str(p_name).strip() clean_key = str(p_name).strip()
if hasattr(self, 'multi_progress') and clean_key in self.multi_progress.bars: if hasattr(self, 'multi_progress') and clean_key in self.multi_progress.bars:
print(completed_channels) print(completed_channels)
self.multi_progress.update_bar(clean_key, completed_channels) self.multi_progress.update_bar(clean_key, completed_channels)
@@ -962,12 +960,12 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
# DEBUG LOG: This tells us exactly why a bar isn't moving # DEBUG LOG: This tells us exactly why a bar isn't moving
print(f"[DEBUG WARNING] Progress received for '{clean_key}' but no matching bar was found. Existing bars: {list(self.multi_progress.bars.keys())}") print(f"[DEBUG WARNING] Progress received for '{clean_key}' but no matching bar was found. Existing bars: {list(self.multi_progress.bars.keys())}")
continue continue
# CASE 2: Worker process crashed with an error string # CASE 2: Worker process crashed with an error string
if isinstance(msg, str) and msg.startswith("ERROR"): if isinstance(msg, str) and msg.startswith("ERROR"):
print(f"Worker Error: {msg}") print(f"Worker Error: {msg}")
#self.completed_count += 1 # Count as finished so the UI doesn't hang #self.completed_count += 1 # Count as finished so the UI doesn't hang
# CASE 3: Final clean text string signal indicating complete file closure # CASE 3: Final clean text string signal indicating complete file closure
elif isinstance(msg, str): elif isinstance(msg, str):
# Max out the progress bar visually on completion # Max out the progress bar visually on completion
@@ -975,7 +973,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
if msg in self.multi_progress.bars: if msg in self.multi_progress.bars:
max_val = self.multi_progress.bars[msg].maximum() max_val = self.multi_progress.bars[msg].maximum()
self.multi_progress.update_bar(msg, max_val) self.multi_progress.update_bar(msg, max_val)
self.completed_count += 1 # Increment the master task tracker self.completed_count += 1 # Increment the master task tracker
print(self.completed_count, time.time()) print(self.completed_count, time.time())
@@ -987,36 +985,36 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
# Clean up when all processes are done # Clean up when all processes are done
if self.completed_count >= len(self.active_processes): if self.completed_count >= len(self.active_processes):
self.result_timer.stop() self.result_timer.stop()
# Close the custom multi-progress window # Close the custom multi-progress window
if hasattr(self, 'multi_progress'): if hasattr(self, 'multi_progress'):
self.multi_progress.force_close() self.multi_progress.force_close()
# Clean up processes # Clean up processes
for p in self.active_processes: for p in self.active_processes:
if p.is_alive(): if p.is_alive():
p.join(timeout=1) # Give it a second to wrap up p.join(timeout=1) # Give it a second to wrap up
p.close() # Explicitly close the process object p.close() # Explicitly close the process object
# Shut down the Manager process (the source of the 'rogue' process) # Shut down the Manager process (the source of the 'rogue' process)
if hasattr(self, 'manager'): if hasattr(self, 'manager'):
self.manager.shutdown() self.manager.shutdown()
self.active_processes = [] self.active_processes = []
print("Processing fully complete. All resources released.") print("Processing fully complete. All resources released.")
if hasattr(self, 'global_channels_data') and self.global_channels_data: if hasattr(self, 'global_channels_data') and self.global_channels_data:
color_map = get_landmark_color_map() color_map = get_landmark_color_map()
# We feed the entire channel pool directly to your existing canvas engine class # We feed the entire channel pool directly to your existing canvas engine class
global_canvas = InteractiveParticipantGridCanvas(self.global_channels_data, color_map) global_canvas = InteractiveParticipantGridCanvas(self.global_channels_data, color_map)
# Create the summary card using your exact visual method # Create the summary card using your exact visual method
global_card = global_canvas.create_total_summary_card( global_card = global_canvas.create_total_summary_card(
title_prefix="Grand Global Layout", title_prefix="Grand Global Layout",
layout_to_attach_to=self.scroll_content_widget.layout() layout_to_attach_to=self.scroll_content_widget.layout()
) )
# Match your exact layout positioning logic to place it next in the grid # Match your exact layout positioning logic to place it next in the grid
count = self.grid_layout.count() - 1 count = self.grid_layout.count() - 1
row = count // 3 row = count // 3
@@ -1031,9 +1029,9 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
def handle_legend_click(event): def handle_legend_click(event):
self.active_legend_window = StandaloneLegendDialog(global_canvas, legend_title, self) self.active_legend_window = StandaloneLegendDialog(global_canvas, legend_title, self)
self.active_legend_window.show() self.active_legend_window.show()
legend_card.mousePressEvent = handle_legend_click legend_card.mousePressEvent = handle_legend_click
count = self.grid_layout.count() count = self.grid_layout.count()
row = count // 3 row = count // 3
col = count % 3 col = count % 3
@@ -1045,7 +1043,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
for file_path, channels_data in result_dict.items(): for file_path, channels_data in result_dict.items():
participant_label = self.participant_map.get(file_path, os.path.basename(file_path)) participant_label = self.participant_map.get(file_path, os.path.basename(file_path))
if hasattr(self, 'global_channels_data'): if hasattr(self, 'global_channels_data'):
for ch_name, ch_data in channels_data.items(): for ch_name, ch_data in channels_data.items():
unique_key = f"{participant_label}_{ch_name}" unique_key = f"{participant_label}_{ch_name}"
@@ -1060,19 +1058,19 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
title_prefix=participant_label, title_prefix=participant_label,
layout_to_attach_to=self.scroll_content_widget.layout() # Maps directly to your grid layout layout_to_attach_to=self.scroll_content_widget.layout() # Maps directly to your grid layout
) )
# Pin Card A to the sequential grid coordinate tracker layout # Pin Card A to the sequential grid coordinate tracker layout
count = self.grid_layout.count() - 1 # Subtract 1 because widget registration steps index values forward count = self.grid_layout.count() - 1 # Subtract 1 because widget registration steps index values forward
row = count // 3 row = count // 3
col = count % 3 col = count % 3
self.grid_layout.addWidget(matrix_card, row, col) self.grid_layout.addWidget(matrix_card, row, col)
# 3. Build Card B (Total Summary Profile Frame Layout) # 3. Build Card B (Total Summary Profile Frame Layout)
summary_card = participant_grid_canvas.create_total_summary_card( summary_card = participant_grid_canvas.create_total_summary_card(
title_prefix=participant_label, title_prefix=participant_label,
layout_to_attach_to=self.scroll_content_widget.layout() layout_to_attach_to=self.scroll_content_widget.layout()
) )
# Pin Card B directly next into the 3-column processing loop matrix layout tracker # Pin Card B directly next into the 3-column processing loop matrix layout tracker
count = self.grid_layout.count() - 1 count = self.grid_layout.count() - 1
row = count // 3 row = count // 3
@@ -1100,21 +1098,21 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
container = QFrame() container = QFrame()
container.setStyleSheet("background-color: #f9f9f9; border: 1px solid #ccc;") container.setStyleSheet("background-color: #f9f9f9; border: 1px solid #ccc;")
vbox = QVBoxLayout(container) vbox = QVBoxLayout(container)
title = QLabel("<b>Brodmann Area Legend</b>") title = QLabel("<b>Brodmann Area Legend</b>")
title.setAlignment(Qt.AlignmentFlag.AlignCenter) title.setAlignment(Qt.AlignmentFlag.AlignCenter)
vbox.addWidget(title) vbox.addWidget(title)
pixmap = self._bytes_to_pixmap(legend_bytes) pixmap = self._bytes_to_pixmap(legend_bytes)
legend_label = QLabel() legend_label = QLabel()
# Legends are usually tall, so we scale it differently or keep it smaller # Legends are usually tall, so we scale it differently or keep it smaller
legend_label.setPixmap(pixmap.scaled( legend_label.setPixmap(pixmap.scaled(
self.thumb_size, self.thumb_size,
Qt.AspectRatioMode.KeepAspectRatio, Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation Qt.TransformationMode.SmoothTransformation
)) ))
legend_label.setAlignment(Qt.AlignmentFlag.AlignCenter) legend_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
legend_label.mousePressEvent = lambda e, p=pixmap: self._open_full_size(p, "Brodmann Legend") legend_label.mousePressEvent = lambda e, p=pixmap: self._open_full_size(p, "Brodmann Legend")
vbox.addWidget(legend_label) vbox.addWidget(legend_label)
self.grid_layout.addWidget(container, 0, 0) self.grid_layout.addWidget(container, 0, 0)
@@ -83,7 +83,7 @@ class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidg
haemo_dict: dict[str, BaseRaw], haemo_dict: dict[str, BaseRaw],
epochs_dict: dict[str, Epochs], epochs_dict: dict[str, Epochs],
) -> None: ) -> None:
super().__init__("ParticipantFunctionalConnectivity") super().__init__("ParticipantFunctionalConnectivity")
self.setWindowTitle(f"Participant Functional Connectivity Viewer [BETA] - {APP_NAME.upper()}") self.setWindowTitle(f"Participant Functional Connectivity Viewer [BETA] - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict self.haemo_dict = haemo_dict
@@ -101,28 +101,28 @@ class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidg
if request is None: if request is None:
return return
(selected_event, selected_file_paths, selected_indexes, raw_params) = request (selected_event, selected_file_paths, selected_indexes, raw_params) = request
param_values = cast(dict[int | str, dict[str, Any]], raw_params) param_values = cast(dict[int | str, dict[str, Any]], raw_params)
# Pass the necessary arguments to each method # Pass the necessary arguments to each method
for file_path in selected_file_paths: for file_path in selected_file_paths:
haemo_obj = self.haemo_dict.get(file_path) haemo_obj = self.haemo_dict.get(file_path)
epochs_obj = self.epochs_dict.get(file_path) epochs_obj = self.epochs_dict.get(file_path)
if haemo_obj is None or epochs_obj is None: if haemo_obj is None or epochs_obj is None:
continue continue
if selected_event: if selected_event:
raw_annotations = getattr(haemo_obj, "annotations", None) raw_annotations = getattr(haemo_obj, "annotations", None)
if raw_annotations is not None: if raw_annotations is not None:
annotations = cast(Annotations, raw_annotations) annotations = cast(Annotations, raw_annotations)
descriptions = cast(list[str], list(annotations.description)) descriptions = cast(list[str], list(annotations.description))
participant_events: set[str] = set(descriptions) participant_events: set[str] = set(descriptions)
else: else:
participant_events: set[str] = set() participant_events: set[str] = set()
if selected_event not in participant_events: if selected_event not in participant_events:
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.") print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
continue continue
@@ -135,7 +135,7 @@ class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidg
vmin = params.get("vmin", 0.9) vmin = params.get("vmin", 0.9)
fmin = params.get("fmin", 0.04) fmin = params.get("fmin", 0.04)
fmax = params.get("fmax", 0.2) fmax = params.get("fmax", 0.2)
functional_connectivity_spectral_epochs(epochs=epochs_obj, n_lines=n_lines, vmin=vmin, fmin=fmin, fmax=fmax, method=method) functional_connectivity_spectral_epochs(epochs=epochs_obj, n_lines=n_lines, vmin=vmin, fmin=fmin, fmax=fmax, method=method)
elif idx == 1: elif idx == 1:
+3 -3
View File
@@ -25,11 +25,11 @@ from src.shared.shareddata import APP_NAME
class ParticipantImageViewerWidget(FlaresBaseWidget): class ParticipantImageViewerWidget(FlaresBaseWidget):
def __init__( def __init__(
self, self,
haemo_dict: dict[str, BaseRaw], haemo_dict: dict[str, BaseRaw],
fig_bytes_dict: dict[str, dict[str, bytes]] fig_bytes_dict: dict[str, dict[str, bytes]]
) -> None: ) -> None:
super().__init__("ParticipantImage") super().__init__("ParticipantImage")
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
self.setWindowTitle(f"Participant Image Viewer - {APP_NAME.upper()}") self.setWindowTitle(f"Participant Image Viewer - {APP_NAME.upper()}")
@@ -52,7 +52,7 @@ class ParticipantImageViewerWidget(FlaresBaseWidget):
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items) self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label) self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
first_fig_dict = next(iter(self.fig_bytes_dict.values())) first_fig_dict = next(iter(self.fig_bytes_dict.values()))
image_label_items = list(first_fig_dict.keys()) image_label_items = list(first_fig_dict.keys())
+92 -97
View File
@@ -14,11 +14,12 @@ import pandas as pd
from pandas import DataFrame 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.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.QtGui import QStandardItemModel, QStandardItem, QPixmap, QIntValidator, QDoubleValidator
from PySide6.QtCore import QEvent, QPoint, QSize, QTimer, Qt, Signal from PySide6.QtCore import QEvent, QPoint, QSize, QTimer, Qt, Signal
from src.shared.shareddata import APP_NAME, PIPELINE_STAGES from src.shared.shareddata import APP_NAME, PIPELINE_STAGES
class FullClickComboBox(QComboBox): class FullClickComboBox(QComboBox):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -28,16 +29,16 @@ class FullClickComboBox(QComboBox):
def eventFilter(self, obj, event): def eventFilter(self, obj, event):
if obj == self.lineEdit(): if obj == self.lineEdit():
if event.type() == QEvent.MouseButtonPress: if event.type() == QEvent.MouseButtonPress:
return True return True
if event.type() == QEvent.MouseButtonRelease: if event.type() == QEvent.MouseButtonRelease:
self.showPopup() self.showPopup()
return True return True
return super().eventFilter(obj, event) return super().eventFilter(obj, event)
class ClickableLabel(QLabel): class ClickableLabel(QLabel):
def __init__(self, full_pixmap: QPixmap, thumbnail_pixmap: QPixmap): def __init__(self, full_pixmap: QPixmap, thumbnail_pixmap: QPixmap):
@@ -47,7 +48,7 @@ class ClickableLabel(QLabel):
self.setAlignment(Qt.AlignmentFlag.AlignCenter) self.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setFixedSize(thumbnail_pixmap.size()) self.setFixedSize(thumbnail_pixmap.size())
self.setStyleSheet("border: 1px solid gray; margin: 2px;") self.setStyleSheet("border: 1px solid gray; margin: 2px;")
def mousePressEvent(self, event): def mousePressEvent(self, event):
viewer = QWidget() viewer = QWidget()
viewer.setWindowTitle(f"Expanded View - {APP_NAME.upper()}") viewer.setWindowTitle(f"Expanded View - {APP_NAME.upper()}")
@@ -100,12 +101,12 @@ class ParameterInputDialog(QDialog):
self.scroll.setMaximumHeight(800) self.scroll.setMaximumHeight(800)
self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.scroll_content = QWidget() self.scroll_content = QWidget()
self.scroll_layout = QVBoxLayout(self.scroll_content) self.scroll_layout = QVBoxLayout(self.scroll_content)
self.scroll_layout.setContentsMargins(10, 10, 10, 10) self.scroll_layout.setContentsMargins(10, 10, 10, 10)
self.scroll.setWidget(self.scroll_content) self.scroll.setWidget(self.scroll_content)
main_layout.addWidget(self.scroll) main_layout.addWidget(self.scroll)
for idx, param_list in params_dict.items(): for idx, param_list in params_dict.items():
@@ -123,7 +124,7 @@ class ParameterInputDialog(QDialog):
# Convert options to string just in case they aren't # Convert options to string just in case they aren't
options = [str(opt) for opt in param_info.get("options", [])] options = [str(opt) for opt in param_info.get("options", [])]
widget.addItems(options) widget.addItems(options)
# Set default choice if it exists in the options list # Set default choice if it exists in the options list
default_val = str(param_info.get("default", "")) default_val = str(param_info.get("default", ""))
if default_val in options: if default_val in options:
@@ -231,14 +232,14 @@ class FullClickComboBox(QComboBox):
def eventFilter(self, obj, event): def eventFilter(self, obj, event):
if obj == self.lineEdit(): if obj == self.lineEdit():
if event.type() == QEvent.MouseButtonPress: if event.type() == QEvent.MouseButtonPress:
return True return True
if event.type() == QEvent.MouseButtonRelease: if event.type() == QEvent.MouseButtonRelease:
self.showPopup() self.showPopup()
return True return True
return super().eventFilter(obj, event) return super().eventFilter(obj, event)
@@ -249,32 +250,32 @@ class FilePickerWidget(QWidget):
def __init__(self, default_val="", file_filter="JSON Files (*.json);;All Files (*)", parent=None): def __init__(self, default_val="", file_filter="JSON Files (*.json);;All Files (*)", parent=None):
super().__init__(parent) super().__init__(parent)
# Inline layout to hold line edit and button side-by-side # Inline layout to hold line edit and button side-by-side
layout = QHBoxLayout(self) layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(5) layout.setSpacing(5)
self.line_edit = QLineEdit() self.line_edit = QLineEdit()
self.line_edit.setText(str(default_val)) self.line_edit.setText(str(default_val))
self.line_edit.setPlaceholderText("Select file path...") self.line_edit.setPlaceholderText("Select file path...")
# Forward internal text updates out through our custom component signal # Forward internal text updates out through our custom component signal
self.line_edit.textChanged.connect(self.textChanged.emit) self.line_edit.textChanged.connect(self.textChanged.emit)
self.browse_btn = QPushButton("Browse...") self.browse_btn = QPushButton("Browse...")
self.browse_btn.clicked.connect(self.open_file_dialog) self.browse_btn.clicked.connect(self.open_file_dialog)
layout.addWidget(self.line_edit) layout.addWidget(self.line_edit)
layout.addWidget(self.browse_btn) layout.addWidget(self.browse_btn)
self.file_filter = file_filter self.file_filter = file_filter
def open_file_dialog(self): def open_file_dialog(self):
# Open PySide6 native file browser # Open PySide6 native file browser
file_path, _ = QFileDialog.getOpenFileName( file_path, _ = QFileDialog.getOpenFileName(
self, self,
"Select Configuration File", "Select Configuration File",
self.line_edit.text(), self.line_edit.text(),
self.file_filter self.file_filter
) )
if file_path: if file_path:
@@ -308,7 +309,7 @@ class ParamSection(QWidget):
""" """
dirty_state_changed = Signal(bool) dirty_state_changed = Signal(bool)
def __init__(self, section_data, global_widgets): def __init__(self, section_data, global_widgets):
super().__init__() super().__init__()
layout = QVBoxLayout() layout = QVBoxLayout()
@@ -317,7 +318,7 @@ class ParamSection(QWidget):
self.dependencies = [] self.dependencies = []
self.selected_path = None self.selected_path = None
self.param_rows = [] self.param_rows = []
self.header_widgets = [] self.header_widgets = []
self.dirty_params = {} self.dirty_params = {}
self._updating_checkstates = False self._updating_checkstates = False
@@ -344,7 +345,7 @@ class ParamSection(QWidget):
# Build label text and tooltips # Build label text and tooltips
label_text = f"⚠️ {param_name}" if is_advanced else param_name label_text = f"⚠️ {param_name}" if is_advanced else param_name
label = QLabel(label_text) label = QLabel(label_text)
if is_advanced: if is_advanced:
label.setToolTip(f"ADVANCED: {help_text}") label.setToolTip(f"ADVANCED: {help_text}")
else: else:
@@ -371,19 +372,19 @@ class ParamSection(QWidget):
widget.setCurrentText(str(default_val)) widget.setCurrentText(str(default_val))
widget.currentTextChanged.connect(lambda _, p=param_name: self.check_if_changed(p)) widget.currentTextChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
widget.currentTextChanged.connect(self.notify_global_update) widget.currentTextChanged.connect(self.notify_global_update)
elif param_type == int or "int" in type_str: elif param_type == int or "int" in type_str:
widget = QLineEdit() widget = QLineEdit()
widget.setValidator(QIntValidator()) widget.setValidator(QIntValidator())
widget.setText(str(default_val)) widget.setText(str(default_val))
widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p)) widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
elif param_type == float or "float" in type_str: elif param_type == float or "float" in type_str:
widget = QLineEdit() widget = QLineEdit()
widget.setValidator(QDoubleValidator()) widget.setValidator(QDoubleValidator())
widget.setText(str(default_val)) widget.setText(str(default_val))
widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p)) widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
elif param_type == list or "list" in type_str: elif param_type == list or "list" in type_str:
options = param.get("options", []) options = param.get("options", [])
if param.get("exclusive", True): if param.get("exclusive", True):
@@ -406,12 +407,12 @@ class ParamSection(QWidget):
else: else:
widget.setValue(15) widget.setValue(15)
widget.valueChanged.connect(lambda _, p=param_name: self.check_if_changed(p)) widget.valueChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
elif param_type == "json_file": elif param_type == "json_file":
widget = FilePickerWidget(default_val=default_val, file_filter="JSON Files (*.json)") widget = FilePickerWidget(default_val=default_val, file_filter="JSON Files (*.json)")
widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p)) widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
else: else:
widget = QLineEdit() widget = QLineEdit()
widget.setText(str(default_val)) widget.setText(str(default_val))
widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p)) widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
@@ -447,7 +448,7 @@ class ParamSection(QWidget):
def update_theme_colors(self): def update_theme_colors(self):
self.title_label.style().unpolish(self.title_label) self.title_label.style().unpolish(self.title_label)
self.title_label.style().polish(self.title_label) self.title_label.style().polish(self.title_label)
def is_different(self, val_a, val_b, param_type=None): def is_different(self, val_a, val_b, param_type=None):
"""Compares two parameter values to determine if they differ.""" """Compares two parameter values to determine if they differ."""
type_str = str(param_type).lower() type_str = str(param_type).lower()
@@ -566,7 +567,7 @@ class ParamSection(QWidget):
normal_items.append(item) normal_items.append(item)
state = Qt.Checked if item.text() in defaults else Qt.Unchecked state = Qt.Checked if item.text() in defaults else Qt.Unchecked
item.setCheckState(state) item.setCheckState(state)
# Sync toggle select all item # Sync toggle select all item
toggle_item = model.item(1) toggle_item = model.item(1)
if toggle_item and normal_items: if toggle_item and normal_items:
@@ -637,7 +638,7 @@ class ParamSection(QWidget):
for hw in self.header_widgets: for hw in self.header_widgets:
hw.setVisible(has_visible_rows) hw.setVisible(has_visible_rows)
self.setVisible(has_visible_rows) self.setVisible(has_visible_rows)
def has_any_changes(self): def has_any_changes(self):
@@ -663,7 +664,7 @@ class ParamSection(QWidget):
for cond in dep["conditions"]: for cond in dep["conditions"]:
parent_name = cond.get("parent_name") or cond.get("parent") 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")) required_val = str(cond.get("depends_value") if "depends_value" in cond else cond.get("value", "True"))
parent_info = self.widgets.get(parent_name) parent_info = self.widgets.get(parent_name)
if not parent_info: if not parent_info:
all_conditions_met = False all_conditions_met = False
@@ -745,7 +746,7 @@ class ParamSection(QWidget):
toggle_item.setCheckState(Qt.Checked if all_checked else Qt.Unchecked) toggle_item.setCheckState(Qt.Checked if all_checked else Qt.Unchecked)
self._updating_checkstates = False self._updating_checkstates = False
if param_name: if param_name:
self.update_dropdown_label(param_name) self.update_dropdown_label(param_name)
self.check_if_changed(param_name) self.check_if_changed(param_name)
@@ -813,7 +814,7 @@ class ParamSection(QWidget):
selected = self._get_checked_items(widget) selected = self._get_checked_items(widget)
widget.lineEdit().setText(", ".join(selected) if selected else "<None Selected>") widget.lineEdit().setText(", ".join(selected) if selected else "<None Selected>")
# def update_annotation_dropdown_from_loaded_files(self, bubble_widgets, button1): # def update_annotation_dropdown_from_loaded_files(self, bubble_widgets, button1):
# file_paths = [bubble.file_path for bubble in bubble_widgets.values()] # file_paths = [bubble.file_path for bubble in bubble_widgets.values()]
# if not file_paths: # if not file_paths:
@@ -822,12 +823,12 @@ class ParamSection(QWidget):
# # 1. Start the UI immediately # # 1. Start the UI immediately
# progress = QProgressDialog("Accessing Workers...", "Cancel", 0, len(file_paths), self) # progress = QProgressDialog("Accessing Workers...", "Cancel", 0, len(file_paths), self)
# progress.setWindowModality(Qt.WindowModality.WindowModal) # progress.setWindowModality(Qt.WindowModality.WindowModal)
# progress.setMinimumDuration(0) # progress.setMinimumDuration(0)
# progress.setValue(0) # progress.setValue(0)
# # Force the UI to draw the window NOW before we start the loop # # Force the UI to draw the window NOW before we start the loop
# progress.show() # progress.show()
# QApplication.processEvents() # QApplication.processEvents()
# annotation_sets = [] # annotation_sets = []
@@ -843,7 +844,7 @@ class ParamSection(QWidget):
# # This call is now nearly instant because the process is already warm # # This call is now nearly instant because the process is already warm
# future = self.file_executor.submit(_extract_annotations, path) # future = self.file_executor.submit(_extract_annotations, path)
# try: # try:
# labels_list = future.result() # labels_list = future.result()
# if labels_list: # if labels_list:
# annotation_sets.append(set(labels_list)) # annotation_sets.append(set(labels_list))
# except Exception as e: # except Exception as e:
@@ -870,10 +871,10 @@ class ProgressBubble(QWidget):
file_path (str): Associated file path stored with the bubble. file_path (str): Associated file path stored with the bubble.
""" """
clicked = Signal(object) clicked = Signal(object)
rightClicked = Signal(object, QPoint) rightClicked = Signal(object, QPoint)
def __init__(self, display_name, file_path): def __init__(self, display_name, file_path):
super().__init__() super().__init__()
@@ -964,7 +965,7 @@ class ProgressBubble(QWidget):
else: else:
rect.setStyleSheet("background-color: white; border: 1px solid gray;") rect.setStyleSheet("background-color: white; border: 1px solid gray;")
def mark_cancelled(self): def mark_cancelled(self):
for i, rect in enumerate(self.rects): for i, rect in enumerate(self.rects):
if i < self.current_step: if i < self.current_step:
@@ -1074,7 +1075,7 @@ class FlaresBaseWidget(QWidget):
model.itemChanged.connect(on_item_changed) model.itemChanged.connect(on_item_changed)
combo.setInsertPolicy(QComboBox.NoInsert) combo.setInsertPolicy(QComboBox.NoInsert)
return combo return combo
# def _get_checked_items(self, combo): # def _get_checked_items(self, combo):
# model = combo.model() # model = combo.model()
@@ -1084,20 +1085,20 @@ class FlaresBaseWidget(QWidget):
# if item.checkState() == Qt.Checked: # if item.checkState() == Qt.Checked:
# checked.append(item.text()) # checked.append(item.text())
# return checked # return checked
def _get_checked_items( def _get_checked_items(
self, self,
combo: QComboBox | None = None combo: QComboBox | None = None
) -> list[str]: ) -> list[str]:
target = combo if combo is not None else getattr(self, 'participant_dropdown', None) target = combo if combo is not None else getattr(self, 'participant_dropdown', None)
if target is None or target.model() is None: if target is None or target.model() is None:
return [] return []
model = target.model() model = target.model()
checked_items = [] checked_items = []
# Exclusion list: any item text that should never be treated as data # Exclusion list: any item text that should never be treated as data
forbidden = {"Toggle All", "Select All", "<None Selected>", "Toggle"} forbidden = {"Toggle All", "Select All", "<None Selected>", "Toggle"}
@@ -1108,7 +1109,7 @@ class FlaresBaseWidget(QWidget):
# Only add if it's not a 'UI control' item # Only add if it's not a 'UI control' item
if text not in forbidden and not text.startswith("Toggle"): if text not in forbidden and not text.startswith("Toggle"):
checked_items.append(text) checked_items.append(text)
return checked_items return checked_items
@@ -1125,7 +1126,7 @@ class FlaresBaseWidget(QWidget):
# 1. Figure out which dropdown we are talking to # 1. Figure out which dropdown we are talking to
target_combo = combo if combo is not None else getattr(self, "participant_dropdown", None) target_combo = combo if combo is not None else getattr(self, "participant_dropdown", None)
if target_combo is None: if target_combo is None:
return # Safety check: nothing to update return # Safety check: nothing to update
@@ -1141,11 +1142,11 @@ class FlaresBaseWidget(QWidget):
# 3. Conditional trigger for event updates # 3. Conditional trigger for event updates
# We only update events if we aren't in one of the excluded viewers # We only update events if we aren't in one of the excluded viewers
excluded_viewers = { excluded_viewers = {
"ParticipantImage", "ParticipantImage",
"ParticipantFoldChannels", "ParticipantFoldChannels",
"ExportToCSV", "ExportToCSV",
} }
if getattr(self, "caller", None) not in excluded_viewers: if getattr(self, "caller", None) not in excluded_viewers:
self._update_event_dropdown() self._update_event_dropdown()
@@ -1162,7 +1163,7 @@ class FlaresBaseWidget(QWidget):
def _update_event_dropdown(self): def _update_event_dropdown(self):
is_split_group = hasattr(self, 'participant_dropdown_a') and hasattr(self, 'participant_dropdown_b') is_split_group = hasattr(self, 'participant_dropdown_a') and hasattr(self, 'participant_dropdown_b')
bypass = False bypass = False
main_win = next((w for w in QApplication.topLevelWidgets() main_win = next((w for w in QApplication.topLevelWidgets()
if w.objectName() == "MainApplication" or hasattr(w, "missing_events_bypass")), None) if w.objectName() == "MainApplication" or hasattr(w, "missing_events_bypass")), None)
@@ -1199,7 +1200,7 @@ class FlaresBaseWidget(QWidget):
if not bypass: if not bypass:
final_annotations = set.intersection(*(sets_a + sets_b)) final_annotations = set.intersection(*(sets_a + sets_b))
else: else:
all_events_a = {event for s in sets_a for event in s} 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} all_events_b = {event for s in sets_b for event in s}
@@ -1216,7 +1217,7 @@ class FlaresBaseWidget(QWidget):
valid_b.add(event) valid_b.add(event)
final_annotations = valid_a.intersection(valid_b) final_annotations = valid_a.intersection(valid_b)
else: else:
names = self._get_checked_items(self.participant_dropdown) names = self._get_checked_items(self.participant_dropdown)
if not names: if not names:
@@ -1288,8 +1289,7 @@ class FlaresBaseWidget(QWidget):
self._updating_checkstates = False self._updating_checkstates = False
model.itemChanged.connect(on_item_changed) model.itemChanged.connect(on_item_changed)
def update_participant_list_for_group(self, group_name=None, combo=None): def update_participant_list_for_group(self, group_name=None, combo=None):
@@ -1323,7 +1323,7 @@ class FlaresBaseWidget(QWidget):
# 4. Refresh the Model # 4. Refresh the Model
model = target_combo.model() model = target_combo.model()
model.clear() model.clear()
for text in ["<None Selected>", "Toggle Select All"]: for text in ["<None Selected>", "Toggle Select All"]:
item = QStandardItem(str(text)) item = QStandardItem(str(text))
if text == "Toggle Select All": if text == "Toggle Select All":
@@ -1343,7 +1343,7 @@ class FlaresBaseWidget(QWidget):
target_combo.setEnabled(True) target_combo.setEnabled(True)
# Get file paths (handles target_group as int or str) # Get file paths (handles target_group as int or str)
group_file_paths = self.group_to_paths.get(target_group, []) group_file_paths = self.group_to_paths.get(target_group, [])
for i, file_path in enumerate(group_file_paths, start=1): for i, file_path in enumerate(group_file_paths, start=1):
short_label = f"Participant {i}" short_label = f"Participant {i}"
display_label = f"{short_label} ({os.path.basename(file_path)})" display_label = f"{short_label} ({os.path.basename(file_path)})"
@@ -1363,8 +1363,8 @@ class InterGroupUIMixin:
participant_map: dict[str, str] participant_map: dict[str, str]
def setup_inter_group_ui( def setup_inter_group_ui(
self, self,
index_texts: Sequence[str], index_texts: Sequence[str],
placeholder_text: str = "" placeholder_text: str = ""
) -> None: ) -> None:
@@ -1394,7 +1394,7 @@ class InterGroupUIMixin:
self.event_dropdown = QComboBox() self.event_dropdown = QComboBox()
self.event_dropdown.addItem("<None Selected>") self.event_dropdown.addItem("<None Selected>")
self.participant_dropdown_a = self._create_multiselect_dropdown([]) self.participant_dropdown_a = self._create_multiselect_dropdown([])
line_edit = self.participant_dropdown_a.lineEdit() line_edit = self.participant_dropdown_a.lineEdit()
assert line_edit is not None, "Dropdown A must be editable to have a lineEdit" assert line_edit is not None, "Dropdown A must be editable to have a lineEdit"
@@ -1453,10 +1453,10 @@ class InterGroupUIMixin:
def _update_group_b_options(self): def _update_group_b_options(self):
"""Triggered when Group B changes: Update Group A to exclude B's choice""" """Triggered when Group B changes: Update Group A to exclude B's choice"""
selected_b = self.group_b_dropdown.currentText() selected_b = self.group_b_dropdown.currentText()
# Refresh Group A and exclude what was just picked in Group B # Refresh Group A and exclude what was just picked in Group B
self._refresh_group_dropdown(self.group_a_dropdown, exclude=selected_b) self._refresh_group_dropdown(self.group_a_dropdown, exclude=selected_b)
# Update the participants for Group B # Update the participants for Group B
self.update_participant_list_for_group(selected_b, self.participant_dropdown_b) self.update_participant_list_for_group(selected_b, self.participant_dropdown_b)
self._update_event_dropdown() self._update_event_dropdown()
@@ -1464,10 +1464,10 @@ class InterGroupUIMixin:
def _update_group_a_options(self): def _update_group_a_options(self):
"""Triggered when Group A changes: Update Group B to exclude A's choice""" """Triggered when Group A changes: Update Group B to exclude A's choice"""
selected_a = self.group_a_dropdown.currentText() selected_a = self.group_a_dropdown.currentText()
# Refresh Group B and exclude what was just picked in Group A # Refresh Group B and exclude what was just picked in Group A
self._refresh_group_dropdown(self.group_b_dropdown, exclude=selected_a) self._refresh_group_dropdown(self.group_b_dropdown, exclude=selected_a)
# Update the participants for Group A # Update the participants for Group A
self.update_participant_list_for_group(selected_a, self.participant_dropdown_a) self.update_participant_list_for_group(selected_a, self.participant_dropdown_a)
self._update_event_dropdown() self._update_event_dropdown()
@@ -1491,8 +1491,6 @@ class InterGroupUIMixin:
dropdown.setCurrentIndex(0) # Reset to "<None Selected>" dropdown.setCurrentIndex(0) # Reset to "<None Selected>"
dropdown.blockSignals(False) dropdown.blockSignals(False)
def _get_file_paths_from_labels(self, labels, group_name): def _get_file_paths_from_labels(self, labels, group_name):
file_paths = [] file_paths = []
@@ -1516,14 +1514,14 @@ class InterGroupUIMixin:
file_paths.append(file_path) file_paths.append(file_path)
return file_paths return file_paths
def get_common_request_data( def get_common_request_data(
self, self,
parameterized_indexes: dict[int, list[dict[str, Any]]], parameterized_indexes: dict[int, list[dict[str, Any]]],
df_ind_dict: dict[str, DataFrame] | None = None, df_ind_dict: dict[str, DataFrame] | None = None,
contrast_dfs: dict[str, dict[str, Any]] | 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: ) -> tuple[str | None, list[str], list[str], list[str], list[int], dict[str, Any]] | None:
selected_event = self.event_dropdown.currentText() selected_event = self.event_dropdown.currentText()
if selected_event == "<None Selected>": if selected_event == "<None Selected>":
selected_event = None selected_event = None
@@ -1562,7 +1560,7 @@ class InterGroupUIMixin:
} }
dynamic_rois = [] dynamic_rois = []
if df_ind_dict: if df_ind_dict:
roi_set = set() roi_set = set()
for fp in all_selected_paths: for fp in all_selected_paths:
@@ -1572,7 +1570,7 @@ class InterGroupUIMixin:
if roi_set: if roi_set:
dynamic_rois = sorted(list(roi_set)) dynamic_rois = sorted(list(roi_set))
# Fallback to prevent UI crashes if JSON file doesn't exist or is empty # Fallback to prevent UI crashes if JSON file doesn't exist or is empty
if not dynamic_rois: if not dynamic_rois:
dynamic_rois = ["Option 1", "Option 2"] dynamic_rois = ["Option 1", "Option 2"]
@@ -1588,7 +1586,7 @@ class InterGroupUIMixin:
# If an event is selected, only keep contrasts containing the event name as a substring. # 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: if selected_event is None or selected_event in contrast_name:
contrast_set.add(contrast_name) contrast_set.add(contrast_name)
# Sort them cleanly for the UI # Sort them cleanly for the UI
dynamic_contrasts = sorted(list(contrast_set)) dynamic_contrasts = sorted(list(contrast_set))
@@ -1634,15 +1632,15 @@ class InterGroupUIMixin:
selected_indexes, selected_indexes,
param_values, param_values,
) )
class CSVUIMixin: class CSVUIMixin:
def setup_csv_ui( def setup_csv_ui(
self, self,
index_texts: Sequence[str] index_texts: Sequence[str]
) -> None: ) -> None:
# Create mappings: file_path -> participant label and dropdown display text # Create mappings: file_path -> participant label and dropdown display text
self.participant_map: dict[str, str] = {} # file_path -> "Participant 1" self.participant_map: dict[str, str] = {} # file_path -> "Participant 1"
self.participant_dropdown_items: list[str] = [] # "Participant 1 (filename)" self.participant_dropdown_items: list[str] = [] # "Participant 1 (filename)"
@@ -1659,7 +1657,7 @@ class CSVUIMixin:
self.participant_dropdown: FullClickComboBox = self._create_multiselect_dropdown(self.participant_dropdown_items) self.participant_dropdown: FullClickComboBox = self._create_multiselect_dropdown(self.participant_dropdown_items)
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label) self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
self.index_texts = index_texts self.index_texts = index_texts
self.image_index_dropdown: FullClickComboBox = self._create_multiselect_dropdown(self.index_texts) self.image_index_dropdown: FullClickComboBox = self._create_multiselect_dropdown(self.index_texts)
@@ -1693,14 +1691,14 @@ class CSVUIMixin:
class IntraGroupUIMixin: class IntraGroupUIMixin:
def setup_intra_group_ui( def setup_intra_group_ui(
self, self,
index_texts: Sequence[str], index_texts: Sequence[str],
placeholder_text: str = "" placeholder_text: str = ""
) -> None: ) -> None:
self.show_all_events = True self.show_all_events = True
self._updating_checkstates = False self._updating_checkstates = False
# Create mappings: file_path -> participant label and dropdown display text # Create mappings: file_path -> participant label and dropdown display text
self.participant_map: dict[str, str] = {} # file_path -> "Participant 1" self.participant_map: dict[str, str] = {} # file_path -> "Participant 1"
self.participant_dropdown_items = [] # "Participant 1 (filename)" self.participant_dropdown_items = [] # "Participant 1 (filename)"
@@ -1771,7 +1769,7 @@ class IntraGroupUIMixin:
df_ind_dict: dict[str, DataFrame] | None = None, df_ind_dict: dict[str, DataFrame] | None = None,
contrast_dfs: dict[str, dict[str, Any]] | None = None, contrast_dfs: dict[str, dict[str, Any]] | None = None,
) -> tuple[str | None, list[str], list[int], dict[str, Any]] | None: ) -> tuple[str | None, list[str], list[int], dict[str, Any]] | None:
selected_event = self.event_dropdown.currentText() selected_event = self.event_dropdown.currentText()
if selected_event == "<None Selected>": if selected_event == "<None Selected>":
selected_event = None selected_event = None
@@ -1793,7 +1791,7 @@ class IntraGroupUIMixin:
if raw is not None and hasattr(raw, "annotations"): if raw is not None and hasattr(raw, "annotations"):
if selected_event in raw.annotations.description: if selected_event in raw.annotations.description:
valid_paths.append(fp) valid_paths.append(fp)
selected_file_paths = valid_paths selected_file_paths = valid_paths
selected_indexes = [ selected_indexes = [
@@ -1803,7 +1801,7 @@ class IntraGroupUIMixin:
if not selected_file_paths: if not selected_file_paths:
print("No participants selected.") print("No participants selected.")
return return
# Inject full_text from index_texts # Inject full_text from index_texts
for idx, params_list in parameterized_indexes.items(): for idx, params_list in parameterized_indexes.items():
@@ -1814,7 +1812,7 @@ class IntraGroupUIMixin:
indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes} indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes}
dynamic_rois = [] dynamic_rois = []
if df_ind_dict: if df_ind_dict:
roi_set = set() roi_set = set()
for fp in selected_file_paths: for fp in selected_file_paths:
@@ -1840,7 +1838,7 @@ class IntraGroupUIMixin:
# If an event is selected, only keep contrasts containing the event name as a substring. # 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: if selected_event is None or selected_event in contrast_name:
contrast_set.add(contrast_name) contrast_set.add(contrast_name)
# Sort them cleanly for the UI # Sort them cleanly for the UI
dynamic_contrasts = sorted(list(contrast_set)) dynamic_contrasts = sorted(list(contrast_set))
@@ -1868,7 +1866,6 @@ class IntraGroupUIMixin:
param_info["options"] = dynamic_contrasts param_info["options"] = dynamic_contrasts
param_info["default"] = dynamic_contrasts[0] if dynamic_contrasts else "" param_info["default"] = dynamic_contrasts[0] if dynamic_contrasts else ""
param_values = {} param_values = {}
if indexes_needing_params: if indexes_needing_params:
dialog = ParameterInputDialog(indexes_needing_params, parent=self) dialog = ParameterInputDialog(indexes_needing_params, parent=self)
@@ -1878,21 +1875,22 @@ class IntraGroupUIMixin:
return return
else: else:
return return
return ( return (
selected_event, selected_event,
selected_file_paths, selected_file_paths,
selected_indexes, selected_indexes,
param_values, param_values,
) )
class ParticipantUIMixin: class ParticipantUIMixin:
def setup_participant_ui( def setup_participant_ui(
self, self,
index_texts: Sequence[str], index_texts: Sequence[str],
placeholder_text: str = "" placeholder_text: str = ""
) -> None: ) -> None:
# Create mappings: file_path -> participant label and dropdown display text # Create mappings: file_path -> participant label and dropdown display text
self.participant_map: dict[str, str] = {} # file_path -> "Participant 1" self.participant_map: dict[str, str] = {} # file_path -> "Participant 1"
self.participant_dropdown_items = [] # "Participant 1 (filename)" self.participant_dropdown_items = [] # "Participant 1 (filename)"
@@ -1913,7 +1911,6 @@ class ParticipantUIMixin:
self.event_dropdown = QComboBox() self.event_dropdown = QComboBox()
self.event_dropdown.addItem("<None Selected>") self.event_dropdown.addItem("<None Selected>")
self.index_texts = index_texts self.index_texts = index_texts
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts) self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
@@ -1968,8 +1965,6 @@ class ParticipantUIMixin:
] ]
# Inject full_text from index_texts # Inject full_text from index_texts
for idx, params_list in parameterized_indexes.items(): 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)" full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)"
@@ -1987,10 +1982,10 @@ class ParticipantUIMixin:
return return
else: else:
return return
return ( return (
selected_event, selected_event,
selected_file_paths, selected_file_paths,
selected_indexes, selected_indexes,
param_values, param_values,
) )
+1 -1
View File
@@ -32,7 +32,7 @@ class AboutWindow(QWidget):
label2 = QLabel(f"{APP_NAME_EXPANDED}", self) label2 = QLabel(f"{APP_NAME_EXPANDED}", self)
label3 = QLabel(f"{APP_NAME.upper()} is licensed under the GPL-3.0 licence. For more information, visit https://www.gnu.org/licenses/gpl-3.0.en.html", self) label3 = QLabel(f"{APP_NAME.upper()} is licensed under the GPL-3.0 licence. For more information, visit https://www.gnu.org/licenses/gpl-3.0.en.html", self)
label4 = QLabel(f"Version v{CURRENT_VERSION}") label4 = QLabel(f"Version v{CURRENT_VERSION}")
layout.addWidget(label) layout.addWidget(label)
layout.addWidget(label2) layout.addWidget(label2)
layout.addWidget(label3) layout.addWidget(label3)
+353 -86
View File
@@ -8,6 +8,10 @@ License: GPL-3.0
""" """
# Built-in imports # Built-in imports
import io
import shutil
import urllib.request
import zipfile
from pathlib import Path from pathlib import Path
from typing import Any, cast from typing import Any, cast
@@ -15,36 +19,105 @@ from typing import Any, cast
from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtGui import QColor from PySide6.QtGui import QColor
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMessageBox,
QMessageBox, QPushButton, QTabWidget, QTextBrowser, QVBoxLayout, QWidget QPushButton, QTabWidget, QTextBrowser, QVBoxLayout, QWidget, QProgressDialog
) )
from plugin_manager import PluginManager, parse_version from plugin_manager import PluginManager, get_ssl_context, parse_version
from src.shared.shareddata import APP_NAME, CURRENT_VERSION, PLUGINS_URL from src.shared.shareddata import APP_NAME, CURRENT_VERSION, PLUGINS_URL
class RemoteFetchWorker(QThread): class RemoteFetchWorker(QThread):
"""Background thread to fetch remote repository data without lagging the main UI.""" """Background thread to fetch remote repository data without lagging the main UI."""
fetched = Signal(list, bool) # (plugins_data, all_failed) fetched = Signal(list, bool, str) # (plugins_data, all_failed, fetch_type)
def __init__(self, manager: PluginManager, urls: list[str]) -> None: def __init__(self, manager: PluginManager, urls: list[str], fetch_type: str = "browser") -> None:
super().__init__() super().__init__()
self.manager = manager self.manager = manager
self.urls = urls self.urls = urls
self.fetch_type = fetch_type
def run(self) -> None: def run(self) -> None:
plugins, all_failed = self.manager.fetch_remote_repositories(self.urls) plugins, all_failed = self.manager.fetch_remote_repositories(self.urls)
self.fetched.emit(plugins, all_failed) self.fetched.emit(plugins, all_failed, self.fetch_type)
class PluginInstallWorker(QThread):
"""Background worker to download, unpack, and install/update plugins with progress reporting."""
progress = Signal(int, int) # (downloaded_bytes, total_bytes)
status_message = Signal(str)
finished = Signal(bool, str) # (success, message_or_path)
def __init__(
self,
manager: PluginManager,
download_url: str,
plugin_id: str,
is_update: bool = False,
plugin_path_to_remove: Path | None = None
) -> None:
super().__init__()
self.manager = manager
self.download_url = download_url
self.plugin_id = plugin_id
self.is_update = is_update
self.plugin_path_to_remove = plugin_path_to_remove
def run(self) -> None:
try:
self.status_message.emit("Connecting to server...")
req = urllib.request.Request(
self.download_url,
headers={"User-Agent": f"{APP_NAME}-PluginManager"},
)
with urllib.request.urlopen(req, timeout=30, context=get_ssl_context()) as response:
if response.status != 200:
self.finished.emit(False, f"Download failed with HTTP status code {response.status}")
return
total_size_str = response.headers.get("Content-Length")
total_size = int(total_size_str) if total_size_str and total_size_str.isdigit() else 0
self.status_message.emit("Downloading plugin archive...")
chunk_size = 8192
downloaded = 0
buffer = io.BytesIO()
while True:
chunk = response.read(chunk_size)
if not chunk:
break
buffer.write(chunk)
downloaded += len(chunk)
self.progress.emit(downloaded, total_size)
zip_bytes = buffer.getvalue()
if self.is_update and self.plugin_path_to_remove and self.plugin_path_to_remove.exists():
self.status_message.emit("Removing old version...")
if self.plugin_path_to_remove.is_dir():
shutil.rmtree(self.plugin_path_to_remove)
else:
self.plugin_path_to_remove.unlink()
self.status_message.emit("Unpacking and extracting files...")
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zip_ref:
zip_ref.extractall(self.manager.plugins_dir)
self.finished.emit(True, str(self.manager.plugins_dir))
except Exception as e:
self.finished.emit(False, str(e))
class PluginsWindow(QWidget): class PluginsWindow(QWidget):
def __init__(self, parent: QWidget | None, plugin_manager: PluginManager) -> None: def __init__(self, parent: QWidget | None, plugin_manager: PluginManager) -> None:
super().__init__(None, Qt.WindowType.Window) super().__init__(None, Qt.WindowType.Window)
self.main_app_window = parent self.main_app_window = parent
self.manager: PluginManager = plugin_manager self.manager: PluginManager = plugin_manager
self.setWindowTitle(f"{APP_NAME.upper()} - Plugins") self.setWindowTitle(f"{APP_NAME.upper()} - Plugins")
self.resize(750, 500) self.resize(750, 500)
@@ -52,13 +125,15 @@ class PluginsWindow(QWidget):
self.remote_plugins_data: list[dict[str, Any]] = [] self.remote_plugins_data: list[dict[str, Any]] = []
self._has_fetched_remote: bool = False self._has_fetched_remote: bool = False
self._fetch_worker: RemoteFetchWorker | None = None self._fetch_worker: RemoteFetchWorker | None = None
self._install_worker: PluginInstallWorker | None = None
self._progress_dialog: QProgressDialog | None = None
# Refresh UI automatically if manager updates state # Refresh UI automatically if manager updates state
self.manager.plugins_changed.connect(self.refresh_installed_plugins) self.manager.plugins_changed.connect(self.refresh_installed_plugins)
main_layout = QVBoxLayout(self) main_layout = QVBoxLayout(self)
self.tab_widget = QTabWidget(self) self.tab_widget = QTabWidget(self)
self.installed_tab = self._create_installed_tab() self.installed_tab = self._create_installed_tab()
self.browser_tab = self._create_browser_tab() self.browser_tab = self._create_browser_tab()
@@ -70,40 +145,75 @@ class PluginsWindow(QWidget):
self.setLayout(main_layout) self.setLayout(main_layout)
self.refresh_installed_plugins() self.refresh_installed_plugins()
self.check_installed_updates()
def check_installed_updates(self) -> None:
"""Asynchronously checks remote repositories for installed plugin updates."""
if self._fetch_worker is not None and self._fetch_worker.isRunning():
return
self._fetch_worker = RemoteFetchWorker(self.manager, self.repository_urls, fetch_type="updates")
self._fetch_worker.fetched.connect(self._on_remote_fetched)
self._fetch_worker.start()
def refresh_installed_plugins(self) -> None: def refresh_installed_plugins(self) -> None:
"""Refreshes installed list using metadata from PluginManager.""" """Refreshes installed list using metadata from PluginManager and attaches update info."""
self.installed_list.clear() self.installed_list.clear()
plugins_info = self.manager.get_installed_plugins_info() plugins_info = self.manager.get_installed_plugins_info()
remote_map: dict[str, dict[str, Any]] = {}
if self._has_fetched_remote and self.remote_plugins_data:
for p in self.remote_plugins_data:
p_id = p.get("id") or p.get("name")
if p_id:
remote_map[str(p_id)] = p
for info in plugins_info: for info in plugins_info:
p_id = str(info.get("id", ""))
local_tuple = parse_version(str(info.get("version", "0.0.0")))
remote_plugin = remote_map.get(p_id)
if remote_plugin:
remote_ver = str(remote_plugin.get("version", "0.0.0"))
if parse_version(remote_ver) > local_tuple:
info["update_available"] = True
info["remote_version"] = remote_ver
info["download_url"] = str(remote_plugin.get("download_url", ""))
display_text = f"{info['name']} (v{info.get('version', '1.0.0')})" display_text = f"{info['name']} (v{info.get('version', '1.0.0')})"
if info.get("update_available"):
display_text += f" [Update Available: v{info.get('remote_version')}]"
if info["is_disabled"]: if info["is_disabled"]:
display_text += " [Disabled]" display_text += " [Disabled]"
item = QListWidgetItem(display_text, self.installed_list) item = QListWidgetItem(display_text, self.installed_list)
item.setData(Qt.ItemDataRole.UserRole, info) item.setData(Qt.ItemDataRole.UserRole, info)
if info["is_disabled"]: if info.get("update_available"):
item.setForeground(QColor("#1976d2"))
elif info["is_disabled"]:
item.setForeground(QColor("#757575")) item.setForeground(QColor("#757575"))
if self.installed_list.count() == 0: if self.installed_list.count() == 0:
self.installed_list.addItem("No plugins installed.") self.installed_list.addItem("No plugins installed.")
self._clear_details_panel() self._clear_details_panel()
# Re-populate browser list if remote data was already fetched to reflect newly installed/uninstalled plugins if not self._has_fetched_remote:
if self._has_fetched_remote and self.remote_plugins_data: self.check_installed_updates()
elif self.remote_plugins_data:
self._populate_browser_list() self._populate_browser_list()
def _on_installed_item_changed(self, current: QListWidgetItem | None, _: Any) -> None: def _on_installed_item_changed(self, current: QListWidgetItem | None, _: Any) -> None:
"""Updates the right-hand details panel when a plugin is selected.""" """Updates the right-hand details panel when a plugin is selected."""
if not current: if not current:
self._clear_details_panel() self._clear_details_panel()
self.btn_update_plugin.setEnabled(False)
return return
info = current.data(Qt.ItemDataRole.UserRole) info = current.data(Qt.ItemDataRole.UserRole)
if not isinstance(info, dict): if not isinstance(info, dict):
self._clear_details_panel() self._clear_details_panel()
self.btn_update_plugin.setEnabled(False)
return return
plugin_info = cast(dict[str, Any], info) plugin_info = cast(dict[str, Any], info)
@@ -114,83 +224,161 @@ class PluginsWindow(QWidget):
desc = str(plugin_info.get("description", "No description provided.")) desc = str(plugin_info.get("description", "No description provided."))
is_disabled = bool(plugin_info.get("is_disabled", False)) is_disabled = bool(plugin_info.get("is_disabled", False))
path = str(plugin_info.get("path", "")) path = str(plugin_info.get("path", ""))
update_available = bool(plugin_info.get("update_available", False))
self.lbl_plugin_title.setText(name) self.lbl_plugin_title.setText(name)
self.lbl_plugin_meta.setText(f"<b>Version:</b> {version} &nbsp;|&nbsp; <b>Author:</b> {author}") self.lbl_plugin_meta.setText(f"<b>Version:</b> {version} &nbsp;|&nbsp; <b>Author:</b> {author}")
if is_disabled: if update_available:
rem_ver = plugin_info.get("remote_version", "")
self.lbl_plugin_status.setText(f"<font color='#1976d2'><b>Status: Update v{rem_ver} Available!</b></font>")
elif is_disabled:
self.lbl_plugin_status.setText("<font color='#d32f2f'><b>Status: Disabled</b></font>") self.lbl_plugin_status.setText("<font color='#d32f2f'><b>Status: Disabled</b></font>")
else: else:
self.lbl_plugin_status.setText("<font color='#2e7d32'><b>Status: Active</b></font>") self.lbl_plugin_status.setText("<font color='#2e7d32'><b>Status: Active</b></font>")
self.txt_plugin_desc.setHtml(desc) self.txt_plugin_desc.setHtml(desc)
self.lbl_plugin_path.setText(f"<b>Path:</b> <code>{path}</code>") self.lbl_plugin_path.setText(f"<b>Path:</b> <code>{path}</code>")
self.btn_update_plugin.setEnabled(update_available)
def _clear_details_panel(self) -> None: def execute_plugin_installation(self, download_url: str, plugin_id: str, plugin_name: str, is_update: bool, plugin_path: Path | None) -> None:
"""Clears details display when nothing is selected.""" """Sets up the progress dialog and starts the background installation/update worker."""
self.lbl_plugin_title.setText("Select a plugin") if self._install_worker is not None and self._install_worker.isRunning():
self.lbl_plugin_meta.setText("") return
self.lbl_plugin_status.setText("")
self.txt_plugin_desc.clear()
self.lbl_plugin_path.setText("")
def toggle_enable_disable(self) -> None: self._installation_finished_flag = False
action_label = "Updating" if is_update else "Installing"
self._progress_dialog = QProgressDialog(f"{action_label} '{plugin_name}'...", "Cancel", 0, 100, self)
self._progress_dialog.setMinimumDuration(0) # Show the progress dialog instantly
self._progress_dialog.setWindowTitle(f"{action_label} Plugin")
self._progress_dialog.setWindowModality(Qt.WindowModality.WindowModal)
self._progress_dialog.setAutoClose(False)
self._progress_dialog.setAutoReset(False)
self._progress_dialog.canceled.connect(self._cancel_installation)
self._progress_dialog.show()
self._install_worker = PluginInstallWorker(self.manager, download_url, plugin_id, is_update, plugin_path)
self._install_worker.progress.connect(self._on_install_progress)
self._install_worker.status_message.connect(self._on_install_status)
self._install_worker.finished.connect(lambda success, msg: self._on_install_finished(success, msg, plugin_name, is_update))
self._install_worker.start()
def _cancel_installation(self) -> None:
"""Handles manual user cancellation of the download/install progress."""
if getattr(self, "_installation_finished_flag", False):
return # Ignore if triggered by closing the dialog programmatically on success
if self._install_worker and self._install_worker.isRunning():
self._install_worker.terminate()
self._install_worker.wait()
QMessageBox.warning(self, "Cancelled", "Plugin installation was cancelled.")
def _on_install_finished(self, success: bool, message: str, plugin_name: str, is_update: bool) -> None:
self._installation_finished_flag = True # Prevent false cancellation triggers
if self._progress_dialog:
self._progress_dialog.close()
self._progress_dialog.deleteLater()
self._progress_dialog = None
action_word = "updated" if is_update else "installed"
action_verb = "update" if is_update else "install"
if success:
self.manager.reload_plugins()
QMessageBox.information(self, "Success", f"Successfully {action_word} '{plugin_name}'!")
self.refresh_installed_plugins()
if self._has_fetched_remote:
self._populate_browser_list()
else:
QMessageBox.critical(self, "Installation Failed", f"Could not {action_verb} plugin:\n{message}")
def _on_install_progress(self, downloaded: int, total: int) -> None:
if self._progress_dialog:
if total > 0:
self._progress_dialog.setMaximum(total)
self._progress_dialog.setValue(downloaded)
mb_down = downloaded / (1024 * 1024)
mb_total = total / (1024 * 1024)
self._progress_dialog.setLabelText(f"Downloading... ({mb_down:.2f} MB / {mb_total:.2f} MB)")
else:
self._progress_dialog.setMaximum(0) # Infinite marquee progress if size is unknown
mb_down = downloaded / (1024 * 1024)
self._progress_dialog.setLabelText(f"Downloading... ({mb_down:.2f} MB downloaded)")
def _on_install_status(self, message: str) -> None:
if self._progress_dialog:
self._progress_dialog.setLabelText(message)
def update_selected_plugin(self) -> None:
"""Triggers the background update process for the selected installed plugin."""
selected = self.installed_list.currentItem() selected = self.installed_list.currentItem()
if not selected: if not selected:
return return
raw_info = selected.data(Qt.ItemDataRole.UserRole) raw_info = selected.data(Qt.ItemDataRole.UserRole)
if isinstance(raw_info, dict):
info = cast(dict[str, Any], raw_info)
plugin_path = info.get("path")
if isinstance(plugin_path, str) and plugin_path:
self.manager.toggle_plugin_state(Path(plugin_path))
def uninstall_plugin(self) -> None:
selected = self.installed_list.currentItem()
if not selected:
return
raw_info = selected.data(Qt.ItemDataRole.UserRole)
if not isinstance(raw_info, dict): if not isinstance(raw_info, dict):
return return
info = cast(dict[str, Any], raw_info) info = cast(dict[str, Any], raw_info)
download_url = info.get("download_url", "")
plugin_id = info.get("id", "unnamed")
plugin_name = info.get("name", "Plugin")
plugin_path = info.get("path") plugin_path = info.get("path")
plugin_name = info.get("name", "this plugin")
if not isinstance(plugin_path, str) or not plugin_path: if not download_url:
QMessageBox.warning(self, "Update Failed", "No download URL provided for this update.")
return return
reply = QMessageBox.question( path_obj = Path(plugin_path) if isinstance(plugin_path, str) and plugin_path else (plugin_path if isinstance(plugin_path, Path) else None)
self, self.execute_plugin_installation(download_url, plugin_id, plugin_name, is_update=True, plugin_path=path_obj)
"Confirm Uninstall",
f"Are you sure you want to delete '{plugin_name}'?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
self.manager.uninstall_plugin(Path(plugin_path))
def fetch_remote_plugins(self) -> None: def install_selected_plugin(self) -> None:
"""Asynchronously fetches remote plugins on a background thread.""" """Triggers the background installation/update process from the browser tab."""
if self._fetch_worker is not None and self._fetch_worker.isRunning(): selected = self.browser_list.currentItem()
if not selected:
return
data = selected.data(Qt.ItemDataRole.UserRole)
if not isinstance(data, dict):
return return
self.browser_list.clear() plugin_data = cast(dict[str, Any], data)
self.browser_list.addItem("Fetching remote repositories...")
self.btn_install.setEnabled(False)
self.btn_fetch.setEnabled(False)
# Defer network call to background worker to avoid UI freeze update_available = bool(selected.data(Qt.ItemDataRole.UserRole + 3))
self._fetch_worker = RemoteFetchWorker(self.manager, self.repository_urls) plugin_id = str(plugin_data.get("id", "unnamed"))
self._fetch_worker.fetched.connect(self._on_remote_fetched) plugin_name = str(plugin_data.get("name", "Plugin"))
self._fetch_worker.start() download_url = str(plugin_data.get("download_url", ""))
def _on_remote_fetched(self, plugins: list[dict[str, Any]], all_failed: bool) -> None: if not download_url:
QMessageBox.warning(self, "Error", "No download URL provided for this plugin.")
return
# Find existing local path if updating an installed plugin
existing_path: Path | None = None
installed_info = self.manager.get_installed_plugins_info()
for info in installed_info:
if info.get("id") == plugin_id or info.get("name") == plugin_name:
p = info.get("path")
if isinstance(p, Path):
existing_path = p
elif isinstance(p, str):
existing_path = Path(p)
break
self.execute_plugin_installation(download_url, plugin_id, plugin_name, is_update=update_available, plugin_path=existing_path)
def _on_remote_fetched(self, plugins: list[dict[str, Any]], all_failed: bool, fetch_type: str) -> None:
"""Callback executed on the main UI thread when remote fetching finishes.""" """Callback executed on the main UI thread when remote fetching finishes."""
self.remote_plugins_data = plugins self.remote_plugins_data = plugins
self._has_fetched_remote = True self._has_fetched_remote = True
self.btn_fetch.setEnabled(True) self.btn_fetch.setEnabled(True)
if fetch_type == "updates":
self.refresh_installed_plugins()
return
if plugins: if plugins:
self._populate_browser_list() self._populate_browser_list()
elif all_failed: elif all_failed:
@@ -200,13 +388,33 @@ class PluginsWindow(QWidget):
self.browser_list.clear() self.browser_list.clear()
self.browser_list.addItem("No plugins found across configured repositories.") self.browser_list.addItem("No plugins found across configured repositories.")
def fetch_remote_plugins(self) -> None:
"""Asynchronously fetches remote plugins on a background thread for the browser."""
if self._fetch_worker is not None and self._fetch_worker.isRunning():
return
self.browser_list.clear()
self.browser_list.addItem("Fetching remote repositories...")
self.btn_install.setEnabled(False)
self.btn_fetch.setEnabled(False)
self._fetch_worker = RemoteFetchWorker(self.manager, self.repository_urls, fetch_type="browser")
self._fetch_worker.fetched.connect(self._on_remote_fetched)
self._fetch_worker.start()
def _populate_browser_list(self) -> None: def _populate_browser_list(self) -> None:
"""Populates the browser tab list and evaluates compatibility and installation state.""" """Populates the browser tab list and evaluates compatibility, installation, and updates."""
self.browser_list.clear() self.browser_list.clear()
installed_info = self.manager.get_installed_plugins_info() installed_info = self.manager.get_installed_plugins_info()
installed_ids = {p.get("id") for p in installed_info if p.get("id")} installed_map: dict[str, dict[str, Any]] = {}
installed_names = {p.get("name") for p in installed_info if p.get("name")} for p in installed_info:
p_id = p.get("id")
p_name = p.get("name")
if p_id:
installed_map[str(p_id)] = p
if p_name:
installed_map[str(p_name)] = p
for plugin in self.remote_plugins_data: for plugin in self.remote_plugins_data:
name = plugin.get("name", "Unknown") name = plugin.get("name", "Unknown")
@@ -220,7 +428,6 @@ class PluginsWindow(QWidget):
is_version_ok = self.manager.current_app_version >= parse_version(min_v_str) is_version_ok = self.manager.current_app_version >= parse_version(min_v_str)
is_compatible = is_platform_ok and is_version_ok is_compatible = is_platform_ok and is_version_ok
# Evaluate reasons for incompatibility
incompat_reasons: list[str] = [] incompat_reasons: list[str] = []
if not is_platform_ok: if not is_platform_ok:
plat_str = ", ".join(platforms) if isinstance(platforms, list) else str(platforms) plat_str = ", ".join(platforms) if isinstance(platforms, list) else str(platforms)
@@ -228,10 +435,20 @@ class PluginsWindow(QWidget):
if not is_version_ok: if not is_version_ok:
incompat_reasons.append(f"Requires App v{min_v_str}+") incompat_reasons.append(f"Requires App v{min_v_str}+")
is_installed = (bool(p_id) and p_id in installed_ids) or (bool(name) and name in installed_names) installed_match = installed_map.get(str(p_id)) or installed_map.get(str(name))
is_installed = installed_match is not None
update_available = False
remote_ver_str = str(plugin.get("version", "0.0.0"))
if is_installed:
local_ver_str = str(installed_match.get("version", "0.0.0"))
if parse_version(remote_ver_str) > parse_version(local_ver_str):
update_available = True
display_text = f"{name} (v{version}) - {desc}" display_text = f"{name} (v{version}) - {desc}"
if is_installed: if update_available:
display_text += f" [Update Available: v{remote_ver_str}]"
elif is_installed:
display_text += " [Installed]" display_text += " [Installed]"
elif incompat_reasons: elif incompat_reasons:
reason_str = "; ".join(incompat_reasons) reason_str = "; ".join(incompat_reasons)
@@ -241,28 +458,14 @@ class PluginsWindow(QWidget):
item.setData(Qt.ItemDataRole.UserRole, plugin) item.setData(Qt.ItemDataRole.UserRole, plugin)
item.setData(Qt.ItemDataRole.UserRole + 1, is_compatible) item.setData(Qt.ItemDataRole.UserRole + 1, is_compatible)
item.setData(Qt.ItemDataRole.UserRole + 2, is_installed) item.setData(Qt.ItemDataRole.UserRole + 2, is_installed)
item.setData(Qt.ItemDataRole.UserRole + 3, update_available)
if is_installed: if update_available:
item.setForeground(QColor("#2e7d32")) # Green for installed item.setForeground(QColor("#1976d2"))
elif is_installed:
item.setForeground(QColor("#2e7d32"))
elif not is_compatible: elif not is_compatible:
item.setForeground(QColor("#d32f2f")) # Red for incompatible item.setForeground(QColor("#d32f2f"))
def install_selected_plugin(self) -> None:
selected = self.browser_list.currentItem()
if not selected:
return
data = selected.data(Qt.ItemDataRole.UserRole)
if not data:
return
try:
target_path = self.manager.install_plugin_from_url(
data.get("download_url", ""),
data.get("id", "unnamed")
)
QMessageBox.information(self, "Success", f"Installed plugin to:\n{target_path}")
except Exception as e:
QMessageBox.critical(self, "Installation Failed", f"Could not install plugin:\n{e}")
def _on_tab_changed(self, index: int) -> None: def _on_tab_changed(self, index: int) -> None:
if index == 1 and not self._has_fetched_remote: if index == 1 and not self._has_fetched_remote:
@@ -272,10 +475,19 @@ class PluginsWindow(QWidget):
if current: if current:
is_compatible = bool(current.data(Qt.ItemDataRole.UserRole + 1)) is_compatible = bool(current.data(Qt.ItemDataRole.UserRole + 1))
is_installed = bool(current.data(Qt.ItemDataRole.UserRole + 2)) is_installed = bool(current.data(Qt.ItemDataRole.UserRole + 2))
update_available = bool(current.data(Qt.ItemDataRole.UserRole + 3))
# Button lights up ONLY if plugin is compatible and NOT yet installed
self.btn_install.setEnabled(is_compatible and not is_installed) if update_available:
self.btn_install.setText("Update Plugin")
self.btn_install.setEnabled(is_compatible)
elif is_installed:
self.btn_install.setText("Install Plugin")
self.btn_install.setEnabled(False)
else:
self.btn_install.setText("Install Plugin")
self.btn_install.setEnabled(is_compatible)
else: else:
self.btn_install.setText("Install Plugin")
self.btn_install.setEnabled(False) self.btn_install.setEnabled(False)
def _on_add_repo(self) -> None: def _on_add_repo(self) -> None:
@@ -334,14 +546,19 @@ class PluginsWindow(QWidget):
btn_layout = QHBoxLayout() btn_layout = QHBoxLayout()
btn_refresh = QPushButton("Refresh List", tab) btn_refresh = QPushButton("Refresh List", tab)
self.btn_update_plugin = QPushButton("Update Plugin", tab)
self.btn_update_plugin.setEnabled(False)
self.btn_update_plugin.setStyleSheet("font-weight: bold; color: #1976d2;")
btn_enable = QPushButton("Enable / Disable", tab) btn_enable = QPushButton("Enable / Disable", tab)
btn_uninstall = QPushButton("Uninstall", tab) btn_uninstall = QPushButton("Uninstall", tab)
btn_refresh.clicked.connect(self.refresh_installed_plugins) btn_refresh.clicked.connect(self.refresh_installed_plugins)
self.btn_update_plugin.clicked.connect(self.update_selected_plugin)
btn_enable.clicked.connect(self.toggle_enable_disable) btn_enable.clicked.connect(self.toggle_enable_disable)
btn_uninstall.clicked.connect(self.uninstall_plugin) btn_uninstall.clicked.connect(self.uninstall_plugin)
btn_layout.addWidget(btn_refresh) btn_layout.addWidget(btn_refresh)
btn_layout.addWidget(self.btn_update_plugin)
btn_layout.addStretch() btn_layout.addStretch()
btn_layout.addWidget(btn_enable) btn_layout.addWidget(btn_enable)
btn_layout.addWidget(btn_uninstall) btn_layout.addWidget(btn_uninstall)
@@ -388,4 +605,54 @@ class PluginsWindow(QWidget):
layout.addLayout(repo_layout) layout.addLayout(repo_layout)
layout.addWidget(self.browser_list) layout.addWidget(self.browser_list)
layout.addLayout(btn_layout) layout.addLayout(btn_layout)
return tab return tab
def toggle_enable_disable(self) -> None:
selected = self.installed_list.currentItem()
if not selected:
return
raw_info = selected.data(Qt.ItemDataRole.UserRole)
if isinstance(raw_info, dict):
info = cast(dict[str, Any], raw_info)
plugin_path = info.get("path")
if isinstance(plugin_path, str) and plugin_path:
self.manager.toggle_plugin_state(Path(plugin_path))
elif isinstance(plugin_path, Path):
self.manager.toggle_plugin_state(plugin_path)
def uninstall_plugin(self) -> None:
selected = self.installed_list.currentItem()
if not selected:
return
raw_info = selected.data(Qt.ItemDataRole.UserRole)
if not isinstance(raw_info, dict):
return
info = cast(dict[str, Any], raw_info)
plugin_path = info.get("path")
plugin_name = info.get("name", "this plugin")
if not plugin_path:
return
reply = QMessageBox.question(
self,
"Confirm Uninstall",
f"Are you sure you want to delete '{plugin_name}'?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
if isinstance(plugin_path, str):
self.manager.uninstall_plugin(Path(plugin_path))
elif isinstance(plugin_path, Path):
self.manager.uninstall_plugin(plugin_path)
def _clear_details_panel(self) -> None:
"""Clears details display when nothing is selected."""
self.lbl_plugin_title.setText("Select a plugin")
self.lbl_plugin_meta.setText("")
self.lbl_plugin_status.setText("")
self.txt_plugin_desc.clear()
self.lbl_plugin_path.setText("")
+14 -14
View File
@@ -33,11 +33,11 @@ class _AssocWorker(QThread):
window. Used for the non-elevated path too for consistency. window. Used for the non-elevated path too for consistency.
""" """
result_ready = Signal(bool, str) result_ready = Signal(bool, str)
def __init__(self, force_admin: bool, parent: QWidget | None = None) -> None: def __init__(self, force_admin: bool, parent: QWidget | None = None) -> None:
super().__init__(parent) super().__init__(parent)
self._force_admin = force_admin self._force_admin = force_admin
def run(self) -> None: def run(self) -> None:
ok, msg = register_file_association(force_admin=self._force_admin) ok, msg = register_file_association(force_admin=self._force_admin)
self.result_ready.emit(ok, msg) self.result_ready.emit(ok, msg)
@@ -92,7 +92,7 @@ class TerminalWindow(QWidget):
self._pending_assoc_confirmation = False self._pending_assoc_confirmation = False
self._handle_assoc_confirmation(command_text.strip().lower()) self._handle_assoc_confirmation(command_text.strip().lower())
return return
parts = command_text.strip().split() parts = command_text.strip().split()
if not parts: if not parts:
return return
@@ -118,10 +118,10 @@ class TerminalWindow(QWidget):
def cmd_help(self, *args: Any) -> str: def cmd_help(self, *args: Any) -> str:
available_cmds = [cmd for cmd in self.commands.keys() if cmd != "utest"] available_cmds = [cmd for cmd in self.commands.keys() if cmd != "utest"]
return f"Available commands: {', '.join(available_cmds)}" return f"Available commands: {', '.join(available_cmds)}"
def cmd_version(self, *args: Any) -> str: def cmd_version(self, *args: Any) -> str:
return f"{APP_NAME.upper()} is running version {CURRENT_VERSION}." return f"{APP_NAME.upper()} is running version {CURRENT_VERSION}."
def cmd_about(self, *args: Any) -> None: def cmd_about(self, *args: Any) -> None:
self.about = AboutWindow(self) self.about = AboutWindow(self)
self.about.show() self.about.show()
@@ -134,7 +134,7 @@ class TerminalWindow(QWidget):
main_win = self.parent() main_win = self.parent()
if not isinstance(main_win, QMainWindow): if not isinstance(main_win, QMainWindow):
return "[Error] Main window context not found." return "[Error] Main window context not found."
self.updater = UpdateManager( self.updater = UpdateManager(
main_window=main_win, main_window=main_win,
api_url=API_URL, api_url=API_URL,
@@ -145,7 +145,7 @@ class TerminalWindow(QWidget):
app_name=APP_NAME app_name=APP_NAME
) )
self.output_area.append("Checking for updates...") self.output_area.append("Checking for updates...")
self.updater.manual_check_for_updates() self.updater.manual_check_for_updates()
return "See status bar for update information." return "See status bar for update information."
@@ -155,10 +155,10 @@ class TerminalWindow(QWidget):
if PLATFORM_NAME != "windows" or is_windows_admin(): if PLATFORM_NAME != "windows" or is_windows_admin():
self._run_assoc(force_admin=False) self._run_assoc(force_admin=False)
return None return None
self._pending_assoc_confirmation = True self._pending_assoc_confirmation = True
return "Not running as admin. Register system-wide via UAC elevation? (y/n)" return "Not running as admin. Register system-wide via UAC elevation? (y/n)"
def _handle_assoc_confirmation(self, answer: str) -> None: def _handle_assoc_confirmation(self, answer: str) -> None:
if answer in ("y", "yes"): if answer in ("y", "yes"):
self._run_assoc(force_admin=True) self._run_assoc(force_admin=True)
@@ -166,15 +166,15 @@ class TerminalWindow(QWidget):
self._run_assoc(force_admin=False) self._run_assoc(force_admin=False)
else: else:
self.output_area.append("Please answer 'y' or 'n'. Run 'assoc' again to retry.") self.output_area.append("Please answer 'y' or 'n'. Run 'assoc' again to retry.")
def _run_assoc(self, force_admin: bool) -> None: def _run_assoc(self, force_admin: bool) -> None:
if force_admin: if force_admin:
self.output_area.append("Requesting elevation. Check for a UAC prompt...") self.output_area.append("Requesting elevation. Check for a UAC prompt...")
self._assoc_worker = _AssocWorker(force_admin=force_admin, parent=self) self._assoc_worker = _AssocWorker(force_admin=force_admin, parent=self)
self._assoc_worker.result_ready.connect(self._on_assoc_result) self._assoc_worker.result_ready.connect(self._on_assoc_result)
self._assoc_worker.start() self._assoc_worker.start()
def _on_assoc_result(self, ok: bool, msg: str) -> None: def _on_assoc_result(self, ok: bool, msg: str) -> None:
self.output_area.append(msg) self.output_area.append(msg)
self._assoc_worker = None self._assoc_worker = None
@@ -190,7 +190,7 @@ class TerminalWindow(QWidget):
return f"[Error] Target script not found at: {target_script}" return f"[Error] Target script not found at: {target_script}"
self._process = QProcess(self) self._process = QProcess(self)
# Stream stdout and stderr live to output_area # Stream stdout and stderr live to output_area
self._process.readyReadStandardOutput.connect(self._handle_stdout) self._process.readyReadStandardOutput.connect(self._handle_stdout)
self._process.readyReadStandardError.connect(self._handle_stderr) self._process.readyReadStandardError.connect(self._handle_stderr)
@@ -198,7 +198,7 @@ class TerminalWindow(QWidget):
# Use current Python interpreter executable. Works when packaged? # Use current Python interpreter executable. Works when packaged?
python_executable = sys.executable python_executable = sys.executable
self.output_area.append(f"Starting {target_script.name}...") self.output_area.append(f"Starting {target_script.name}...")
self._process.start(python_executable, [str(target_script)]) self._process.start(python_executable, [str(target_script)])
return None return None
+32 -32
View File
@@ -102,7 +102,7 @@ def _align_boris_and_write_snirf_worker(
for raw_event in boris_events: for raw_event in boris_events:
if not isinstance(raw_event, (list, tuple)): if not isinstance(raw_event, (list, tuple)):
continue continue
event = cast(Sequence[Any], raw_event) event = cast(Sequence[Any], raw_event)
if len(event) < 3: if len(event) < 3:
@@ -179,7 +179,7 @@ def align_boris_and_write_snirf_isolated(
time_shift time_shift
) )
return future.result() return future.result()
class EventUpdateMode(Enum): class EventUpdateMode(Enum):
WRITE_SNIRF = auto() # destructive WRITE_SNIRF = auto() # destructive
@@ -217,7 +217,7 @@ class UpdateEventsWindow(QWidget):
self.label_events = QLabel("Events in selected observation:") self.label_events = QLabel("Events in selected observation:")
self.combo_events = QComboBox() self.combo_events = QComboBox()
self.combo_events.setEnabled(False) self.combo_events.setEnabled(False)
self.label_snirf_events = QLabel("Events in SNIRF file:") self.label_snirf_events = QLabel("Events in SNIRF file:")
self.combo_snirf_events = QComboBox() self.combo_snirf_events = QComboBox()
self.combo_snirf_events.setEnabled(False) self.combo_snirf_events.setEnabled(False)
@@ -227,13 +227,13 @@ class UpdateEventsWindow(QWidget):
self.btn_clear.clicked.connect(self.clear_files) self.btn_clear.clicked.connect(self.clear_files)
self.btn_go.clicked.connect(self.go_action) self.btn_go.clicked.connect(self.go_action)
# --- # ---
layout = QVBoxLayout() layout = QVBoxLayout()
self.description = QLabel() self.description = QLabel()
self.description.setTextFormat(Qt.TextFormat.RichText) self.description.setTextFormat(Qt.TextFormat.RichText)
self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
self.description.setOpenExternalLinks(True) self.description.setOpenExternalLinks(True)
self.description.setText("The events that are present in a snirf file may not be the events that are to be studied and examined.<br>" self.description.setText("The events that are present in a snirf file may not be the events that are to be studied and examined.<br>"
"Utilizing different software and video recordings, it is easy enough to see when an action actually occured in a file.<br>" "Utilizing different software and video recordings, it is easy enough to see when an action actually occured in a file.<br>"
"The software <a href='https://www.boris.unito.it/'>BORIS</a> is used to add these events to video files, and these events can be applied to the snirf file <br>" "The software <a href='https://www.boris.unito.it/'>BORIS</a> is used to add these events to video files, and these events can be applied to the snirf file <br>"
@@ -317,7 +317,7 @@ class UpdateEventsWindow(QWidget):
help_btn_suffix.setToolTip(help_text_suffix) help_btn_suffix.setToolTip(help_text_suffix)
help_btn_suffix.clicked.connect(lambda: self.show_help_popup(help_text_suffix)) help_btn_suffix.clicked.connect(lambda: self.show_help_popup(help_text_suffix))
suffix2_layout.addWidget(help_btn_suffix) suffix2_layout.addWidget(help_btn_suffix)
suffix2_container = QWidget() suffix2_container = QWidget()
suffix2_container_layout = QHBoxLayout() suffix2_container_layout = QHBoxLayout()
suffix2_container_layout.setContentsMargins(0, 0, 0, 0) suffix2_container_layout.setContentsMargins(0, 0, 0, 0)
@@ -328,7 +328,7 @@ class UpdateEventsWindow(QWidget):
suffix2_layout.addWidget(suffix2_container) suffix2_layout.addWidget(suffix2_container)
layout.addLayout(suffix2_layout) layout.addLayout(suffix2_layout)
snirf_events_layout = QHBoxLayout() snirf_events_layout = QHBoxLayout()
help_text_snirf_events = "The event markers extracted from the SNIRF file." help_text_snirf_events = "The event markers extracted from the SNIRF file."
@@ -370,7 +370,7 @@ class UpdateEventsWindow(QWidget):
try: try:
# Memory leak safe: Extracted in a separate process that terminates immediately # Memory leak safe: Extracted in a separate process that terminates immediately
annotations: Annotations = load_snirf_annotations_isolated(file_path) annotations: Annotations = load_snirf_annotations_isolated(file_path)
print(f"Loaded {len(annotations)} annotations from {file_path}") print(f"Loaded {len(annotations)} annotations from {file_path}")
# Build individual event entries # Build individual event entries
@@ -398,7 +398,7 @@ class UpdateEventsWindow(QWidget):
file_path, _ = QFileDialog.getOpenFileName(self, "Select BORIS File", "", "BORIS project Files (*.boris)") file_path, _ = QFileDialog.getOpenFileName(self, "Select BORIS File", "", "BORIS project Files (*.boris)")
if file_path: if file_path:
self.line_edit_file_b.setText(file_path) self.line_edit_file_b.setText(file_path)
try: try:
with open(file_path, 'r', encoding='utf-8') as f: with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f) data = json.load(f)
@@ -411,7 +411,7 @@ class UpdateEventsWindow(QWidget):
except (json.JSONDecodeError, FileNotFoundError, KeyError) as e: except (json.JSONDecodeError, FileNotFoundError, KeyError) as e:
QMessageBox.warning(self, "Error", f"Failed to parse BORIS file:\n{e}") QMessageBox.warning(self, "Error", f"Failed to parse BORIS file:\n{e}")
def extract_boris_observation_keys(self, data: dict[str, Any]) -> List[str]: def extract_boris_observation_keys(self, data: dict[str, Any]) -> List[str]:
if "observations" not in data: if "observations" not in data:
raise KeyError("Missing 'observations' key in BORIS file.") raise KeyError("Missing 'observations' key in BORIS file.")
@@ -419,7 +419,7 @@ class UpdateEventsWindow(QWidget):
observations = cast(dict[str, Any], data["observations"]) observations = cast(dict[str, Any], data["observations"])
return list(observations.keys()) return list(observations.keys())
def on_observation_selected(self): def on_observation_selected(self):
selected_obs = self.combo_suffix.currentText() selected_obs = self.combo_suffix.currentText()
if not selected_obs or not hasattr(self, 'boris_data'): if not selected_obs or not hasattr(self, 'boris_data'):
@@ -454,12 +454,12 @@ class UpdateEventsWindow(QWidget):
def clear_files(self) -> None: def clear_files(self) -> None:
self.line_edit_file_a.clear() self.line_edit_file_a.clear()
self.line_edit_file_b.clear() self.line_edit_file_b.clear()
def go_action(self) -> None: def go_action(self) -> None:
file_a = self.line_edit_file_a.text() file_a = self.line_edit_file_a.text()
suffix = "flare" suffix = "flare"
if not hasattr(self, "boris_data") or self.combo_events.count() == 0 or self.combo_snirf_events.count() == 0: if not hasattr(self, "boris_data") or self.combo_events.count() == 0 or self.combo_snirf_events.count() == 0:
QMessageBox.warning(self, "Missing data", "Please make sure a BORIS and SNIRF event are selected.") QMessageBox.warning(self, "Missing data", "Please make sure a BORIS and SNIRF event are selected.")
return return
@@ -600,7 +600,7 @@ class UpdateEventsBlazesWindow(QWidget):
self.label_events = QLabel("Events in selected blazes file:") self.label_events = QLabel("Events in selected blazes file:")
self.combo_events = QComboBox() self.combo_events = QComboBox()
self.combo_events.setEnabled(False) self.combo_events.setEnabled(False)
self.label_snirf_events = QLabel("Events in SNIRF file:") self.label_snirf_events = QLabel("Events in SNIRF file:")
self.combo_snirf_events = QComboBox() self.combo_snirf_events = QComboBox()
self.combo_snirf_events.setEnabled(False) self.combo_snirf_events.setEnabled(False)
@@ -610,13 +610,13 @@ class UpdateEventsBlazesWindow(QWidget):
self.btn_clear.clicked.connect(self.clear_files) self.btn_clear.clicked.connect(self.clear_files)
self.btn_go.clicked.connect(self.go_action) self.btn_go.clicked.connect(self.go_action)
# --- # ---
layout = QVBoxLayout() layout = QVBoxLayout()
self.description = QLabel() self.description = QLabel()
self.description.setTextFormat(Qt.TextFormat.RichText) self.description.setTextFormat(Qt.TextFormat.RichText)
self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
self.description.setOpenExternalLinks(True) self.description.setOpenExternalLinks(True)
self.description.setText("The events that are present in a snirf file may not be the events that are to be studied and examined.<br>" self.description.setText("The events that are present in a snirf file may not be the events that are to be studied and examined.<br>"
"Utilizing different software and video recordings, it is easy enough to see when an action actually occured in a file.<br>" "Utilizing different software and video recordings, it is easy enough to see when an action actually occured in a file.<br>"
"The software <a href='https://git.research.dezeeuw.ca/tyler/blazes/'>BLAZES</a> is used to create these events in video files, and these events can be applied to the snirf file <br>" "The software <a href='https://git.research.dezeeuw.ca/tyler/blazes/'>BLAZES</a> is used to create these events in video files, and these events can be applied to the snirf file <br>"
@@ -679,7 +679,7 @@ class UpdateEventsBlazesWindow(QWidget):
help_btn_suffix.setToolTip(help_text_suffix) help_btn_suffix.setToolTip(help_text_suffix)
help_btn_suffix.clicked.connect(lambda: self.show_help_popup(help_text_suffix)) help_btn_suffix.clicked.connect(lambda: self.show_help_popup(help_text_suffix))
suffix2_layout.addWidget(help_btn_suffix) suffix2_layout.addWidget(help_btn_suffix)
suffix2_container = QWidget() suffix2_container = QWidget()
suffix2_container_layout = QHBoxLayout() suffix2_container_layout = QHBoxLayout()
suffix2_container_layout.setContentsMargins(0, 0, 0, 0) suffix2_container_layout.setContentsMargins(0, 0, 0, 0)
@@ -690,7 +690,7 @@ class UpdateEventsBlazesWindow(QWidget):
suffix2_layout.addWidget(suffix2_container) suffix2_layout.addWidget(suffix2_container)
layout.addLayout(suffix2_layout) layout.addLayout(suffix2_layout)
snirf_events_layout = QHBoxLayout() snirf_events_layout = QHBoxLayout()
help_text_snirf_events = "The event markers extracted from the SNIRF file." help_text_snirf_events = "The event markers extracted from the SNIRF file."
@@ -733,7 +733,7 @@ class UpdateEventsBlazesWindow(QWidget):
try: try:
# Memory leak safe: Extracted in a separate process that terminates immediately # Memory leak safe: Extracted in a separate process that terminates immediately
annotations: Annotations = load_snirf_annotations_isolated(file_path) annotations: Annotations = load_snirf_annotations_isolated(file_path)
print(f"Loaded {len(annotations)} annotations from {file_path}") print(f"Loaded {len(annotations)} annotations from {file_path}")
# Build individual event entries # Build individual event entries
@@ -761,7 +761,7 @@ class UpdateEventsBlazesWindow(QWidget):
file_path, _ = QFileDialog.getOpenFileName(self, "Select JSON Timeline File", "", "JSON Files (*.json)") file_path, _ = QFileDialog.getOpenFileName(self, "Select JSON Timeline File", "", "JSON Files (*.json)")
if file_path: if file_path:
self.line_edit_file_b.setText(file_path) self.line_edit_file_b.setText(file_path)
try: try:
with open(file_path, 'r', encoding='utf-8') as f: with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f) data = json.load(f)
@@ -780,7 +780,7 @@ class UpdateEventsBlazesWindow(QWidget):
QMessageBox.warning(self, "Error", f"Failed to parse JSON file:\n{e}") QMessageBox.warning(self, "Error", f"Failed to parse JSON file:\n{e}")
self.combo_events.clear() self.combo_events.clear()
self.combo_events.setEnabled(False) self.combo_events.setEnabled(False)
def extract_json_observation_strings(self, data: dict[str, Any]) -> List[str]: def extract_json_observation_strings(self, data: dict[str, Any]) -> List[str]:
if "events" not in data: if "events" not in data:
@@ -792,23 +792,23 @@ class UpdateEventsBlazesWindow(QWidget):
for event in data["events"]: for event in data["events"]:
track_name = event.get("track_name", "Unknown") track_name = event.get("track_name", "Unknown")
onset = event.get("start_sec", 0.0) onset = event.get("start_sec", 0.0)
display_str = f"{track_name} @ {onset:.3f}s" display_str = f"{track_name} @ {onset:.3f}s"
event_strings.append(display_str) event_strings.append(display_str)
return event_strings return event_strings
def clear_files(self) -> None: def clear_files(self) -> None:
self.line_edit_file_a.clear() self.line_edit_file_a.clear()
self.line_edit_file_b.clear() self.line_edit_file_b.clear()
def go_action(self) -> None: def go_action(self) -> None:
file_a = self.line_edit_file_a.text() file_a = self.line_edit_file_a.text()
_ = self.line_edit_file_b.text() _ = self.line_edit_file_b.text()
suffix = APP_NAME suffix = APP_NAME
if not hasattr(self, "json_data") or self.combo_events.count() == 0 or self.combo_snirf_events.count() == 0: if not hasattr(self, "json_data") or self.combo_events.count() == 0 or self.combo_snirf_events.count() == 0:
QMessageBox.warning(self, "Missing data", "Please make sure a JSON and SNIRF event are selected.") QMessageBox.warning(self, "Missing data", "Please make sure a JSON and SNIRF event are selected.")
return return
@@ -835,14 +835,14 @@ class UpdateEventsBlazesWindow(QWidget):
durations: list[float] = [] durations: list[float] = []
descriptions: list[str] = [] descriptions: list[str] = []
skipped_count = 0 skipped_count = 0
try: try:
events_list = self.json_data.get("events", []) events_list = self.json_data.get("events", [])
for event in events_list: for event in events_list:
track_name = event.get("track_name", "Unknown") track_name = event.get("track_name", "Unknown")
clean_name = track_name.replace("AI: ", "").strip() clean_name = track_name.replace("AI: ", "").strip()
original_start = event.get("start_sec", 0.0) original_start = event.get("start_sec", 0.0)
original_end = event.get("end_sec", original_start) original_end = event.get("end_sec", original_start)
duration = original_end - original_start duration = original_end - original_start
@@ -870,7 +870,7 @@ class UpdateEventsBlazesWindow(QWidget):
if self.mode == EventUpdateMode.WRITE_SNIRF: if self.mode == EventUpdateMode.WRITE_SNIRF:
suggested_name = f"{os.path.splitext(os.path.basename(file_a))[0]}_{suffix}.snirf" suggested_name = f"{os.path.splitext(os.path.basename(file_a))[0]}_{suffix}.snirf"
save_path, _ = QFileDialog.getSaveFileName(self, "Save SNIRF", suggested_name, "SNIRF Files (*.snirf)") save_path, _ = QFileDialog.getSaveFileName(self, "Save SNIRF", suggested_name, "SNIRF Files (*.snirf)")
if not save_path: return if not save_path: return
if not save_path.lower().endswith(".snirf"): save_path += ".snirf" if not save_path.lower().endswith(".snirf"): save_path += ".snirf"
+14 -14
View File
@@ -61,7 +61,7 @@ class UpdateOptodesWindow(QWidget):
self.description.setTextFormat(Qt.TextFormat.RichText) self.description.setTextFormat(Qt.TextFormat.RichText)
self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
self.description.setOpenExternalLinks(False) # Handle the click internally self.description.setOpenExternalLinks(False) # Handle the click internally
self.description.setText("Some software when creating snirf files will insert a template of optode positions as the correct position of the optodes for the participant.<br>" self.description.setText("Some software when creating snirf files will insert a template of optode positions as the correct position of the optodes for the participant.<br>"
"This is rarely correct as each head differs slightly in shape or size, and a lot of calculations require the optodes to be in the correct location.<br>" "This is rarely correct as each head differs slightly in shape or size, and a lot of calculations require the optodes to be in the correct location.<br>"
"Using a .txt or .xlsx file, we can update the positions in the snirf file to match those of a digitization system such as one from Polhemus or elsewhere.<br>" "Using a .txt or .xlsx file, we can update the positions in the snirf file to match those of a digitization system such as one from Polhemus or elsewhere.<br>"
@@ -270,12 +270,12 @@ class UpdateOptodesWindow(QWidget):
) )
def update_optode_positions( def update_optode_positions(
self, self,
file_a: Union[str, Path], file_a: Union[str, Path],
file_b: Union[str, Path], file_b: Union[str, Path],
save_path: Union[str, Path] save_path: Union[str, Path]
) -> None: ) -> None:
fiducials = {} fiducials = {}
ch_positions = {} ch_positions = {}
@@ -304,33 +304,33 @@ class UpdateOptodesWindow(QWidget):
df = pd.read_excel(file_b, sheet_name='Sheet1') # type: ignore df = pd.read_excel(file_b, sheet_name='Sheet1') # type: ignore
def _get_block_data( def _get_block_data(
target_df: pd.DataFrame, target_df: pd.DataFrame,
block_id: int, block_id: int,
row_mapping: Union[Dict[int, str], str], row_mapping: Union[Dict[int, str], str],
scale: float = 0.001 scale: float = 0.001
) -> Dict[str, npt.NDArray[np.float64]]: ) -> Dict[str, npt.NDArray[np.float64]]:
"""Isolates a block, cleans numeric data, and returns a scaled dictionary.""" """Isolates a block, cleans numeric data, and returns a scaled dictionary."""
# 1. Isolate and clean # 1. Isolate and clean
block = target_df[target_df['block_id'] == block_id].iloc[:, [1, 2, 3]].copy() block = target_df[target_df['block_id'] == block_id].iloc[:, [1, 2, 3]].copy()
block = block.apply(pd.to_numeric, errors='coerce') block = block.apply(pd.to_numeric, errors='coerce')
# 2. Extract into dictionary based on mapping # 2. Extract into dictionary based on mapping
result: Dict[str, npt.NDArray[np.float64]] = {} result: Dict[str, npt.NDArray[np.float64]] = {}
# If row_mapping is a dict (like {0: 'nz'}), use it directly # If row_mapping is a dict (like {0: 'nz'}), use it directly
if isinstance(row_mapping, dict): if isinstance(row_mapping, dict):
for row_idx, key in row_mapping.items(): for row_idx, key in row_mapping.items():
if row_idx < len(block): if row_idx < len(block):
result[key] = block.iloc[row_idx].to_numpy(dtype=float) * scale result[key] = block.iloc[row_idx].to_numpy(dtype=float) * scale
# If row_mapping is a string prefix (like 'D' or 'S'), auto-generate keys # If row_mapping is a string prefix (like 'D' or 'S'), auto-generate keys
else: else:
for i in range(len(block)): for i in range(len(block)):
result[f"{row_mapping}{i+1}"] = block.iloc[i].to_numpy(dtype=float) * scale result[f"{row_mapping}{i+1}"] = block.iloc[i].to_numpy(dtype=float) * scale
return result return result
# Identify blocks # Identify blocks
is_empty = df.isnull().all(axis=1) is_empty = df.isnull().all(axis=1)
df['block_id'] = is_empty.cumsum() df['block_id'] = is_empty.cumsum()
+12 -12
View File
@@ -48,12 +48,12 @@ class ViewerLauncherWidget(QWidget):
qc_dict: dict[str, dict[str, Any]], qc_dict: dict[str, dict[str, Any]],
folding_bypass: bool, folding_bypass: bool,
) -> None: ) -> None:
super().__init__() super().__init__()
self.setWindowTitle(f"Viewer Launcher - {APP_NAME.upper()}") self.setWindowTitle(f"Viewer Launcher - {APP_NAME.upper()}")
group_dict = {f: c.get("GROUP", "Unknown") for f, c in config_dict.items()} group_dict = {f: c.get("GROUP", "Unknown") for f, c in config_dict.items()}
btn_data: list[tuple[str, Type[QWidget], list[Any], bool]] = [ btn_data: list[tuple[str, Type[QWidget], list[Any], bool]] = [
("Participant Image Viewer", ParticipantImageViewerWidget, [haemo_dict, fig_bytes_dict], True), ("Participant Image Viewer", ParticipantImageViewerWidget, [haemo_dict, fig_bytes_dict], True),
("Participant Brain Viewer", ParticipantBrainViewerWidget, [haemo_dict, cha_dict], True), ("Participant Brain Viewer", ParticipantBrainViewerWidget, [haemo_dict, cha_dict], True),
@@ -64,7 +64,7 @@ class ViewerLauncherWidget(QWidget):
("Inter-Group Stats Viewer", InterGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, roi_channel_map_dict, group_dict], True), ("Inter-Group Stats Viewer", InterGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, roi_channel_map_dict, group_dict], True),
("Intra-Group Brain and Image Viewer", IntraGroupBrainImageWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True), ("Intra-Group Brain and Image Viewer", IntraGroupBrainImageWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True),
("Inter-Group Brain and Image Viewer", InterGroupBrainImageWidget, [haemo_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True), ("Inter-Group Brain and Image Viewer", InterGroupBrainImageWidget, [haemo_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True),
("Export To CSV Viewer", ExportToCSVWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict, config_dict, fir_feature_dict, qc_dict], True) ("Export To CSV Viewer", ExportToCSVWidget, [haemo_dict, epochs_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict, config_dict, fir_feature_dict, qc_dict], True)
] ]
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
@@ -84,23 +84,23 @@ class ViewerLauncherWidget(QWidget):
def callback(_checked: bool = False) -> None: def callback(_checked: bool = False) -> None:
self._open_viewer(widget_class, btn, *args) self._open_viewer(widget_class, btn, *args)
return callback return callback
def _open_viewer( def _open_viewer(
self, self,
widget_class: Type[QWidget], widget_class: Type[QWidget],
btn: QPushButton, btn: QPushButton,
*args: Any *args: Any
) -> None: ) -> None:
# Instantiate and show dynamically # Instantiate and show dynamically
self.active_viewer = widget_class(*args) self.active_viewer = widget_class(*args)
self.active_viewer.show() self.active_viewer.show()
self._trigger_success(btn) self._trigger_success(btn)
def _launch( def _launch(
self, self,
func: Callable[..., Any], func: Callable[..., Any],
btn: QPushButton, btn: QPushButton,
*args: Any *args: Any
) -> None: ) -> None:
@@ -112,7 +112,7 @@ class ViewerLauncherWidget(QWidget):
original_text = button.text() original_text = button.text()
button.setText(f"{original_text} ✔") button.setText(f"{original_text} ✔")
button.setStyleSheet("color: green; font-weight: bold;") button.setStyleSheet("color: green; font-weight: bold;")
# Revert after 1 second # Revert after 1 second
QTimer.singleShot(1000, lambda: self._revert_button(button, original_text)) QTimer.singleShot(1000, lambda: self._revert_button(button, original_text))
+7 -7
View File
@@ -22,9 +22,9 @@ class WelcomeDialog(QDialog):
self.setWindowTitle(f"What's New - {APP_NAME.upper()}") self.setWindowTitle(f"What's New - {APP_NAME.upper()}")
self.setMinimumSize(550, 450) self.setMinimumSize(550, 450)
self.resize(800, 500) self.resize(800, 500)
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
header_layout = QHBoxLayout() header_layout = QHBoxLayout()
logo_label = QLabel(self) logo_label = QLabel(self)
@@ -41,7 +41,7 @@ class WelcomeDialog(QDialog):
header_layout.addWidget(title_label) header_layout.addWidget(title_label)
header_layout.addStretch() header_layout.addStretch()
layout.addLayout(header_layout) layout.addLayout(header_layout)
self.text_browser = QTextBrowser(self) self.text_browser = QTextBrowser(self)
self.text_browser.setHtml("<p style='color: gray;'>Loading latest updates from server...</p>") self.text_browser.setHtml("<p style='color: gray;'>Loading latest updates from server...</p>")
@@ -49,17 +49,17 @@ class WelcomeDialog(QDialog):
self.text_browser.setOpenLinks(False) self.text_browser.setOpenLinks(False)
self.text_browser.anchorClicked.connect(QDesktopServices.openUrl) self.text_browser.anchorClicked.connect(QDesktopServices.openUrl)
layout.addWidget(self.text_browser) layout.addWidget(self.text_browser)
footer_layout = QHBoxLayout() footer_layout = QHBoxLayout()
ok_button = QPushButton("OK", self) ok_button = QPushButton("OK", self)
ok_button.setDefault(True) ok_button.setDefault(True)
ok_button.clicked.connect(self.accept) ok_button.clicked.connect(self.accept)
footer_layout.addStretch() footer_layout.addStretch()
footer_layout.addWidget(ok_button) footer_layout.addWidget(ok_button)
layout.addLayout(footer_layout) layout.addLayout(footer_layout)
# Fetch markdown from the web asynchronously # Fetch markdown from the web asynchronously
self.network_manager = QNetworkAccessManager(self) self.network_manager = QNetworkAccessManager(self)
self.network_manager.finished.connect(self._on_download_complete) self.network_manager.finished.connect(self._on_download_complete)
+1 -1
View File
@@ -30,7 +30,7 @@ def parse_startup_args(argv: list[str]) -> StartupArgs:
a fixed position, so it survives regardless of a fixed position, so it survives regardless of
whether --finish-update precedes it. whether --finish-update precedes it.
""" """
finish_update = "--finish-update" in argv[1:] finish_update = "--finish-update" in argv[1:]
initial_file = None initial_file = None
+12 -12
View File
@@ -37,7 +37,7 @@ class UpdateDownloadThread(QThread):
download_url (str): URL of the update zip file to download. download_url (str): URL of the update zip file to download.
latest_version (str): Version string of the latest update. latest_version (str): Version string of the latest update.
""" """
update_ready = Signal(str, str) update_ready = Signal(str, str)
error_occurred = Signal(str) error_occurred = Signal(str)
@@ -48,7 +48,7 @@ class UpdateDownloadThread(QThread):
platform_name: str, platform_name: str,
app_name: str, app_name: str,
) -> None: ) -> None:
super().__init__() super().__init__()
self.download_url = download_url self.download_url = download_url
self.latest_version = latest_version self.latest_version = latest_version
@@ -124,7 +124,7 @@ class UpdateCheckThread(QThread):
platform_name: str, platform_name: str,
app_name: str, app_name: str,
) -> None: ) -> None:
super().__init__() super().__init__()
self.api_url = api_url self.api_url = api_url
self.api_url_sec = api_url_sec self.api_url_sec = api_url_sec
@@ -171,7 +171,7 @@ class UpdateCheckThread(QThread):
if not latest: if not latest:
continue continue
tag = latest["tag_name"].lstrip("v") tag = latest["tag_name"].lstrip("v")
for asset in latest.get("assets", []): for asset in latest.get("assets", []):
@@ -203,7 +203,7 @@ class LocalPendingUpdateCheckThread(QThread):
platform_name: str, platform_name: str,
app_name: str, app_name: str,
) -> None: ) -> None:
super().__init__() super().__init__()
self.current_version = current_version self.current_version = current_version
self.platform_suffix = platform_suffix self.platform_suffix = platform_suffix
@@ -237,7 +237,7 @@ class LocalPendingUpdateCheckThread(QThread):
break break
except Exception: except Exception:
pass pass
if not found: if not found:
self.no_pending_update.emit() self.no_pending_update.emit()
@@ -247,7 +247,7 @@ class UpdateManager(QObject):
Orchestrates the update process. Orchestrates the update process.
Main apps should instantiate this and call check_for_updates(). Main apps should instantiate this and call check_for_updates().
""" """
def __init__( def __init__(
self, self,
main_window: QMainWindow, main_window: QMainWindow,
@@ -258,7 +258,7 @@ class UpdateManager(QObject):
platform_suffix: str, platform_suffix: str,
app_name: str, app_name: str,
) -> None: ) -> None:
super().__init__(main_window) super().__init__(main_window)
self.main_window: QMainWindow = main_window self.main_window: QMainWindow = main_window
self.api_url = api_url self.api_url = api_url
@@ -267,7 +267,7 @@ class UpdateManager(QObject):
self.platform_name = platform_name self.platform_name = platform_name
self.platform_suffix = platform_suffix self.platform_suffix = platform_suffix
self.app_name = app_name self.app_name = app_name
self.pending_update_version: Optional[str] = None self.pending_update_version: Optional[str] = None
self.pending_update_path: Optional[str] = None self.pending_update_path: Optional[str] = None
@@ -331,7 +331,7 @@ class UpdateManager(QObject):
except Exception as e: except Exception as e:
if self.main_window.statusBar(): if self.main_window.statusBar():
self.main_window.statusBar().showMessage(f"Failed to delete old update folder: {e}") self.main_window.statusBar().showMessage(f"Failed to delete old update folder: {e}")
# Clear pending update info so new download proceeds # Clear pending update info so new download proceeds
self.pending_update_version = None self.pending_update_version = None
self.pending_update_path = None self.pending_update_path = None
@@ -471,7 +471,7 @@ def finish_update_if_needed(platform_name: str, app_name: str, cfg_path: str, fi
return return
print("Finishing update...") print("Finishing update...")
# 1. Reset welcome dialog config flag # 1. Reset welcome dialog config flag
update_cfg = configparser.ConfigParser() update_cfg = configparser.ConfigParser()
try: try:
@@ -480,7 +480,7 @@ def finish_update_if_needed(platform_name: str, app_name: str, cfg_path: str, fi
if not update_cfg.has_section("Options"): if not update_cfg.has_section("Options"):
update_cfg.add_section("Options") update_cfg.add_section("Options")
update_cfg.set("Options", "show_welcome_dialog", "true") update_cfg.set("Options", "show_welcome_dialog", "true")
with open(cfg_path, "w") as f: with open(cfg_path, "w") as f:
update_cfg.write(f) update_cfg.write(f)
print("Welcome dialog flag successfully reset to 'true' for next run.") print("Welcome dialog flag successfully reset to 'true' for next run.")