improved project saving capabilities

This commit is contained in:
2026-08-03 22:34:41 -07:00
parent 0b52d2b0bc
commit a0199737f5
6 changed files with 1512 additions and 1125 deletions
+14 -3
View File
@@ -1,6 +1,6 @@
# Verison 1.6.0 # Verison 1.6.0
- This is potentially a save-changing release due to adding more data into the save file and renaming existing data. Please update your project files to ensure compatibility - This is potentially a save-changing release due to adding more data into the save file, renaming existing data, and changing what part of the code saves data. Please update your project files to ensure compatibility
- It is still possible to load older saves by enabling 'Incompatible Save Bypass' from the Preferences menu, but your mileage may vary - It is still possible to load older saves by enabling 'Incompatible Save Bypass' from the Preferences menu, but your mileage may vary
- Optimized calculations being performed when calculating the heart rate to speed up step 5 by up to ~35% on a per-file basis - Optimized calculations being performed when calculating the heart rate to speed up step 5 by up to ~35% on a per-file basis
- Optimized calculations being performed when running the General Linear Model to speed up step 5 by ~35% on a per-file basis - Optimized calculations being performed when running the General Linear Model to speed up step 5 by ~35% on a per-file basis
@@ -13,6 +13,17 @@
- Added a new metadata value of HAND to allow for grouping participants based on their handiness - Added a new metadata value of HAND to allow for grouping participants based on their handiness
- Updated the layout of the participant metadata area to better accommodate current and future metadata values - Updated the layout of the participant metadata area to better accommodate current and future metadata values
- Updated the message displayed when "Why are these useful?" is clicked to better represent what the values are used for and to accomidate the new metadata values - Updated the message displayed when "Why are these useful?" is clicked to better represent what the values are used for and to accomidate the new metadata values
- Fixed some popup windows not properly displaying the application name
- Changed the application title to now show what project you are working on. If you are not working on a project, it will display 'Untitled'
- Tracking of current save states is now present and an asterisks will now appear in the title bar when a project has not been saved
- Currently tracking is only supported on loaded files, parameters on the right side of the screen, and when processing has completed. Per-file metadata is not currently implemented
- Clicking close on the Main Window will now properly close all subwindows when closing the application, and closing the application with unsaved changes will now prompt to save a project or discard changes. Fixes [Issue 87](https://git.research.dezeeuw.ca/tyler/flares/issues/87)
- The Save button under the File menu no longer asks for a destination if a saved project was already loaded. Fixes [Issue 72](https://git.research.dezeeuw.ca/tyler/flares/issues/72)
- The popup that appeared when a project was saved has now been moved to a message displayed from the status bar
- Fixed an issue where when loading a save file some list dropdowns could go blue and bold even if the value was default
- Moved all loading of files, loading of folders, loading of projects, and saving of projects to a new file for easier future development
- Changed the working of some messages on the status bar to better reflect what has occurred
- Fixed an issue where the Analysis button would not be clickable if the user had previously pushed Clear. Fixes [Issue 83](https://git.research.dezeeuw.ca/tyler/flares/issues/83)
# Version 1.5.2 # Version 1.5.2
@@ -62,7 +73,7 @@
- Fixed participant metadata remaining in the background when the participant was removed. Fixes [Issue 82](https://git.research.dezeeuw.ca/tyler/flares/issues/82) - Fixed participant metadata remaining in the background when the participant was removed. Fixes [Issue 82](https://git.research.dezeeuw.ca/tyler/flares/issues/82)
- Fixed processing remaining active hidden in the background when the "Clear" button was pushed. Fixes [Issue 81](https://git.research.dezeeuw.ca/tyler/flares/issues/81) - Fixed processing remaining active hidden in the background when the "Clear" button was pushed. Fixes [Issue 81](https://git.research.dezeeuw.ca/tyler/flares/issues/81)
- Now when "Clear" is pushed while data is processing, a popup will appear ensuring that the user understands that pushing "Clear" will stop processing - Now when "Clear" is pushed while data is processing, a popup will appear ensuring that the user understands that pushing "Clear" will stop processing
- Reset to Default Configuration will now properly reset all of the parameters to their default values. [Issue 90](https://git.research.dezeeuw.ca/tyler/flares/issues/90) - Reset to Default Configuration will now properly reset all of the parameters to their default values. Fixes [Issue 90](https://git.research.dezeeuw.ca/tyler/flares/issues/90)
- A confirmation popup will now display once the application has been reset to default in addition to the status bar message - A confirmation popup will now display once the application has been reset to default in addition to the status bar message
- Changed the improper display text of "Cross Validation" to now properly read as "Coefficient of Variation" - Changed the improper display text of "Cross Validation" to now properly read as "Coefficient of Variation"
- Changed the parameters "CV" and "CV_THRESHOLD" to now be "COEFF_VAR and COEFF_VAR_THRESHOLD" - Changed the parameters "CV" and "CV_THRESHOLD" to now be "COEFF_VAR and COEFF_VAR_THRESHOLD"
@@ -81,7 +92,7 @@
- Fixed a crucial bug where short channels were not being processed and filtered the same way as long channels before being used as regressors - Fixed a crucial bug where short channels were not being processed and filtered the same way as long channels before being used as regressors
- Fixed a crucial bug where short channels were being presented to the design matrix as normal long channels - Fixed a crucial bug where short channels were being presented to the design matrix as normal long channels
- Fixed a crucial bug where long channels could be interpolated from short channels. Short channels are still potentially interpolated from long channels. See [this link](https://git.research.dezeeuw.ca/tyler/flares/issues/80) for more information regarding this issue. - Fixed a crucial bug where long channels could be interpolated from short channels. Short channels are still potentially interpolated from long channels. See [this link](https://git.research.dezeeuw.ca/tyler/flares/issues/80) for more information regarding this issue.
- Decreased unnecessary processing time when fOLDing channels by an order of magnitude - Decreased unnecessary processing time when fOLDing channels by an order of magnitude. Fixes [Issue 84](https://git.research.dezeeuw.ca/tyler/flares/issues/84)
- Added a welcome message when the terminal is opened, resized the terminal, and added more commands - Added a welcome message when the terminal is opened, resized the terminal, and added more commands
+5 -5
View File
@@ -12,9 +12,9 @@
- New parameters have been added to the right side of the screen! This allows for more flexibility and customizability when processing - New parameters have been added to the right side of the screen! This allows for more flexibility and customizability when processing
- A new Preference Menu option has been added: Show Advanced Parameters. This keeps some of the parameters hidden when not checked. Since this is a preference, it will be saved when reopening the application - A new Preference Menu option has been added: Show Advanced Parameters. This keeps some of the parameters hidden when not checked. Since this is a preference, it will be saved when reopening the application
- Advanced parameters should only be changed if you know what you are doing, and will have a yellow warning symbol next to them to avoid potential confusion on what parameters are advanced - Advanced parameters should only be changed if you know what you are doing, and will have a yellow warning symbol next to them to avoid potential confusion on what parameters are advanced
- Optimized some of the calculations in Scalp Coupling Index to speed up Step 6 by ~25% - Optimized some of the calculations in Scalp Coupling Index to speed up Step 6 by up to ~25% on a per-file basis
- Removed duplicate/redundant calculations in Peak Spectral Power to speed up Step 8 by ~50% - Removed duplicate/redundant calculations in Peak Spectral Power to speed up Step 8 by up to ~50% on a per-file basis
- Changed how the figures are generated when processing to speed up Step 28 by ~85% - Changed how the figures are generated when processing to speed up Step 28 by up to ~85% on a per-file basis
- Removed unused methods inside the processing file to slightly speed up application load time - Removed unused methods inside the processing file to slightly speed up application load time
- Fixed an issue with the build script not properly updating the version string causing the application to falsely think that an update was always available - Fixed an issue with the build script not properly updating the version string causing the application to falsely think that an update was always available
- Fixed an issue where parameters that were dependent on SHORT_CHANNELS were not properly being updated - Fixed an issue where parameters that were dependent on SHORT_CHANNELS were not properly being updated
@@ -45,7 +45,7 @@
- Fixed participant metadata remaining in the background when the participant was removed. Fixes [Issue 82](https://git.research.dezeeuw.ca/tyler/flares/issues/82) - Fixed participant metadata remaining in the background when the participant was removed. Fixes [Issue 82](https://git.research.dezeeuw.ca/tyler/flares/issues/82)
- Fixed processing remaining active hidden in the background when the "Clear" button was pushed. Fixes [Issue 81](https://git.research.dezeeuw.ca/tyler/flares/issues/81) - Fixed processing remaining active hidden in the background when the "Clear" button was pushed. Fixes [Issue 81](https://git.research.dezeeuw.ca/tyler/flares/issues/81)
- Now when "Clear" is pushed while data is processing, a popup will appear ensuring that the user understands that pushing "Clear" will stop processing - Now when "Clear" is pushed while data is processing, a popup will appear ensuring that the user understands that pushing "Clear" will stop processing
- Reset to Default Configuration will now properly reset all of the parameters to their default values. [Issue 90](https://git.research.dezeeuw.ca/tyler/flares/issues/90) - Reset to Default Configuration will now properly reset all of the parameters to their default values. Fixes [Issue 90](https://git.research.dezeeuw.ca/tyler/flares/issues/90)
- A confirmation popup will now display once the application has been reset to default in addition to the status bar message - A confirmation popup will now display once the application has been reset to default in addition to the status bar message
- Changed the improper display text of "Cross Validation" to now properly read as "Coefficient of Variation" - Changed the improper display text of "Cross Validation" to now properly read as "Coefficient of Variation"
- Changed the parameters "CV" and "CV_THRESHOLD" to now be "COEFF_VAR and COEFF_VAR_THRESHOLD" - Changed the parameters "CV" and "CV_THRESHOLD" to now be "COEFF_VAR and COEFF_VAR_THRESHOLD"
@@ -64,7 +64,7 @@
- Fixed a crucial bug where short channels were not being processed and filtered the same way as long channels before being used as regressors - Fixed a crucial bug where short channels were not being processed and filtered the same way as long channels before being used as regressors
- Fixed a crucial bug where short channels were being presented to the design matrix as normal long channels - Fixed a crucial bug where short channels were being presented to the design matrix as normal long channels
- Fixed a crucial bug where long channels could be interpolated from short channels. Short channels are still potentially interpolated from long channels. See [this link](https://git.research.dezeeuw.ca/tyler/flares/issues/80) for more information regarding this issue. - Fixed a crucial bug where long channels could be interpolated from short channels. Short channels are still potentially interpolated from long channels. See [this link](https://git.research.dezeeuw.ca/tyler/flares/issues/80) for more information regarding this issue.
- Decreased unnecessary processing time when fOLDing channels by an order of magnitude - Decreased unnecessary processing time when fOLDing channels by an order of magnitude. Fixes [Issue 84](https://git.research.dezeeuw.ca/tyler/flares/issues/84)
- Added a welcome message when the terminal is opened, resized the terminal, and added more commands - Added a welcome message when the terminal is opened, resized the terminal, and added more commands
+229 -819
View File
File diff suppressed because it is too large Load Diff
+826
View File
@@ -0,0 +1,826 @@
"""
Filename: project_manager.py
Description: Manager file for anything project related
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in imports
import os
import sys
import copy
import pickle
import concurrent
from pathlib import Path, PurePosixPath
# External library imports
import pandas as pd
from PySide6.QtWidgets import QMessageBox, QVBoxLayout, QFileDialog, QLabel, QDialog
from PySide6.QtCore import QThread, Signal, Qt, QTimer
from PySide6.QtGui import QAction
from mne.io import read_raw_snirf
from mne.preprocessing.nirs import source_detector_distances
from mne_nirs.channels import get_short_channels # type: ignore
from src.shared.flaresbasewidget import ProgressBubble
from src.shared.shareddata import APP_NAME, CURRENT_VERSION, PLATFORM_NAME, DATA_SCHEMA
class SaveProjectThread(QThread):
finished_signal = Signal(str)
error_signal = Signal(str)
def __init__(self, filename, project_data):
super().__init__()
self.filename = filename
self.project_data = project_data
def run(self):
try:
with open(self.filename, "wb") as f:
pickle.dump(self.project_data, f)
self.finished_signal.emit(self.filename)
except Exception as e:
self.error_signal.emit(str(e))
class SavingOverlay(QDialog):
def __init__(self, parent=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("Saving 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 ProjectManager:
"""
Central manager for all I/O operations:
- File loading (individual files & folders)
- Project loading & saving (Save vs Save As)
- Relative / absolute path utilities
- State baseline synchronization (dirty tracking)
"""
def __init__(self, app, file_cfg, cfg_path):
self.app = app
self.file_cfg = file_cfg
self.cfg_path = cfg_path
# =========================================================================
# Path Utilities
# =========================================================================
def get_safe_path(self, target_path, project_dir):
"""Converts an absolute file path to a relative path relative to project_dir."""
try:
target = Path(target_path).resolve()
proj = Path(project_dir).resolve()
return str(PurePosixPath(target.relative_to(proj)))
except ValueError:
# Fall back to absolute path string if on a different drive/volume
return str(PurePosixPath(Path(target_path).resolve()))
# =========================================================================
# File & Folder Opening Dialogs
# =========================================================================
def open_file_dialog(self):
"""Opens dialog to pick a single .snirf file."""
file_path, _ = QFileDialog.getOpenFileName(
self.app, "Open File", "", "SNIRF Files (*.snirf);;All Files (*)"
)
if file_path:
self._load_files_into_pipeline([os.path.normpath(file_path)])
def open_folder_dialog(self):
"""Recursively finds all .snirf files in a selected directory."""
folder_path = QFileDialog.getExistingDirectory(self.app, "Select Folder", "")
if folder_path:
snirf_files = [os.path.normpath(str(f)) for f in Path(folder_path).rglob("*.snirf")]
self._load_files_into_pipeline(snirf_files)
def _load_files_into_pipeline(self, file_paths):
"""Loads .snirf files into UI using chunked batches and background workers."""
app = self.app
if not file_paths:
return
# 1. Warm up the executor if needed
if not hasattr(app, "file_executor") or app.file_executor is None:
app.file_executor = concurrent.futures.ProcessPoolExecutor(max_workers=1)
# 2. Track this session to prevent ghost updates
if not hasattr(app, "loading_session_id"):
app.loading_session_id = 0
app.loading_session_id += 1
current_session = app.loading_session_id
# 3. Setup internal tracking if not exists
if not hasattr(app, "bubble_widgets"):
app.bubble_widgets = {}
if not hasattr(app, "selected_paths"):
app.selected_paths = []
if not hasattr(app, "metadata_cache"):
app.metadata_cache = {}
# Filter out files already in the UI to avoid duplicates
new_files = [p for p in file_paths if p not in app.selected_paths]
if not new_files:
return
# Update the pending count for the current load batch
if not hasattr(app, "pending_files_count"):
app.pending_files_count = 0
app.pending_files_count += len(new_files)
app.button1.setVisible(True)
app.statusBar().showMessage(f"Loading {len(new_files)} new file(s)...")
# Queue chunked widget creation
CHUNK_SIZE = 10
def process_chunk(file_queue):
chunk = file_queue[:CHUNK_SIZE]
remaining = file_queue[CHUNK_SIZE:]
for path in chunk:
app.selected_paths.append(path)
self.add_to_recent_files(path)
display_name = os.path.basename(path)
bubble = ProgressBubble(display_name, path)
bubble.setCursor(Qt.CursorShape.WaitCursor)
bubble.set_loading_state(True)
app.bubble_widgets[path] = bubble
app.bubble_layout.addWidget(bubble)
# Submit background task as each bubble is constructed
future = app.file_executor.submit(extract_metadata_worker, path)
future.add_done_callback(
lambda f, p=path, s=current_session: app._on_metadata_ready(f, p, s)
)
app.files_are_dirty = True
app.is_saved = False
if hasattr(app, "check_if_app_is_dirty"):
app.check_if_app_is_dirty()
elif hasattr(app, "update_window_title"):
app.update_window_title()
# Schedule remaining files
if remaining:
QTimer.singleShot(0, lambda: process_chunk(remaining))
process_chunk(new_files)
def add_files_to_project(self, file_paths):
"""Adds file paths to the application state, creating bubble UI items."""
app = self.app
normalized_paths = [os.path.normpath(p) for p in file_paths]
# Merge with existing selected paths avoiding duplicates
existing_paths = getattr(app, "selected_paths", [])
new_paths = [p for p in normalized_paths if p not in existing_paths]
if not new_paths:
return
app.selected_paths = existing_paths + new_paths
# Render file bubbles in UI if method exists
if hasattr(app, "show_files_as_bubbles_from_list"):
progress_states = getattr(app, "progress_states", {})
current_project = getattr(app, "current_project_path", "")
app.show_files_as_bubbles_from_list(
app.selected_paths, progress_states, current_project
)
# Record in recent files menu
for path in new_paths:
self.add_to_recent_files(path)
# Trigger dirty check
if hasattr(app, "check_if_app_is_dirty"):
app.check_if_app_is_dirty()
# =========================================================================
# Project Loading
# =========================================================================
def load_project_dialog(self):
"""Prompts for a project file and loads it."""
app = self.app
filename, _ = QFileDialog.getOpenFileName(
app, "Load Project", "", "FLARE Project (*.flare)"
)
if filename:
self.load_project(filename)
def load_project(self, filename):
"""Loads a .flare project file into the application."""
app = self.app
try:
with open(filename, "rb") as f:
data = pickle.load(f)
checks = [
("version", "<=1.1.7"),
("file_metadata", "<=1.2.2"),
("file_parameters", "<=1.3.0"),
("roi_channel_map_dict", "<=1.5.2"),
]
for key, ver_str in checks:
if key not in data:
msg = (
f"This project was saved in an earlier version of {APP_NAME.upper()} ({ver_str}) "
"and is potentially not compatible with this version. "
)
if getattr(app, "incompatible_save_bypass", False):
QMessageBox.warning(
app, f"Warning - {APP_NAME.upper()}", msg + "Attempting load."
)
break
else:
QMessageBox.critical(
app,
f"Error - {APP_NAME.upper()}",
msg + "Enable bypass in Preferences to load.",
)
return
# Clear existing UI bubbles
if hasattr(app, "bubble_widgets"):
for bubble in list(app.bubble_widgets.values()):
bubble.setParent(None)
bubble.deleteLater()
app.bubble_widgets.clear()
app.selected_paths = []
app.current_project_path = filename
# Restore Data Schema
for item in DATA_SCHEMA:
setattr(app, item["key"], data.get(item["key"], {}))
project_dir = Path(filename).parent
saved_cache = data.get("file_metadata", {})
raw_params = data.get("file_parameters", {})
app.metadata_cache = {}
app.file_metadata = {}
for rel_path, meta_content in saved_cache.items():
abs_path = str((project_dir / Path(rel_path)).resolve())
app.metadata_cache[abs_path] = meta_content
file_list = [
str((project_dir / Path(rel_path)).resolve()) for rel_path in data["file_list"]
]
raw_progress = data.get("progress_states", {})
progress_states = {
str((project_dir / Path(rel_path)).resolve()): step
for rel_path, step in raw_progress.items()
}
for rel_path in data["file_list"]:
abs_path = str((project_dir / Path(rel_path)).resolve())
if rel_path in raw_params:
app.file_metadata[abs_path] = raw_params[rel_path]
elif hasattr(app, "config_dict") and abs_path in app.config_dict:
old_cfg = app.config_dict[abs_path]
app.file_metadata[abs_path] = {
"AGE": str(old_cfg.get("AGE", "")),
"SEX": str(old_cfg.get("SEX", "")),
"HAND": str(old_cfg.get("HAND", "")),
"GROUP": str(old_cfg.get("GROUP", "")),
}
else:
app.file_metadata[abs_path] = {
"AGE": "",
"SEX": "",
"HAND": "",
"GROUP": "",
}
app.show_files_as_bubbles_from_list(file_list, progress_states, filename)
if "current_ui_params" in data:
app.restore_sections_from_config(data["current_ui_params"])
elif getattr(app, "config_dict", None):
first_file = next(iter(app.config_dict.keys()))
app.restore_sections_from_config(app.config_dict[first_file])
has_data = any(len(getattr(app, item["key"], {})) > 0 for item in DATA_SCHEMA)
if hasattr(app, "button1"):
app.button1.setVisible(not has_data)
if hasattr(app, "button3"):
app.button3.setVisible(has_data)
self.add_to_recent_projects(os.path.normpath(filename))
# Reset baselines cleanly
self.reset_all_dirty_states()
QMessageBox.information(app, "Loaded", f"Project loaded from:\n{filename}")
except Exception as e:
QMessageBox.critical(app, "Error", f"Failed to load project:\n{e}")
# =========================================================================
# Project Saving (Save / Save As)
# =========================================================================
def save_project(self, onCrash=False, ask=False):
"""
Saves the project to disk.
- ask=False: Quick Save to self.app.current_project_path (prompts if unsaved).
- ask=True: Save As (always prompts for location).
"""
app = self.app
# 1. Sync active text fields into active metadata dict
if hasattr(app, "current_file") and app.current_file and hasattr(app, "meta_fields"):
if not hasattr(app, "file_metadata"):
app.file_metadata = {}
app.file_metadata[app.current_file] = {
key: field.text().strip() for key, field in app.meta_fields.items()
}
# 2. Check if saveable state exists
has_files = len(getattr(app, "selected_paths", [])) > 0
has_metadata = any(
any(val for val in m.values()) for m in getattr(app, "file_metadata", {}).values()
)
has_param_changes = any(
s.has_any_changes() for s in getattr(app, "param_sections", [])
)
has_processed_data = any(
len(getattr(app, item["key"], {})) > 0 for item in DATA_SCHEMA
)
if not (has_files or has_processed_data or has_metadata or has_param_changes):
if not onCrash:
QMessageBox.warning(
app,
"Save Project",
"There is no data or configuration to save.",
)
return
# 3. Path Resolution
filename = None
if not onCrash:
existing_path = getattr(app, "current_project_path", None)
if ask or not existing_path:
start_dir = existing_path if existing_path else ""
filename, _ = QFileDialog.getSaveFileName(
app, "Save Project", start_dir, "FLARE Project (*.flare)"
)
if not filename:
return
else:
filename = existing_path
else:
if PLATFORM_NAME == "darwin":
filename = os.path.join(
os.path.dirname(sys.executable), "../../../flares_autosave.flare"
)
else:
filename = os.path.join(os.getcwd(), "flares_autosave.flare")
try:
if not filename.endswith(".flare"):
filename += ".flare"
project_path = Path(filename).resolve()
project_dir = project_path.parent
# 4. Convert Paths to Relative
bubble_widgets = getattr(app, "bubble_widgets", {})
file_list = [
self.get_safe_path(b.file_path, project_dir) for b in bubble_widgets.values()
]
progress_states = {
self.get_safe_path(b.file_path, project_dir): getattr(b, "current_step", 0)
for b in bubble_widgets.values()
}
rel_metadata = {}
for full_path, meta in getattr(app, "metadata_cache", {}).items():
try:
rel_metadata[self.get_safe_path(full_path, project_dir)] = meta
except Exception as e:
print(f"Metadata conversion failed for {full_path}: {e}")
rel_file_params = {
self.get_safe_path(f_path, project_dir): meta
for f_path, meta in getattr(app, "file_metadata", {}).items()
}
current_params = app.get_all_current_ui_params()
if not current_params and getattr(app, "config_dict", None):
first_file = next(iter(app.config_dict.keys()))
current_params = app.config_dict[first_file]
# 5. Build Serialized Payload
project_data = {
item["key"]: getattr(app, item["key"], {}) for item in DATA_SCHEMA
}
project_data.update({
"version": CURRENT_VERSION,
"file_list": file_list,
"progress_states": progress_states,
"file_metadata": rel_metadata,
"file_parameters": rel_file_params,
"current_ui_params": current_params,
})
def sanitize(obj):
if isinstance(obj, Path):
return str(PurePosixPath(obj))
elif isinstance(obj, dict):
return {sanitize(k): sanitize(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [sanitize(i) for i in obj]
return obj
project_data = sanitize(project_data)
self.add_to_recent_projects(os.path.normpath(filename))
# 6. Background Saving Execution
if not onCrash:
app.saving_overlay = SavingOverlay(app)
app.saving_overlay.resize(app.size())
app.saving_overlay.show()
app.save_thread = SaveProjectThread(filename, project_data)
def _on_save_success(saved_file):
if hasattr(app, "saving_overlay"):
app.saving_overlay.close()
self.add_to_recent_projects(os.path.normpath(saved_file))
app.current_project_path = saved_file
self.reset_all_dirty_states()
if not onCrash:
QMessageBox.information(
app, "Success", f"Project saved to:\n{saved_file}"
)
def _on_save_error(error_msg):
if hasattr(app, "saving_overlay"):
app.saving_overlay.close()
if not onCrash:
QMessageBox.critical(
app, "Error", f"Failed to save project:\n{error_msg}"
)
app.save_thread.finished_signal.connect(_on_save_success)
app.save_thread.error_signal.connect(_on_save_error)
app.save_thread.start()
except Exception as e:
if not onCrash:
QMessageBox.critical(app, "Error", f"Failed to save project:\n{e}")
def update_recent_projects_menu(self):
"""Clears and rebuilds the Recent Projects submenu items."""
app = self.app
if not hasattr(app, "recent_projects_menu"):
return
app.recent_projects_menu.clear()
raw_projects = self.file_cfg.get("File", "recent_projects", fallback="")
projects = [p.strip() for p in raw_projects.split(",") if p.strip()]
if not projects:
no_recent = app.recent_projects_menu.addAction(
"No Recent Projects"
)
no_recent.setEnabled(False)
return
for i, project_path in enumerate(projects):
action = QAction(f"{i+1}: {project_path}", app)
action.setToolTip(project_path)
action.triggered.connect(
lambda checked, path=project_path: self.open_recent_project(path)
)
app.recent_projects_menu.addAction(action)
def add_to_recent_projects(self, project_path):
"""Adds a project path, moves it to the top, and hard caps at 10."""
raw_projects = self.file_cfg.get("File", "recent_projects", fallback="")
projects = [p.strip() for p in raw_projects.split(",") if p.strip()]
if project_path in projects:
projects.remove(project_path)
projects.insert(0, project_path)
projects = projects[:10] # Hard cap of 10 items
self.file_cfg.set("File", "recent_projects", ",".join(projects))
try:
with open(self.cfg_path, "w") as f:
self.file_cfg.write(f)
except Exception as e:
print(f"Warning: Could not save config history: {e}")
self.update_recent_projects_menu()
def open_recent_project(self, project_path):
"""The slot that executes when a recent project entry is clicked."""
if os.path.exists(project_path):
print(f"Opening recent project: {project_path}")
# Route project loading through ProjectManager or app's loader
if hasattr(self.app, "project_loader"):
self.app.project_loader(project_path)
else:
self.load_project(project_path)
self.add_to_recent_projects(project_path)
else:
QMessageBox.warning(
self.app,
"Project Not Found",
f"The project file could not be found:\n{project_path}",
)
# Clean out the broken path
raw_projects = self.file_cfg.get("File", "recent_projects", fallback="")
projects = [
p.strip()
for p in raw_projects.split(",")
if p.strip() and p.strip() != project_path
]
self.file_cfg.set("File", "recent_projects", ",".join(projects))
self.update_recent_projects_menu()
# =========================================================================
# Recent Files Operations
# =========================================================================
def update_recent_files_menu(self):
"""Clears and rebuilds the Recent Files submenu items."""
app = self.app
if not hasattr(app, "recent_files_menu"):
return
app.recent_files_menu.clear()
raw_files = self.file_cfg.get("File", "recent_files", fallback="")
files = [f.strip() for f in raw_files.split(",") if f.strip()]
if not files:
no_recent = app.recent_files_menu.addAction("No Recent Files")
no_recent.setEnabled(False)
return
for i, file_path in enumerate(files):
action = QAction(f"{i+1}: {file_path}", app)
action.triggered.connect(
lambda checked, path=file_path: self.open_recent_file(path)
)
app.recent_files_menu.addAction(action)
def add_to_recent_files(self, file_path):
"""Adds a path, moves it to the top, and hard caps the list at 10."""
raw_files = self.file_cfg.get("File", "recent_files", fallback="")
files = [f.strip() for f in raw_files.split(",") if f.strip()]
if file_path in files:
files.remove(file_path)
files.insert(0, file_path)
files = files[:10]
self.file_cfg.set("File", "recent_files", ",".join(files))
try:
with open(self.cfg_path, "w") as f:
self.file_cfg.write(f)
except Exception as e:
print(f"Warning: Could not save config history: {e}")
self.update_recent_files_menu()
def open_recent_file(self, file_path):
"""The slot that executes when someone clicks a recent file entry."""
if os.path.exists(file_path):
print(f"Opening recent file: {file_path}")
self._load_files_into_pipeline([os.path.normpath(file_path)])
# Refresh position to top
self.add_to_recent_files(file_path)
else:
QMessageBox.warning(
self.app,
"File Not Found",
f"The file could not be found:\n{file_path}",
)
# Clean up the broken link from history
raw_files = self.file_cfg.get("File", "recent_files", fallback="")
files = [
f.strip()
for f in raw_files.split(",")
if f.strip() and f.strip() != file_path
]
self.file_cfg.set("File", "recent_files", ",".join(files))
self.update_recent_files_menu()
# =========================================================================
# Baseline Synchronization & Dirty Checks
# =========================================================================
def sync_metadata_baseline(self):
"""Captures current file_metadata state as baseline."""
self.app.saved_file_metadata = copy.deepcopy(getattr(self.app, "file_metadata", {}))
def is_metadata_dirty(self):
"""Returns True if file metadata has been modified relative to saved baseline."""
current_meta = getattr(self.app, "file_metadata", {})
saved_meta = getattr(self.app, "saved_file_metadata", {})
if current_meta != saved_meta:
return True
if (
hasattr(self.app, "current_file")
and self.app.current_file
and hasattr(self.app, "meta_fields")
):
active_saved = saved_meta.get(self.app.current_file, {})
for key, field in getattr(self.app, "meta_fields", {}).items():
if field.text().strip() != active_saved.get(key, "").strip():
return True
return False
def reset_all_dirty_states(self):
"""Resets parameter, file, and metadata baselines after load/save."""
# 1. Sync file list baseline
self.app.saved_selected_paths = copy.deepcopy(getattr(self.app, "selected_paths", []))
self.app.files_are_dirty = False
# 2. Sync metadata baseline
self.sync_metadata_baseline()
# 3. Sync parameter baselines
for section_widget in getattr(self.app, "param_sections", []):
if hasattr(section_widget, "save_current_as_baseline"):
section_widget.save_current_as_baseline()
# 4. Clear dirty status & update UI title
self.app.is_saved = True
if hasattr(self.app, "update_window_title"):
self.app.update_window_title()
def _get_bids_demographics(snirf_path: str) -> dict[str, str]:
"""Traverses the path of a SNIRF file to extract age/sex/hand from BIDS TSV files.
'hand' is only included if a value is present and isn't 'n/a' (case-insensitive) -
many datasets leave it unset/inapplicable, so surfacing 'n/a' explicitly just adds noise.
"""
path = Path(snirf_path)
fields = ["age", "sex", "hand"]
# Extract sub-XX and ses-YY labels from the path
sub_id = next((part for part in path.parts if part.startswith("sub-")), None)
ses_id = next((part for part in path.parts if part.startswith("ses-")), None)
if not sub_id:
return {}
def _row_to_dict(row) -> dict[str, str]:
result = {}
for field in fields:
if field not in row:
continue
val = row[field]
if pd.isna(val):
continue
val_str = str(val).strip()
if field == "hand" and val_str.lower() in ("n/a", "na", ""):
continue
result[field] = val_str
return result
# 1. Look for sub-<id>/sub-<id>_sessions.tsv
sub_dir = next((p for p in path.parents if p.name == sub_id), None)
if sub_dir and ses_id:
sessions_tsv = sub_dir / f"{sub_id}_sessions.tsv"
if sessions_tsv.exists():
try:
df = pd.read_csv(sessions_tsv, sep="\t")
matching = df[df["session_id"].astype(str).str.replace("ses-", "") == ses_id.replace("ses-", "")]
if not matching.empty:
result = _row_to_dict(matching.iloc[0])
if result:
return result
except Exception:
pass
# 2. Fallback: Check dataset root participants.tsv
bids_root = sub_dir.parent if sub_dir else None
if bids_root:
participants_tsv = bids_root / "participants.tsv"
if participants_tsv.exists():
try:
df = pd.read_csv(participants_tsv, sep="\t")
matching = df[df["participant_id"].astype(str).str.replace("sub-", "") == sub_id.replace("sub-", "")]
if not matching.empty:
result = _row_to_dict(matching.iloc[0])
if result:
return result
except Exception:
pass
return {}
def extract_metadata_worker(file_name):
"""Runs in the separate worker process. Returns a clean dict."""
# 1. Use preload=False! We only need metadata.
raw = None
try:
raw = read_raw_snirf(file_name, preload=False, verbose="ERROR")
snirf_info = {}
# 2. Measurement date
snirf_info['Measurement Date'] = str(raw.info.get('meas_date'))
# 3. Short Channels
try:
short_chans = get_short_channels(raw, max_dist=0.015)
names = list(short_chans.ch_names)
snirf_info['Short Channels'] = f"Likely - {names}"
total_chans = len(raw.ch_names)
pct_short = (len(names) / total_chans * 100) if total_chans else 0
if pct_short > 25:
snirf_info['Short Channels'] += "\n There are a lot of short channels. Optode distances are likely incorrect!"
except:
snirf_info['Short Channels'] = "Unlikely"
# 4. Distances
dist_vals = source_detector_distances(raw.info)
snirf_info['Source-Detector Distances'] = [
f"{name}: {d:.4f} m" for name, d in zip(raw.info['ch_names'], dist_vals)
]
# 5. Digitization
dig = raw.info.get('dig', None)
if dig is not None:
snirf_info['Digitization Points'] = [
f"Kind: {p['kind']}, ID: {p['ident']}, Coord: {p['r']}" for p in dig
]
else:
snirf_info['Digitization Points'] = "Not found"
# 6. Annotations (using our copy-to-string trick)
if raw.annotations is not None and len(raw.annotations) > 0:
snirf_info['Annotations'] = [
f"Onset: {o:.2f}s, Duration: {d:.2f}s, Description: {str(desc)}"
for o, d, desc in zip(raw.annotations.onset, raw.annotations.duration, raw.annotations.description)
]
else:
snirf_info['Annotations'] = "No annotations found"
demographics = _get_bids_demographics(file_name)
if "age" in demographics:
snirf_info["BIDS - Age"] = demographics["age"]
if "sex" in demographics:
snirf_info["BIDS - Sex"] = demographics["sex"]
if "hand" in demographics:
snirf_info["BIDS - Handedness"] = demographics["hand"]
return snirf_info
except Exception as e:
print(f"Worker safely caught failure on {file_name}: {str(e)}")
return {'status': 'error', 'reason': str(e)}
finally:
if raw is not None:
try:
raw.close()
except:
pass
+423 -297
View File
@@ -7,18 +7,17 @@ License: GPL-3.0
""" """
import os import os
import json from copy import deepcopy
from pathlib import Path
from typing import Sequence, Any from typing import Sequence, Any
import pandas as pd 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, QVBoxLayout, QWidget, QFrame, QSpinBox
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 QStandardItemModel, QStandardItem, QPixmap, QIntValidator, QDoubleValidator from PySide6.QtGui import QStandardItemModel, QStandardItem, QPixmap, QIntValidator, QDoubleValidator
from PySide6.QtCore import QEvent, QSize, Qt from PySide6.QtCore import QEvent, QPoint, QSize, QTimer, Qt, Signal
from src.shared.shareddata import APP_NAME
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):
@@ -51,7 +50,7 @@ class ClickableLabel(QLabel):
def mousePressEvent(self, event): def mousePressEvent(self, event):
viewer = QWidget() viewer = QWidget()
viewer.setWindowTitle("Expanded View") viewer.setWindowTitle(f"Expanded View" - {APP_NAME.upper()})
layout = QVBoxLayout(viewer) layout = QVBoxLayout(viewer)
label = QLabel() label = QLabel()
label.setPixmap(self._pixmap_full) label.setPixmap(self._pixmap_full)
@@ -85,7 +84,7 @@ class ParameterInputDialog(QDialog):
} }
""" """
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Input Parameters") self.setWindowTitle(f"Input Parameters - {APP_NAME.upper()}")
self.params_dict = params_dict self.params_dict = params_dict
self.inputs = {} # {(idx, param_key): QLineEdit} self.inputs = {} # {(idx, param_key): QLineEdit}
@@ -218,7 +217,7 @@ class ParameterInputDialog(QDialog):
def _show_error(self, message): def _show_error(self, message):
error_box = QMessageBox(self) error_box = QMessageBox(self)
error_box.setIcon(QMessageBox.Critical) error_box.setIcon(QMessageBox.Critical)
error_box.setWindowTitle("Input Error") error_box.setWindowTitle(f"Input Error - {APP_NAME.upper()}")
error_box.setText(message) error_box.setText(message)
error_box.exec_() error_box.exec_()
@@ -244,9 +243,6 @@ class FullClickComboBox(QComboBox):
from PySide6.QtWidgets import QWidget, QHBoxLayout, QLineEdit, QPushButton, QFileDialog
from PySide6.QtCore import Signal
class FilePickerWidget(QWidget): class FilePickerWidget(QWidget):
# This custom signal lets our container mimic a standard QLineEdit # This custom signal lets our container mimic a standard QLineEdit
textChanged = Signal(str) textChanged = Signal(str)
@@ -294,9 +290,7 @@ class FilePickerWidget(QWidget):
class ParamSection(QWidget): class ParamSection(QWidget):
""" """
A widget section that dynamically creates labeled input fields from parameter metadata. Args:
Args:
section_data (dict): Dictionary containing section title and list of parameter info. section_data (dict): Dictionary containing section title and list of parameter info.
Expected format: Expected format:
{ {
@@ -312,6 +306,8 @@ class ParamSection(QWidget):
] ]
} }
""" """
dirty_state_changed = Signal(bool)
def __init__(self, section_data, global_widgets): def __init__(self, section_data, global_widgets):
super().__init__() super().__init__()
@@ -323,6 +319,8 @@ class ParamSection(QWidget):
self.param_rows = [] self.param_rows = []
self.header_widgets = [] self.header_widgets = []
self.dirty_params = {}
self._updating_checkstates = False
# Title label # Title label
title_label = QLabel(section_data["title"]) title_label = QLabel(section_data["title"])
@@ -339,30 +337,23 @@ class ParamSection(QWidget):
for param in section_data["params"]: for param in section_data["params"]:
h_layout = QHBoxLayout() h_layout = QHBoxLayout()
is_advanced = param.get("advanced", False) is_advanced = param.get("advanced", False)
param_name = param["name"]
help_text = param.get("help", "")
# Build label text and tooltips # Build label text and tooltips
param_name = param["name"] label_text = f"⚠️ {param_name}" if is_advanced else param_name
if is_advanced:
label_text = f"⚠️ {param_name}"
else:
label_text = param_name
label = QLabel(label_text) label = QLabel(label_text)
# Set hover tooltip
if is_advanced: if is_advanced:
label.setToolTip(f"ADVANCED: {param.get("help", "")}") label.setToolTip(f"ADVANCED: {help_text}")
else: else:
label.setToolTip(param.get("help", "")) label.setToolTip(help_text)
help_text = param.get("help", "")
help_btn = QPushButton("?") help_btn = QPushButton("?")
help_btn.setFixedWidth(25) help_btn.setFixedWidth(25)
help_btn.setToolTip(help_text) help_btn.setToolTip(help_text)
help_btn.clicked.connect(lambda _, text=help_text: self.show_help_popup(text)) help_btn.clicked.connect(lambda _, text=help_text, name=param_name: self.show_help_popup(name, text))
h_layout.addWidget(help_btn) h_layout.addWidget(help_btn)
h_layout.addWidget(label) h_layout.addWidget(label)
@@ -370,220 +361,331 @@ class ParamSection(QWidget):
h_layout.setStretch(1, 6) h_layout.setStretch(1, 6)
default_val = param["default"] default_val = param["default"]
param_type = param["type"]
type_str = str(param_type).lower()
# Create input widget based on type # Create input widget based on type
if param["type"] == bool: if param_type == bool or "bool" in type_str:
widget = QComboBox() widget = QComboBox()
widget.addItems(["True", "False"]) widget.addItems(["True", "False"])
widget.setCurrentText(str(default_val)) widget.setCurrentText(str(default_val))
widget.currentTextChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val)) 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:
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 val, p=param["name"]: self.check_if_changed(p, val)) widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
elif param["type"] == float:
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 val, p=param["name"]: self.check_if_changed(p, val)) widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
elif param["type"] == list:
elif param_type == list or "list" in type_str:
options = param.get("options", [])
if param.get("exclusive", True): if param.get("exclusive", True):
widget = QComboBox() widget = QComboBox()
widget.addItems(param.get("options", [])) widget.addItems(options)
widget.setCurrentText(str(default_val)) initial_text = default_val[0] if (isinstance(default_val, list) and len(default_val) > 0) else str(default_val)
widget.currentTextChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val)) widget.setCurrentText(initial_text)
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)
else: else:
widget = self._create_multiselect_dropdown(None) widget = self._create_multiselect_dropdown(options, default_val=default_val, param_name=param_name)
elif param["type"] == range:
elif param_type == range or "range" in type_str:
widget = QSpinBox() widget = QSpinBox()
widget.setRange(0, 999) #NOTE: will this be a high enough limit? widget.setRange(0, 999)
# If default is "None" or range(15), handle it gracefully:
if isinstance(default_val, range): if isinstance(default_val, range):
widget.setValue(default_val.stop) widget.setValue(default_val.stop)
elif str(default_val).isdigit(): elif str(default_val).isdigit():
widget.setValue(int(default_val)) widget.setValue(int(default_val))
else: else:
widget.setValue(15) # Default fallback widget.setValue(15)
widget.valueChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val)) widget.valueChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
elif param["type"] == "json_file":
# Create our custom dual-element compound layout widget 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)")
# Your existing tracking signals connect seamlessly! widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
widget.textChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val))
else: else:
widget = QLineEdit() widget = QLineEdit()
widget.setText(str(default_val)) widget.setText(str(default_val))
widget.textChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val)) widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
self.widgets[param_name] = {
"widget": widget,
"label": label,
"default": default_val,
"saved_value": deepcopy(default_val),
"type": param_type,
"h_layout": h_layout
}
if "depends_on" in param: if "depends_on" in param:
deps_list = [] deps_list = param["depends_on"] if isinstance(param["depends_on"], list) else [{
"parent_name": param["depends_on"],
if isinstance(param["depends_on"], list): "depends_value": param.get("depends_value", "True")
deps_list = param["depends_on"] }]
else:
deps_list.append({
"parent_name": param["depends_on"],
"depends_value": param.get("depends_value", "True")
})
self.dependencies.append({ self.dependencies.append({
"child_name": param["name"], "child_name": param_name,
"conditions": deps_list "conditions": deps_list
}) })
widget.setToolTip(help_text) widget.setToolTip(help_text)
h_layout.addWidget(widget) h_layout.addWidget(widget)
h_layout.setStretch(2, 3) h_layout.setStretch(2, 3)
layout.addLayout(h_layout) layout.addLayout(h_layout)
self.widgets[param["name"]] = { self.param_rows.append(([help_btn, label, widget], h_layout, is_advanced))
"widget": widget,
"label": label,
"default": default_val,
"type": param["type"],
"h_layout": h_layout
}
row_widgets = [help_btn, label, widget]
is_advanced = param.get("advanced", False)
self.param_rows.append((row_widgets, h_layout, is_advanced))
self.update_dependencies() self.update_dependencies()
def set_advanced_visible(self, show_advanced: bool): def is_different(self, val_a, val_b, param_type=None):
"""Shows/hides advanced parameters and adjusts header visibility dynamically.""" """Compares two parameter values to determine if they differ."""
has_visible_rows = False type_str = str(param_type).lower()
for row_widgets, h_layout, is_advanced in self.param_rows: # BOOL comparison
visible = show_advanced or not is_advanced if param_type == bool or "bool" in type_str:
def to_bool(v):
# Toggle visibility of all child widgets in the row if isinstance(v, bool):
for w in row_widgets: return v
w.setVisible(visible) return str(v).strip().lower() in ("true", "1", "yes")
return to_bool(val_a) != to_bool(val_b)
if visible:
has_visible_rows = True
# Hide or show section header title and horizontal rule if section is completely empty # LIST comparison
for hw in self.header_widgets: if param_type == list or "list" in type_str:
hw.setVisible(has_visible_rows) def to_list(v):
if v is None:
self.setVisible(has_visible_rows) return []
if isinstance(v, list):
res = []
for item in v:
res.extend(to_list(item))
return res
if isinstance(v, str):
s = v.strip()
if s.startswith('[') and s.endswith(']'):
s = s[1:-1]
items = [x.strip().strip("'\"") for x in s.split(',') if x.strip().strip("'\"")]
return [i for i in items if i != "<None Selected>"]
return [str(v).strip()]
return sorted(to_list(val_a)) != sorted(to_list(val_b))
def has_any_changes(self): # RANGE comparison
"""Returns True if any parameter in this section differs from its default.""" if param_type == range or "range" in type_str:
for name, info in self.widgets.items(): def to_range_stop(v):
default = info["default"] if isinstance(v, range):
return v.stop
current_val = self.get_param_values().get(name) try:
return int(v)
if str(current_val) != str(default): except (ValueError, TypeError):
return True return 0
return False return to_range_stop(val_a) != to_range_stop(val_b)
def check_if_changed(self, param_name, current_value): # INT / FLOAT comparison
"""Toggles bold font on the label if the value differs from default.""" if param_type in (int, float) or "int" in type_str or "float" in type_str:
try:
if val_a is not None and val_b is not None and str(val_a).strip() != "" and str(val_b).strip() != "":
return float(val_a) != float(val_b)
except (ValueError, TypeError):
pass
# String / Default Fallback
str_a = "" if val_a is None else str(val_a).strip()
str_b = "" if val_b is None else str(val_b).strip()
return str_a != str_b
def check_if_changed(self, param_name, current_value=None, *args, **kwargs):
"""
Toggles bold font/blue color on the label if the value differs from default.
Always pulls current UI value from get_param_values() if not explicitly passed.
"""
info = self.widgets.get(param_name) info = self.widgets.get(param_name)
if not info: if not info:
return return
label = info["label"] label = info["label"]
default = info["default"] default = info["default"]
saved = info.get("saved_value", default)
is_changed = False param_type = info["type"]
if info["type"] == list:
# If it's an exclusive ComboBox, current_value is a string.
# We wrap it in a list to compare it to the default list.
if isinstance(current_value, str):
normalized_current = [current_value]
else:
normalized_current = current_value # Already a list from multi-select
# Ensure default is a list for comparison
normalized_default = default if isinstance(default, list) else [default]
# Use sorted to ensure order doesn't matter
is_changed = sorted(normalized_current) != sorted(normalized_default)
# 2. Handle Range (SpinBox) if current_value is None:
elif info["type"] == range: current_value = self.get_param_values().get(param_name)
ref = default.stop if isinstance(default, range) else default
try:
is_changed = int(current_value) != int(ref)
except (ValueError, TypeError):
is_changed = True
# 3. Standard Comparison (bool, int, float, str) # 1. COLORING LOGIC (Compares against 'default')
else: if self.is_different(current_value, default, param_type):
is_changed = str(current_value) != str(default) label.setStyleSheet("color: #3498db; font-weight: bold;")
if is_changed:
label.setStyleSheet("color: #3498db; font-weight: bold;") # Nice Blue
else: else:
label.setStyleSheet("") label.setStyleSheet("")
# 2. DIRTY STATE LOGIC (Compares against 'saved_value')
self.dirty_params[param_name] = self.is_different(current_value, saved, param_type)
self.dirty_state_changed.emit(any(self.dirty_params.values()))
def reset_to_defaults(self):
"""
Resets all UI input widgets back to their defined default values in SECTIONS,
resets saved baseline states, updates label styling (removes blue highlight),
and updates dependent widget visibility/enablement.
"""
for param_name, info in self.widgets.items():
widget = info["widget"]
default_val = info["default"]
param_type = info["type"]
type_str = str(param_type).lower()
# Update saved baseline to match default
info["saved_value"] = deepcopy(default_val)
# Reset Widget Values
if param_type == bool or "bool" in type_str:
if isinstance(widget, QComboBox):
widget.setCurrentText(str(default_val))
elif param_type in (int, float, str) or "int" in type_str or "float" in type_str or "str" in type_str:
if isinstance(widget, QLineEdit):
widget.setText("" if default_val is None else str(default_val))
elif param_type == list or "list" in type_str:
if isinstance(widget, FullClickComboBox):
defaults = default_val if isinstance(default_val, list) else ([default_val] if default_val else [])
model = widget.model()
self._updating_checkstates = True
normal_items = []
for i in range(2, model.rowCount()):
item = model.item(i)
normal_items.append(item)
state = Qt.Checked if item.text() in defaults else Qt.Unchecked
item.setCheckState(state)
# Sync toggle select all item
toggle_item = model.item(1)
if toggle_item and normal_items:
all_checked = all(i.checkState() == Qt.Checked for i in normal_items)
toggle_item.setCheckState(Qt.Checked if all_checked else Qt.Unchecked)
self._updating_checkstates = False
self.update_dropdown_label(param_name)
elif isinstance(widget, QComboBox):
initial_text = default_val[0] if (isinstance(default_val, list) and len(default_val) > 0) else str(default_val)
widget.setCurrentText(initial_text)
elif param_type == range or "range" in type_str:
if isinstance(widget, QSpinBox):
if isinstance(default_val, range):
widget.setValue(default_val.stop)
elif str(default_val).isdigit():
widget.setValue(int(default_val))
else:
widget.setValue(15)
elif param_type == "json_file":
if hasattr(widget, "setText"):
widget.setText("" if default_val is None else str(default_val))
# Re-evaluate visual styling and dirty flags
self.check_if_changed(param_name)
self.dirty_params.clear()
self.dirty_state_changed.emit(False)
self.update_dependencies()
# Aliases for clear button calls
def clear(self):
self.reset_to_defaults()
def reset(self):
self.reset_to_defaults()
def reset_baseline_to_default(self):
"""Resets baseline saved values back to defaults and re-checks visual styling."""
for name, info in self.widgets.items():
info["saved_value"] = deepcopy(info["default"])
self.check_if_changed(name)
self.dirty_params.clear()
self.dirty_state_changed.emit(False)
def save_current_as_baseline(self):
"""Call this when a project is saved to lock current UI state as saved_value."""
current_values = self.get_param_values()
for name, info in self.widgets.items():
if name in current_values:
info["saved_value"] = deepcopy(current_values[name])
self.check_if_changed(name)
self.dirty_params.clear()
self.dirty_state_changed.emit(False)
def set_advanced_visible(self, show_advanced: bool):
has_visible_rows = False
for row_widgets, _, is_advanced in self.param_rows:
visible = show_advanced or not is_advanced
for w in row_widgets:
w.setVisible(visible)
if visible:
has_visible_rows = True
for hw in self.header_widgets:
hw.setVisible(has_visible_rows)
self.setVisible(has_visible_rows)
def has_any_changes(self):
current_values = self.get_param_values()
for name, info in self.widgets.items():
if self.is_different(current_values.get(name), info["default"], info["type"]):
return True
return False
def notify_global_update(self): def notify_global_update(self):
""" parent = self.parent()
Since dependencies can cross sections, we need to tell if parent:
all sections to refresh their enabled/disabled states. for section in parent.findChildren(ParamSection):
""" section.update_dependencies()
# If you have a reference to the parent container, call its update.
# Otherwise, you can iterate through the known param_sections:
for section in self.parent().findChildren(ParamSection):
section.update_dependencies()
def update_dependencies(self): def update_dependencies(self):
"""Disables/Enables widgets based on parent selection values."""
for dep in self.dependencies: for dep in self.dependencies:
child_info = self.widgets.get(dep["child_name"]) child_info = self.widgets.get(dep["child_name"])
if not child_info: if not child_info:
continue continue
# Default to enabled until a condition fails
all_conditions_met = True all_conditions_met = True
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_value = 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
break break
parent_widget = parent_info["widget"] p_widget = parent_info["widget"]
if isinstance(p_widget, QComboBox):
# Extract current parent value based on widget type curr_val = p_widget.currentText()
if isinstance(parent_widget, QComboBox): elif isinstance(p_widget, QLineEdit):
current_parent_value = parent_widget.currentText() curr_val = p_widget.text()
elif isinstance(parent_widget, QLineEdit): elif isinstance(p_widget, QSpinBox):
current_parent_value = parent_widget.text() curr_val = str(p_widget.value())
elif isinstance(parent_widget, QSpinBox):
current_parent_value = str(parent_widget.value())
else: else:
current_parent_value = str(parent_widget) curr_val = str(p_widget)
# If any condition fails, flag as false if curr_val != required_val:
if current_parent_value != required_value:
all_conditions_met = False all_conditions_met = False
break break
# Toggle the entire row (Button, Label, and Input)
h_layout = child_info["h_layout"] h_layout = child_info["h_layout"]
for i in range(h_layout.count()): for i in range(h_layout.count()):
item = h_layout.itemAt(i).widget() item = h_layout.itemAt(i).widget()
if item: if item:
item.setEnabled(all_conditions_met) item.setEnabled(all_conditions_met)
def _create_multiselect_dropdown(self, items): def _create_multiselect_dropdown(self, items, default_val=None, param_name=""):
combo = FullClickComboBox() combo = FullClickComboBox()
combo.setView(QListView()) combo.setView(QListView())
model = QStandardItemModel() model = QStandardItemModel()
@@ -601,16 +703,18 @@ class ParamSection(QWidget):
toggle_item.setData(Qt.Unchecked, Qt.CheckStateRole) toggle_item.setData(Qt.Unchecked, Qt.CheckStateRole)
model.appendRow(toggle_item) model.appendRow(toggle_item)
if items is not None: defaults = default_val if isinstance(default_val, list) else ([default_val] if default_val else [])
for item in items:
standard_item = QStandardItem(item) if items:
for item_text in items:
standard_item = QStandardItem(item_text)
standard_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) standard_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
standard_item.setData(Qt.Unchecked, Qt.CheckStateRole) initial_state = Qt.Checked if item_text in defaults else Qt.Unchecked
standard_item.setData(initial_state, Qt.CheckStateRole)
model.appendRow(standard_item) model.appendRow(standard_item)
combo.setInsertPolicy(QComboBox.NoInsert) combo.setInsertPolicy(QComboBox.NoInsert)
def on_view_clicked(index): def on_view_clicked(index):
item = model.itemFromIndex(index) item = model.itemFromIndex(index)
if item.isCheckable(): if item.isCheckable():
@@ -619,171 +723,70 @@ class ParamSection(QWidget):
combo.view().pressed.connect(on_view_clicked) combo.view().pressed.connect(on_view_clicked)
self._updating_checkstates = False
def on_item_changed(item): def on_item_changed(item):
if self._updating_checkstates: if self._updating_checkstates:
return return
self._updating_checkstates = True self._updating_checkstates = True
normal_items = [model.item(i) for i in range(2, model.rowCount())] # skip dummy and toggle normal_items = [model.item(i) for i in range(2, model.rowCount())]
if item == toggle_item: if item == toggle_item:
all_checked = all(i.checkState() == Qt.Checked for i in normal_items) all_checked = all(i.checkState() == Qt.Checked for i in normal_items)
if all_checked: target_state = Qt.Unchecked if all_checked else Qt.Checked
for i in normal_items: for i in normal_items:
i.setCheckState(Qt.Unchecked) i.setCheckState(target_state)
toggle_item.setCheckState(Qt.Unchecked) toggle_item.setCheckState(target_state)
else: elif item != dummy_item:
for i in normal_items:
i.setCheckState(Qt.Checked)
toggle_item.setCheckState(Qt.Checked)
elif item == dummy_item:
pass
else:
# When normal items change, update toggle item
all_checked = all(i.checkState() == Qt.Checked for i in normal_items) all_checked = all(i.checkState() == Qt.Checked for i in normal_items)
toggle_item.setCheckState(Qt.Checked if all_checked else Qt.Unchecked) toggle_item.setCheckState(Qt.Checked if all_checked else Qt.Unchecked)
self._updating_checkstates = False self._updating_checkstates = False
for param_name, info in self.widgets.items(): if param_name:
if info["widget"] == combo: self.update_dropdown_label(param_name)
self.update_dropdown_label(param_name) self.check_if_changed(param_name)
break
model.itemChanged.connect(on_item_changed) model.itemChanged.connect(on_item_changed)
combo.setInsertPolicy(QComboBox.NoInsert)
return combo return combo
def show_help_popup(self, text): def show_help_popup(self, param_name, text):
msg = QMessageBox(self) msg = QMessageBox(self)
msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}") msg.setWindowTitle(f"Parameter Info - {param_name} - {APP_NAME.upper()}")
msg.setText(text) msg.setText(text)
msg.exec() msg.exec()
def get_param_values(self): def get_param_values(self):
values = {} values = {}
for name, info in self.widgets.items(): for name, info in self.widgets.items():
widget = info["widget"] widget = info["widget"]
expected_type = info["type"] expected_type = info["type"]
type_str = str(expected_type).lower()
if name == "SHORT_CHANNEL_REGRESSION": if expected_type == bool or "bool" in type_str:
# If the widget is disabled (greyed out), force False
if not widget.isEnabled():
values[name] = False
continue
if expected_type == bool:
values[name] = widget.currentText() == "True" values[name] = widget.currentText() == "True"
elif expected_type == list: elif expected_type == list or "list" in type_str:
if isinstance(widget, FullClickComboBox): if isinstance(widget, FullClickComboBox):
values[name] = [x.strip() for x in widget.lineEdit().text().split(",") if x.strip()] values[name] = [x.strip() for x in widget.lineEdit().text().split(",") if x.strip() and x.strip() != "<None Selected>"]
elif isinstance(widget, QComboBox): elif isinstance(widget, QComboBox):
values[name] = widget.currentText() values[name] = widget.currentText()
elif expected_type == range: elif expected_type == range or "range" in type_str:
if isinstance(widget, QSpinBox): values[name] = range(widget.value()) if isinstance(widget, QSpinBox) else range(15)
# Convert the integer N into range(N)
values[name] = range(widget.value())
else:
values[name] = range(15) # Fallback
elif expected_type == "json_file": elif expected_type == "json_file":
values[name] = widget.text() values[name] = widget.text()
else: else:
raw_text = widget.text() raw_text = widget.text()
try: try:
if expected_type == int: if expected_type == int or "int" in type_str:
values[name] = int(raw_text) values[name] = int(raw_text)
elif expected_type == float: elif expected_type == float or "float" in type_str:
values[name] = float(raw_text) values[name] = float(raw_text)
elif expected_type == str:
values[name] = raw_text
else: else:
values[name] = raw_text # Fallback values[name] = raw_text
except Exception as e: except Exception:
raise ValueError(f"Invalid value for {name}: {raw_text}") from e values[name] = raw_text
return values return values
def update_dropdown_items(self, param_name, new_items):
"""
Updates the items in a multi-select dropdown parameter field.
Args:
param_name (str): The parameter name (must match one in self.widgets).
new_items (list): The new items to populate in the dropdown.
"""
widget_info = self.widgets.get(param_name)
#print("[ParamSection] Current widget keys:", list(self.widgets.keys()))
if not widget_info:
print(f"[ParamSection] No widget found for param '{param_name}'")
return
widget = widget_info["widget"]
if not isinstance(widget, FullClickComboBox):
print(f"[ParamSection] Widget for param '{param_name}' is not a FullClickComboBox")
return
# Replace the model on the existing widget
new_model = QStandardItemModel()
dummy_item = QStandardItem("<None Selected>")
dummy_item.setFlags(Qt.ItemIsEnabled)
new_model.appendRow(dummy_item)
toggle_item = QStandardItem("Toggle Select All")
toggle_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
toggle_item.setData(Qt.Unchecked, Qt.CheckStateRole)
new_model.appendRow(toggle_item)
for item_text in new_items:
item = QStandardItem(item_text)
item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
item.setData(Qt.Unchecked, Qt.CheckStateRole)
new_model.appendRow(item)
widget.setModel(new_model)
widget.setView(QListView()) # Reset view to refresh properly
def on_view_clicked(index):
item = new_model.itemFromIndex(index)
if item.isCheckable():
new_state = Qt.Checked if item.checkState() == Qt.Unchecked else Qt.Unchecked
item.setCheckState(new_state)
widget.view().pressed.connect(on_view_clicked)
def on_item_changed(item):
if getattr(self, "_updating_checkstates", False):
return
self._updating_checkstates = True
normal_items = [new_model.item(i) for i in range(2, new_model.rowCount())]
if item == toggle_item:
all_checked = all(i.checkState() == Qt.Checked for i in normal_items)
for i in normal_items:
i.setCheckState(Qt.Unchecked if all_checked else Qt.Checked)
toggle_item.setCheckState(Qt.Unchecked if all_checked else Qt.Checked)
else:
all_checked = all(i.checkState() == Qt.Checked for i in normal_items)
toggle_item.setCheckState(Qt.Checked if all_checked else Qt.Unchecked)
self._updating_checkstates = False
for param_name, info in self.widgets.items():
if info["widget"] == widget:
self.update_dropdown_label(param_name)
break
new_model.itemChanged.connect(on_item_changed)
widget.lineEdit().setText("<None Selected>")
def _get_checked_items(self, combo): def _get_checked_items(self, combo):
checked = [] checked = []
model = combo.model() model = combo.model()
@@ -798,20 +801,14 @@ class ParamSection(QWidget):
def update_dropdown_label(self, param_name): def update_dropdown_label(self, param_name):
widget_info = self.widgets.get(param_name) widget_info = self.widgets.get(param_name)
if not widget_info: if not widget_info:
print(f"[ParamSection] No widget found for param '{param_name}'")
return return
widget = widget_info["widget"] widget = widget_info["widget"]
if not isinstance(widget, FullClickComboBox): if not isinstance(widget, FullClickComboBox):
print(f"[ParamSection] Widget for param '{param_name}' is not a FullClickComboBox")
return return
selected = self._get_checked_items(widget) selected = self._get_checked_items(widget)
if not selected: widget.lineEdit().setText(", ".join(selected) if selected else "<None Selected>")
widget.lineEdit().setText("<None Selected>")
else:
# You can customize how you display selected items here:
widget.lineEdit().setText(", ".join(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()]
@@ -860,6 +857,135 @@ class ParamSection(QWidget):
# self.update_dropdown_items("REMOVE_EVENTS", sorted(list(common))) # self.update_dropdown_items("REMOVE_EVENTS", sorted(list(common)))
class ProgressBubble(QWidget):
"""
A clickable widget displaying a progress bar made of colored rectangles and a label.
Args:
display_name (str): Text to display above the progress bar.
file_path (str): Associated file path stored with the bubble.
"""
clicked = Signal(object)
rightClicked = Signal(object, QPoint)
def __init__(self, display_name, file_path):
super().__init__()
self.layout = QVBoxLayout()
self.label = QLabel(display_name)
self.loading_timer = QTimer(self)
self.loading_timer.timeout.connect(self._rotate_spinner)
self.spinner_frames = ["", "", "", ""] #cute
self.spinner_idx = 0
self.is_loading = False
self.base_text = display_name
self.status_icon = ""
self.suffix_text = ""
self.label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.label.setStyleSheet("""
QLabel {
border: 1px solid #888;
border-radius: 10px;
padding: 8px 12px;
background-color: #e0f0ff;
}
""")
self.progress_layout = QHBoxLayout()
self.rects = []
for i in range(28):
rect = QFrame()
rect.setFixedSize(10, 18)
rect.setStyleSheet("background-color: white; border: 1px solid gray;")
stage_name = PIPELINE_STAGES[i]
rect.setToolTip(f"Stage {i + 1}: {stage_name}")
self.progress_layout.addWidget(rect)
self.rects.append(rect)
self.layout.addWidget(self.label)
self.layout.addLayout(self.progress_layout)
self.setLayout(self.layout)
# Store the file path
self.file_path = os.path.normpath(file_path)
self.current_step = 0
# Make the bubble appear to the user as clickable
self.setCursor(Qt.CursorShape.PointingHandCursor)
# Resize policy to make bubbles responsive
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
def _update_label_text(self):
"""Combines base text, green checkmark/spinner, and metadata into one display label."""
text = self.base_text
if self.status_icon:
text += f" {self.status_icon}"
if self.suffix_text:
text += f" ({self.suffix_text})"
self.label.setText(text)
def set_loading_state(self, loading=True):
self.is_loading = loading
if loading:
self.loading_timer.start(150) # Rotate every 150ms
else:
self.loading_timer.stop()
# Transition to a green checkmark
self.status_icon = "<span style='color: green;'>✔</span>"
self._update_label_text()
def setSuffixText(self, suffix):
"""Updates the metadata text portion without destroying the checkmark."""
self.suffix_text = suffix if suffix else ""
self._update_label_text()
def update_progress(self, step_index, active=True):
self.current_step = step_index
for i, rect in enumerate(self.rects):
if i < step_index:
rect.setStyleSheet("background-color: green; border: 1px solid gray;")
elif i == step_index:
color = "yellow" if active else "white"
rect.setStyleSheet(f"background-color: {color}; border: 1px solid gray;")
else:
rect.setStyleSheet("background-color: white; border: 1px solid gray;")
def mark_cancelled(self):
for i, rect in enumerate(self.rects):
if i < self.current_step:
rect.setStyleSheet("background-color: green; border: 1px solid gray;")
elif i == self.current_step:
rect.setStyleSheet("background-color: red; border: 1px solid gray;")
else:
rect.setStyleSheet("background-color: white; border: 1px solid gray;")
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self.clicked.emit(self)
elif event.button() == Qt.MouseButton.RightButton:
self.rightClicked.emit(self, event.globalPosition().toPoint())
super().mousePressEvent(event)
def _rotate_spinner(self):
frame = self.spinner_frames[self.spinner_idx % len(self.spinner_frames)]
# Using HTML in setText allows us to style the spinner specifically
self.status_icon = f"<span style='color: #555;'>{frame}</span>"
self.spinner_idx += 1
self._update_label_text()
class FlaresBaseWidget(QWidget): class FlaresBaseWidget(QWidget):
def __init__(self, caller): def __init__(self, caller):
super().__init__() super().__init__()
+15 -1
View File
@@ -13,7 +13,7 @@ import sys
import platform import platform
CURRENT_VERSION = "1.5.2" CURRENT_VERSION = "1.6.0"
APP_NAME = "flares" APP_NAME = "flares"
APP_NAME_EXPANDED = "fNIRS Lightweight Analysis, Research, & Evaluation Suite" APP_NAME_EXPANDED = "fNIRS Lightweight Analysis, Research, & Evaluation Suite"
API_URL = f"https://git.research.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases" API_URL = f"https://git.research.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases"
@@ -55,6 +55,20 @@ PIPELINE_STAGES = [
] ]
DATA_SCHEMA = [
{"key": "raw_haemo_dict", "help": "Dict[file_path, MNE RawArray]: Haemodynamic raw data"},
{"key": "epochs_dict", "help": "Dict[file_path, MNE Epochs]: Time-locked epoch data"},
{"key": "cha_dict", "help": "Dict[file_path, DataFrame]: Channel analysis results"},
{"key": "df_ind_dict", "help": "Dict[file_path, DataFrame]: Individual-level data/ROI results"},
{"key": "design_matrix_dict", "help": "Dict[file_path, DataFrame]: GLM design matrices"},
{"key": "config_dict", "help": "Dict[file_path, dict]: Processing configuration parameters"},
{"key": "fig_bytes_dict", "help": "Dict[file_path, dict]: Serialized figure data"},
{"key": "contrast_results_dict", "help": "Dict[file_path, dict]: Calculated contrast statistical results"},
{"key": "roi_channel_map_dict", "help": "Dict[file_path, dict]: Calculated contrast statistical results"},
{"key": "valid_dict", "help": "Dict[file_path, bool]: Boolean validity status per file"}
]
def resource_path(relative_path: str) -> str: def resource_path(relative_path: str) -> str:
""" """
Get absolute path to resource regardless of running directly or packaged using PyInstaller Get absolute path to resource regardless of running directly or packaged using PyInstaller