Files
flares/main.py
T
2026-08-10 16:47:32 -07:00

2603 lines
119 KiB
Python

"""
Filename: main.py
Description: FLARES main executable
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in imports
import os
import sys
import time
import shutil
import traceback
import subprocess
import configparser
import concurrent.futures
from queue import Empty
from copy import deepcopy
from pathlib import Path
from datetime import datetime
from multiprocessing import Process, current_process, freeze_support, Queue, set_start_method
# External library imports
import psutil
from PySide6.QtWidgets import (
QApplication, QWidget, QMessageBox, QVBoxLayout, QHBoxLayout, QTextEdit, QScrollArea, QComboBox, QGridLayout, QSplitter, QDialogButtonBox, QHeaderView,
QPushButton, QMainWindow, QLabel, QLineEdit, QGroupBox, QDialog, QMenu, QSpinBox, QTableWidget, QTableWidgetItem
)
from PySide6.QtCore import Signal, Qt, QTimer
from PySide6.QtGui import QAction, QFontMetrics, QKeySequence, QIcon
from PySide6.QtSvgWidgets import QSvgWidget # needed to show svgs when app is not frozen
from file_ext_registration import register_file_association, ELEVATION_FLAG
from project_manager import ProjectManager
from src.window.about import AboutWindow
from src.window.terminal import TerminalWindow
from src.window.updateevents import EventUpdateMode, UpdateEventsBlazesWindow, UpdateEventsWindow
from src.window.updateoptodes import UpdateOptodesWindow
from src.window.userguide import UserGuideWindow
from src.window.viewerlauncher import ViewerLauncherWidget
from src.window.welcome import WelcomeDialog
from src.shared.flaresbasewidget import FilePickerWidget, ParamSection, ProgressBubble
from src.shared.shareddata import API_URL, API_URL_SECONDARY, APP_NAME, CURRENT_VERSION, PLATFORM_NAME, DATA_SCHEMA
from startup_args import parse_startup_args
from updater import finish_update_if_needed, UpdateManager, LocalPendingUpdateCheckThread
DEFAULT_CONFIG = """
[File]
recent_files =
recent_projects =
[Edit]
[View]
status_bar = true
left_top = 0
left_bottom = 0
right = 0
[Options]
show_welcome_dialog = false
first_startup = true
[Preferences]
2d_data_bypass = false
incompatible_save_bypass = false
missing_events_bypass = false
analysis_clearing_bypass = false
folding_bypass = false
advanced_parameters = false
[Terminal]
[General]
"""
# Selectable parameters on the right side of the window
SECTIONS = [
{
"title": "Preprocessing",
"params": [
{"name": "DOWNSAMPLE", "default": True, "type": bool, "advanced": False, "help": "Should the snirf files be downsampled? If this is set to True, DOWNSAMPLE_FREQUENCY will be used as the target frequency to downsample to."},
{"name": "DOWNSAMPLE_FREQUENCY", "default": 25, "type": int, "depends_on": "DOWNSAMPLE", "advanced": False, "help": "Frequency (Hz) to downsample to. If this is set higher than the input data, new data will be interpolated."},
]
},
{
"title": "Trimming",
"params": [
{"name": "TRIM", "default": True, "type": bool, "advanced": False, "help": "Should the start of the files be trimmed?"},
{"name": "SECONDS_TO_KEEP", "default": 5.0, "type": float, "advanced": False, "depends_on": "TRIM", "help": "Seconds to keep at the beginning of all loaded snirf files before the first annotation/event occurs. Calculation is done seperatly on all loaded snirf files. Setting this to 0 will have the first annotation/event be at time point 0. Only used if TRIM is set to True."},
]
},
{
"title": "Verify Optode Placement",
"params": [
{"name": "OPTODE_PLACEMENT", "default": True, "type": bool, "advanced": False, "help": "Should an image be generated for each participant outlining their optode placement on a head?"},
{"name": "SHOW_OPTODE_NAMES", "default": True, "type": bool, "advanced": False, "depends_on": "OPTODE_PLACEMENT", "help": "Should the optode names be written next to their location in the image?"},
]
},
{
"title": "Short/Long Channels",
"params": [
{"name": "SHORT_CHANNELS", "default": True, "type": bool, "advanced": False, "help": "This should be set to True if the data has a short channel present in the data. For more information about short channels, please visit the Wiki."},
{"name": "LONG_CHANNELS", "default": True, "type": bool, "advanced": True, "help": "Should channels exceeding the maximum allowed distance be removed?"},
{"name": "SHORT_CHANNELS_THRESHOLD", "default": 0.015, "type": float, "depends_on": "SHORT_CHANNELS", "advanced": False, "help": "The maximum distance the short channel can be in metres before it is no longer considered a short channel."},
{"name": "LONG_CHANNELS_THRESHOLD", "default": 0.045, "type": float, "depends_on": "LONG_CHANNELS", "advanced": False, "help": "The maximum distance channels can be in metres. Any channel longer than this distance will be discarded."},
]
},
{
"title": "Heart Rate",
"params": [
{"name": "HEART_RATE", "default": True, "type": bool, "advanced": False, "help": "Should an attempt be made to calculate the participants heart rate?"},
{"name": "SECONDS_TO_STRIP_HR", "default": 5, "type": int, "depends_on": "HEART_RATE", "advanced": False, "help": "Will remove this many seconds from the start and end of the file. Useful if recording before cap is firmly placed, or participant removes cap while still recording."},
{"name": "HR_LOW_FREQ", "default": 0.8, "type": float, "depends_on": "HEART_RATE", "advanced": True, "help": "Lower frequency bound for heart rate detection (Hz). Used to isolate cardiac frequencies before peak detection."},
{"name": "HR_HIGH_FREQ", "default": 2.5, "type": float, "depends_on": "HEART_RATE", "advanced": True, "help": "Upper frequency bound for heart rate detection (Hz). Used to isolate cardiac frequencies before peak detection."},
{"name": "HR_SEARCH_MIN", "default": 30, "type": int, "depends_on": "HEART_RATE", "advanced": True, "help": "Minimum heart rate considered during spectral analysis (BPM)."},
{"name": "HR_SEARCH_MAX", "default": 200, "type": int, "depends_on": "HEART_RATE", "advanced": True, "help": "Maximum heart rate considered during spectral analysis (BPM)."},
{"name": "MAX_LOW_HR", "default": 40, "type": int, "depends_on": "HEART_RATE", "advanced": False, "help": "Any heart rate windows that average below this value will be rounded up to this value."},
{"name": "MAX_HIGH_HR", "default": 200, "type": int, "depends_on": "HEART_RATE", "advanced": False, "help": "Any heart rate windows that average above this value will be rounded down to this value."},
{"name": "SMOOTHING_WINDOW_HR", "default": 100, "type": int, "depends_on": "HEART_RATE", "advanced": True, "help": "Number of individual heart rate samples used to create each smoothed value."},
{"name": "HEART_RATE_WINDOW", "default": 25, "type": int, "depends_on": "HEART_RATE", "advanced": False, "help": "Visualization window around the estimated heart rate (BPM)."},
]
},
{
"title": "Scalp Coupling Index",
"params": [
{"name": "SCI", "default": True, "type": bool, "advanced": False, "help": "Calculate and mark channels bad based on their Scalp Coupling Index. This metric calculates the quality of the connection between the optode and the scalp."},
{"name": "SCI_USE_HEART_RATE_BAND", "default": True, "type": bool, "depends_on": [{"parent_name": "SCI"}, {"parent_name": "HEART_RATE"}], "advanced": False, "help": "Adjust the SCI frequency band using the participant's estimated heart rate."},
{"name": "SCI_LOW_FREQ", "default": 0.7, "type": float, "depends_on": "SCI_USE_HEART_RATE_BAND", "depends_value": False,"advanced": True, "help": "Lower frequency cutoff for SCI bandpass filtering (Hz)."},
{"name": "SCI_HIGH_FREQ", "default": 1.5, "type": float, "depends_on": "SCI_USE_HEART_RATE_BAND", "depends_value": False, "advanced": True, "help": "Upper frequency cutoff for SCI bandpass filtering (Hz)."},
{"name": "SCI_TIME_WINDOW", "default": 3, "type": int, "depends_on": "SCI", "advanced": False, "help": "Duration of each independent SCI calculation window in seconds."},
{"name": "SCI_THRESHOLD", "default": 0.6, "type": float, "depends_on": "SCI", "advanced": False, "help": "SCI threshold on a scale of 0-1. Channels below this value are marked bad."},
]
},
{
"title": "Signal to Noise Ratio",
"params": [
{"name": "SNR", "default": True, "type": bool, "advanced": False, "help": "Calculate and mark channels bad based on their Signal to Noise Ratio. This metric calculates how much of the observed signal was noise versus how much of it was a useful signal."},
{"name": "SNR_THRESHOLD", "default": 5.0, "type": float, "depends_on": "SNR", "advanced": False, "help": "SNR threshold (dB). Channels below this value will be marked as bad."},
{"name": "SNR_SIGNAL_LOW_FREQ", "default": 0.01, "type": float, "depends_on": "SNR", "advanced": True, "help": "Lower frequency bound for the signal band used in SNR calculation (Hz)."},
{"name": "SNR_SIGNAL_HIGH_FREQ", "default": 0.5, "type": float, "depends_on": "SNR", "advanced": True, "help": "Upper frequency bound for the signal band used in SNR calculation (Hz)."},
{"name": "SNR_NOISE_LOW_FREQ", "default": 1.0, "type": float, "depends_on": "SNR", "advanced": True, "help": "Lower frequency bound for the noise band used in SNR calculation (Hz)."},
{"name": "SNR_NOISE_HIGH_FREQ", "default": 10.0, "type": float, "depends_on": "SNR", "advanced": True, "help": "Upper frequency bound for the noise band used in SNR calculation (Hz)."},
]
},
{
"title": "Peak Spectral Power",
"params": [
{"name": "PSP", "default": True, "type": bool, "advanced": False, "help": "Calculate and mark channels bad based on their Peak Spectral Power. This metric calculates the amplitude or strength of the most prominent frequency component in a specified spectral range."},
{"name": "PSP_TIME_WINDOW", "default": 3, "type": int, "depends_on": "PSP", "advanced": False, "help": "Length of each independent PSP calculation window in seconds."},
{"name": "PSP_THRESHOLD", "default": 0.1, "type": float, "depends_on": "PSP", "advanced": False, "help": "Channels with average PSP values below this threshold will be marked as bad."},
{"name": "PSP_LOW_FREQ", "default": 0.7, "type": float, "depends_on": "PSP", "advanced": True, "help": "Lower frequency cutoff for PSP bandpass filtering (Hz)."},
{"name": "PSP_HIGH_FREQ", "default": 1.5, "type": float, "depends_on": "PSP", "advanced": True, "help": "Upper frequency cutoff for PSP bandpass filtering (Hz)."},
]
},
{
"title": "Coefficient of Variation",
"params": [
{"name": "COEFF_VAR", "default": True, "type": bool, "advanced": False, "help": "Identifies bad channels using the Coefficient of Variation."},
{"name": "COEFF_VAR_THRESHOLD", "default": 20, "type": int, "depends_on": "COEFF_VAR", "advanced": False, "help": "Noise threshold (%)."},
]
},
{
"title": "Median Absolute Deviation",
"params": [
{"name": "MAD", "default": True, "type": bool, "advanced": False, "help": "Identifies bad channels using Median Absolute Deviation."},
{"name": "MAD_THRESHOLD", "default": 4, "type": int, "depends_on": "MAD", "advanced": False, "help": "Amount of deviations before the channel is flagged bad."},
]
},
{
"title": "Power Spectral Density Noise",
"params": [
{"name": "PSD_NOISE", "default": True, "type": bool, "advanced": False, "help": "Identifies bad channels based on excessive power at high frequencies."},
{"name": "TARGET_FREQ_DIV", "default": 4, "type": int, "depends_on": "PSD_NOISE", "advanced": False, "help": "Target frequency is calculated by dividing the recording frequency by this value. Must be greater than 2."},
{"name": "DB_LIMIT", "default": -60, "type": float, "depends_on": "PSD_NOISE", "advanced": False, "help": "Power threshold in dB. Channels exceeding this value near the target frequency are marked bad."},
{"name": "PSD_MIN_FREQ", "default": 0.1, "type": float, "depends_on": "PSD_NOISE", "advanced": True, "help": "Minimum frequency included when calculating the PSD (Hz)."},
{"name": "PSD_TARGET_BANDWIDTH", "default": 0.2, "type": float, "depends_on": "PSD_NOISE", "advanced": True, "help": "Frequency window around the target frequency used when averaging PSD power (Hz)."},
]
},
{
"title": "Sensor Dropout",
"params": [
{"name": "SENSOR_DROPOUT", "default": True, "type": bool, "advanced": False, "help": "Identifies bad channels based on comparing the variance of the first 25% of the data to the last 25%."},
{"name": "SENSOR_DROPOUT_VARIANCE_THRESHOLD", "default": 0.05, "type": float, "depends_on": "SENSOR_DROPOUT", "advanced": False, "help": "If the end variance is less than this % of the start variance, the channel will be marked as bad."},
]
},
{
"title": "Bad Channels Handling",
"params": [
{"name": "BAD_CHANNELS_HANDLING", "default": ["Interpolate"], "type": list, "options": ["Interpolate", "Remove", "None"], "exclusive": True, "advanced": False, "help": "How should we deal with the bad channels that occurred? Note: Some analysis options will only work when this is set to 'Interpolate'."},
{"name": "MAX_DIST", "default": 0.03, "type": float, "depends_on": "BAD_CHANNELS_HANDLING", "depends_value": "Interpolate", "advanced": True, "help": "The maximum distance to look for neighbours when interpolating. Used only when BAD_CHANNELS_HANDLING is set to 'Interpolate'."},
{"name": "MIN_NEIGHBORS", "default": 2, "type": int, "depends_on": "BAD_CHANNELS_HANDLING", "depends_value": "Interpolate", "advanced": True, "help": "The minimumn amount of neighbours needed within the MAX_DIST parameter. Used only when BAD_CHANNELS_HANDLING is set to 'Interpolate'."},
{"name": "MAX_BAD_CHANNELS", "default": 12, "type": int, "depends_on": "BAD_CHANNELS_HANDLING", "depends_value": "Remove", "advanced": False, "help": "Maximum amount of bad channels before the participant as a whole is marked as bad (exclusive). If this occurs, the participant will be prevented from processing any further. Used only when BAD_CHANNELS_HANDLING is set to 'Remove'."},
]
},
{
"title": "Optical Density",
"params": [
# NOTE: Intentionally empty
]
},
{
"title": "Temporal Derivative Distribution Repair filtering",
"params": [
{"name": "TDDR", "default": True, "type": bool, "advanced": False, "help": "Apply Temporal Derivitave Distribution Repair filtering - a method that removes baseline shift and spike artifacts from the data."},
]
},
{
"title": "Wavelet filtering",
"params": [
{"name": "WAVELET", "default": True, "type": bool, "advanced": False, "help": "Apply Wavelet filtering. It is a method to filter involving decomposition, threholding, and reconstruction."},
{"name": "WAVELET_TYPE", "default": "db4", "type": str, "depends_on": "WAVELET", "advanced": False, "help": "Wavelet type. Valid values are ['bior1.1', 'bior1.3', 'bior1.5', 'bior2.2', 'bior2.4', 'bior2.6', 'bior2.8', 'bior3.1', 'bior3.3', 'bior3.5', 'bior3.7', 'bior3.9', 'bior4.4', 'bior5.5', 'bior6.8', 'coif1', 'coif2', 'coif3', 'coif4', 'coif5', 'coif6', 'coif7', 'coif8', 'coif9', 'coif10', 'coif11', 'coif12', 'coif13', 'coif14', 'coif15', 'coif16', 'coif17', 'db1', 'db2', 'db3', 'db4', 'db5', 'db6', 'db7', 'db8', 'db9', 'db10', 'db11', 'db12', 'db13', 'db14', 'db15', 'db16', 'db17', 'db18', 'db19', 'db20', 'db21', 'db22', 'db23', 'db24', 'db25', 'db26', 'db27', 'db28', 'db29', 'db30', 'db31', 'db32', 'db33', 'db34', 'db35', 'db36', 'db37', 'db38', 'dmey', 'haar', 'rbio1.1', 'rbio1.3', 'rbio1.5', 'rbio2.2', 'rbio2.4', 'rbio2.6', 'rbio2.8', 'rbio3.1', 'rbio3.3', 'rbio3.5', 'rbio3.7', 'rbio3.9', 'rbio4.4', 'rbio5.5', 'rbio6.8', 'sym2', 'sym3', 'sym4', 'sym5', 'sym6', 'sym7', 'sym8', 'sym9', 'sym10', 'sym11', 'sym12', 'sym13', 'sym14', 'sym15', 'sym16', 'sym17', 'sym18', 'sym19', 'sym20']"},
{"name": "WAVELET_LEVEL", "default": 3, "type": int, "depends_on": "WAVELET", "advanced": False, "help": "Wavelet Decomposition level (must be >= 0)."},
{"name": "IQR", "default": 1.5, "type": float, "depends_on": "WAVELET", "advanced": False, "help": "Scaling factor for the Inter-Quartile Range."},
]
},
{
"title": "Haemoglobin Concentration",
"params": [
{"name": "OVERRIDE_PPF", "default": False, "type": bool, "advanced": True, "help": "Override the dynamic PPF calculation based on age to instead use the same values for all participants."},
{"name": "PPF_LOWER_WAVELENGTH", "default": 6.0, "type": float, "advanced": True, "depends_on": "OVERRIDE_PPF", "help": "PPF value to use for the lower wavelength."},
{"name": "PPF_UPPER_WAVELENGTH", "default": 6.0, "type": float, "advanced": True, "depends_on": "OVERRIDE_PPF", "help": "PPF value to use for the upper wavelength."}
]
},
{
"title": "Enhance Negative Correlation",
"params": [
{"name": "ENHANCE_NEGATIVE_CORRELATION", "default": False, "type": bool, "advanced": False, "help": "Apply Enhance Negative Correlation."},
]
},
{
"title": "Filtering",
"params": [
{"name": "FILTER", "default": True, "type": bool, "advanced": False, "help": "Should the data be bandpass filtered?"},
{"name": "FILTER_ALGORITHM", "default": ["fir"], "type": list, "options": ["fir", "iir"], "exclusive": True, "advanced": False, "help": "Filtering algorithm."},
{"name": "L_FREQ", "default": 0.005, "type": float, "depends_on": "FILTER", "advanced": False, "help": "Any frequencies lower than this value will be removed."},
{"name": "H_FREQ", "default": 0.3, "type": float, "depends_on": "FILTER", "advanced": False, "help": "Any frequencies higher than this value will be removed."},
{"name": "L_TRANS_BANDWIDTH", "default": 0.002, "type": float, "depends_on": "FILTER", "advanced": True, "help": "Width of the lower transition band to prevent abrupt filter cutoff."},
{"name": "H_TRANS_BANDWIDTH", "default": 0.002, "type": float, "depends_on": "FILTER", "advanced": True, "help": "Width of the upper transition band to prevent abrupt filter cutoff."},
# {"name": "IIR_TYPE", "default": ["butterworth"], "type": list, "options": ["butterworth", "chebyshev1", "chebyshev2", "elliptic", "bessel"], "exclusive": True, "depends_on": "FILTER_ALGORITHM", "depends_value": "IIR", "advanced": True, "help": "IIR filter design."},
# {"name": "IIR_ORDER", "default": 4, "type": int, "depends_on": "FILTER_ALGORITHM", "depends_value": "IIR", "advanced": True, "help": "Order of the IIR filter."},
{"name": "FILTER_LENGTH", "default": "auto", "type": str, "depends_on": "FILTER_ALGORITHM", "depends_value": "FIR", "advanced": True, "help": "Length of the FIR filter. 'auto' allows automatic selection."},
{"name": "FILTER_PHASE", "default": ["zero"], "type": list, "options": ["zero", "zero-double", "minimum", "minimum-half", "linear"], "exclusive": True, "depends_on": "FILTER_ALGORITHM", "depends_value": "FIR", "advanced": True, "help": "Phase response of the FIR filter."},
{"name": "FIR_WINDOW", "default": ["hamming"], "type": list, "options": ["hamming", "hann", "blackman"], "exclusive": True, "depends_on": "FILTER_ALGORITHM", "depends_value": "FIR", "advanced": True, "help": "Window function used when designing the FIR filter."},
{"name": "FIR_DESIGN", "default": ["firwin"], "type": list, "options": ["firwin", "firwin2"], "exclusive": True, "depends_on": "FILTER_ALGORITHM", "depends_value": "FIR", "advanced": True, "help": "Method used to design the FIR filter."},
# {"name": "IIR_OUTPUT", "default": ["sos"], "type": list, "options": ["sos", "ba", "zpk"], "exclusive": True, "depends_on": "FILTER_ALGORITHM", "depends_value": "IIR", "advanced": True, "help": "Representation used for IIR filter coefficients."},
# {"name": "PASSBAND_RIPPLE", "default": 1.0, "type": float, "depends_on": "IIR_TYPE", "depends_value": ["chebyshev1", "elliptic"], "advanced": True, "help": "Maximum allowed ripple in the passband (dB)."},
# {"name": "STOPBAND_ATTENUATION", "default": 40.0, "type": float, "depends_on": "IIR_TYPE", "depends_value": ["chebyshev2", "elliptic"], "advanced": True, "help": "Minimum attenuation in the stopband (dB)."},
{"name": "FILTER_PAD", "default": ["reflect_limited"], "type": list, "options": ["reflect_limited", "reflect", "edge", "constant"], "exclusive": True, "depends_on": "FILTER", "advanced": True, "help": "Padding strategy used during filtering to reduce edge artifacts."},
# {"name": "SKIP_BY_ANNOTATION", "default": ["edge", "bad_acq_skip"], "type": list, "depends_on": "FILTER", "advanced": True, "help": "Annotations that should be skipped when applying the filter."},
{"name": "FILTER_N_JOBS", "default": 1, "type": int, "advanced": True, "help": "Number of parallel jobs used during filtering. Use -1 to use all available CPUs."},
]
},
{
"title": "Extracting Events",
"params": [
{"name": "EVENTS", "default": True, "type": bool, "advanced": True, "help": "Extract events from annotations for visualization and downstream event-based analysis."},
{"name": "EVENT_ID", "default": "auto", "type": str, "advanced": True, "help": "Controls how annotation descriptions are converted into event identifiers. Use 'auto' for automatic event detection."},
{"name": "EVENT_REGEX", "default": r"^(?![Bb][Aa][Dd]|[Ee][Dd][Gg][Ee]).*$", "type": str, "advanced": True, "help": "Regular expression used to select which annotations are converted into events. By default, bad and edge annotations are ignored."},
# {"name": "EVENT_CHUNK_DURATION", "default": 0.0, "type": float, "advanced": True, "help": "If provided, creates repeated events at this interval within longer annotations instead of only using annotation onset times."},
]
},
{
"title": "Epoch Calculations",
"params": [
{"name": "EPOCHS", "default": True, "type": bool, "depends_on": "EVENTS", "advanced": True, "help": "Create epochs around extracted events for condition-based analysis."},
{"name": "EPOCH_HANDLING", "default": ["shift"], "type": list, "options": ["shift", "strict", "drop"], "exclusive": True, "advanced": False, "help": "How to handle events occurring at the same sample. Shift moves conflicting events forward, strict raises an error, and drop removes conflicting events."},
{"name": "MAX_SHIFT", "default": 5, "type": int, "depends_on": "EPOCH_HANDLING", "depends_value": "shift", "advanced": True, "help": "Maximum number of samples to shift conflicting events before failing."},
{"name": "T_MIN", "default": -5.0, "type": float, "advanced": False, "help": "Time in seconds before each event to include in the epoch."},
{"name": "T_MAX", "default": 15.0, "type": float, "advanced": False, "help": "Time in seconds after each event to include in the epoch."},
# {"name": "BASELINE", "default": ["pre_event"], "type": list, "options": ["none", "pre_event"], "exclusive": True, "advanced": False, "help": "Baseline correction applied to epochs. Pre-event uses the period before the event as baseline."},
{"name": "REJECT_EPOCHS", "default": True, "type": bool, "advanced": False, "help": "Automatically reject epochs containing excessively large haemoglobin amplitude changes."},
{"name": "REJECT_HBO_THRESHOLD", "default": 80e-7, "type": float, "depends_on": "REJECT_EPOCHS", "advanced": True, "help": "Maximum allowed HbO amplitude before an epoch is rejected."},
]
},
{
"title": "Design Matrix",
"params": [
{"name": "RESAMPLE", "default": True, "type": bool, "advanced": False, "help": "Resample the data before creating the design matrix. Lower frequencies can reduce computation time while preserving the overall signal shape."},
{"name": "RESAMPLE_FREQ", "default": 1, "type": int, "depends_on": "RESAMPLE", "advanced": False, "help": "Sampling frequency (Hz) used when resampling the data before design matrix calculation."},
{"name": "HRF_MODEL", "default": ["fir"], "type": list, "options": ["fir", "glover", "spm", "spm + derivative", "spm + derivative + dispersion", "glover + derivative", "glover + derivative + dispersion"], "exclusive": True, "advanced": False, "help": "Haemodynamic response function model used to create regressors from event timings."},
{"name": "STIM_DUR", "default": 0.5, "type": float, "advanced": False, "help": "Expected duration of each stimulus/event in seconds. For FIR models, determines the width of each event bin."},
{"name": "FIR_DELAYS", "default": 15, "type": range, "depends_on": "HRF_MODEL", "depends_value": "fir", "advanced": True, "help": "Number of delayed regressors used for FIR models. Defines how long after an event the response is modelled."},
{"name": "DRIFT_MODEL", "default": ["cosine"], "type": list, "options": ["cosine", "polynomial"], "exclusive": True, "advanced": True, "help": "Model used to account for slow baseline signal drift."},
{"name": "HIGH_PASS", "default": 0.01, "type": float, "depends_on": "DRIFT_MODEL", "depends_value": "cosine", "advanced": True, "help": "High-pass cutoff frequency (Hz) for cosine drift removal."},
{"name": "DRIFT_ORDER", "default": 1, "type": int, "depends_on": "DRIFT_MODEL", "depends_value": "polynomial", "advanced": True, "help": "Polynomial order used to model slow drift."},
{"name": "MIN_ONSET", "default": -24, "type": int, "advanced": True, "help": "Minimum event onset relative to the sampled frame times in seconds."},
{"name": "OVERSAMPLING", "default": 50, "type": int, "advanced": True, "help": "Temporal oversampling factor used during HRF convolution."},
{"name": "SHORT_CHANNEL_REGRESSION", "default": True, "type": bool, "depends_on": "SHORT_CHANNELS", "advanced": False, "help": "Add short channel signals to the design matrix as nuisance regressors to reduce superficial physiological noise."},
]
},
{
"title": "General Linear Model",
"params": [
{"name": "NOISE_MODEL", "default": "ar1", "type": str, "advanced": False, "help": "The temporal variance model. Defaults to first order auto regressive model 'ar1'. The AR model can be set to any integer value by modifying the value of N. E.g. use ar5 for a fifth order model. If the string auto is provided a model with order 4 times the sample rate will be used."},
{"name": "BINS", "default": 0, "type": int, "advanced": True, "help": "Maximum number of discrete bins for the AR coef histogram/clustering. By default the value is 0, which will set the number of bins to the number of channels, effectively estimating the AR model for each channel."},
{"name": "N_JOBS", "default": 1, "type": int, "advanced": True, "help": "The number of CPUs to use to do the GLM computation. -1 means 'all CPUs'."},
]
},
{
"title": "Region of Interest",
"params": [
{"name": "JSON_LOCATION", "default": "", "type": "json_file", "advanced": False, "help": "Location of the JSON file containing region of interest results for significance calculations."},
]
},
{
"title": "Other",
"params": [
{"name": "MAX_WORKERS", "default": 6, "type": int, "advanced": False, "help": "Number of files to be processed at once. Setting this to a small integer value may help on underpowered systems. Remove the value to use an automatic amount."},
{"name": "VERBOSITY", "default": False, "type": bool, "advanced": True, "help": "Setting this to True will log lots of debugging information to the log file. Setting this to False will log minimal data."},
]
},
]
BIDS_FIELD_MAP = {
"BIDS - Age": "AGE",
"BIDS - Sex": "SEX",
"BIDS - Hand": "HAND",
}
class GroupAssignmentDialog(QDialog):
"""Dialog allowing users to create groups and assign unique metadata values to them."""
def __init__(self, parent=None, file_metadata: dict | None = None, field_names: list[str] | None = None):
super().__init__(parent)
self.setWindowTitle(f"Assign Groups by Metadata - {APP_NAME.upper()}")
self.resize(520, 420)
self.file_metadata = file_metadata or {}
self.field_names = [
f for f in (field_names or [])
if self._unique_values_for(f)
]
self.groups = ["Group A", "Group B"]
self.combos = []
self.unique_values = []
self._init_ui()
def _unique_values_for(self, field_name: str) -> list[str]:
return sorted(
{
str(meta.get(field_name, "")).strip()
for meta in self.file_metadata.values()
if str(meta.get(field_name, "")).strip()
}
)
def _init_ui(self):
layout = QVBoxLayout(self)
# Field selector
field_box_layout = QHBoxLayout()
field_box_layout.addWidget(QLabel("Group by:"))
self.field_selector = QComboBox()
self.field_selector.addItems(self.field_names)
self.field_selector.currentTextChanged.connect(self._on_field_changed)
field_box_layout.addWidget(self.field_selector)
layout.addLayout(field_box_layout)
# Header info (updated dynamically in _on_field_changed)
self.info_label = QLabel()
self.info_label.setWordWrap(True)
layout.addWidget(self.info_label)
# Group Creation Bar
group_box_layout = QHBoxLayout()
self.group_input = QLineEdit()
self.group_input.setPlaceholderText(
"Enter new group name (e.g. Infants, Control)..."
)
self.group_input.returnPressed.connect(self._add_group)
add_btn = QPushButton("Add Group")
add_btn.clicked.connect(self._add_group)
group_box_layout.addWidget(self.group_input)
group_box_layout.addWidget(add_btn)
layout.addLayout(group_box_layout)
# Mapping Table (Values -> Group Dropdown)
self.table = QTableWidget(0, 2)
self.table.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Stretch
)
layout.addWidget(self.table)
# Dialog Buttons
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
# Populate for whichever field is selected first
if self.field_names:
self._rebuild_table(self.field_names[0])
def _on_field_changed(self, field_name: str):
if field_name:
self._rebuild_table(field_name)
def _rebuild_table(self, field_name: str):
"""Rebuilds the value table for the newly-selected field. Any group
names created so far (self.groups) are kept - only the per-value
rows and their combo selections reset, since a value from one
field has no meaningful mapping to a value from another."""
self.field_name = field_name
self.unique_values = self._unique_values_for(field_name)
self.info_label.setText(
f"Found <b>{len(self.unique_values)}</b> unique <i>{field_name}</i> value(s) "
f"across loaded files.<br>Create custom group names and assign each value below:"
)
self.table.setRowCount(len(self.unique_values))
self.table.setHorizontalHeaderLabels([f"{field_name} Value", "Assigned Group"])
self.combos = []
for row, val in enumerate(self.unique_values):
val_item = QTableWidgetItem(str(val))
val_item.setFlags(val_item.flags() ^ Qt.ItemFlag.ItemIsEditable)
self.table.setItem(row, 0, val_item)
combo = QComboBox()
self.combos.append(combo)
self.table.setCellWidget(row, 1, combo)
self._refresh_combos()
@classmethod
def run(cls, parent, file_metadata: dict, field_names: list[str] = ["AGE", "SEX", "HAND"]):
"""Checks for groupable data across the given fields, presents the
dialog with a field-selector dropdown, and returns (field_name, mappings)
for whichever field the user grouped by."""
dialog = cls(parent, file_metadata=file_metadata, field_names=field_names)
if not dialog.field_names:
QMessageBox.information(
parent,
"No Groupable Metadata",
f"None of {field_names} had values found in the metadata to group.",
)
return None
if dialog.exec() == QDialog.DialogCode.Accepted:
return dialog.field_name, dialog.get_mappings()
return None
def _add_group(self):
"""Adds a new group to the available options."""
name = self.group_input.text().strip()
if name and name not in self.groups:
self.groups.append(name)
self.group_input.clear()
self._refresh_combos()
def _refresh_combos(self):
"""Refreshes all dropdown choices while preserving active selections."""
for combo in self.combos:
current_selection = combo.currentText()
combo.clear()
combo.addItem("-- Select Group --")
combo.addItems(self.groups)
if current_selection in self.groups:
combo.setCurrentText(current_selection)
def get_mappings(self) -> dict:
"""Returns a mapping dictionary: { metadata_value: assigned_group_name }"""
mappings = {}
for row, val in enumerate(self.unique_values):
assigned = self.combos[row].currentText()
if assigned and assigned != "-- Select Group --":
mappings[str(val)] = assigned
return mappings
class MainApplication(QMainWindow):
"""
Main application window that creates and sets up the UI.
"""
progress_update_signal = Signal(str, int)
metadata_processed = Signal(str, int)
metadata_ui_signal = Signal(dict, str, int)
def __init__(self, file_to_open=None):
super().__init__()
self.setWindowTitle(f"{APP_NAME.upper()}")
self.setGeometry(100, 100, 1280, 720)
# Load the mne data in a seperate process
self.file_executor = concurrent.futures.ProcessPoolExecutor(max_workers=1)
self.about = None
self.help = None
self.regroup_metadata = None
self.optodes = None
self.events = None
self.events_blazes = None
self.terminal = None
self.bubble_widgets = {}
self.param_sections = []
self.folder_paths = []
self.section_widget = None
self.first_run = True
self.is_2d_bypass = False
self.incompatible_save_bypass = False
self.missing_events_bypass = False
self.analysis_clearing_bypass = False
self.folding_bypass = False
self.advanced_parameters = False
self.is_saved = True
self.current_project_path = None
self.selected_paths = []
self.saved_selected_paths = []
self.files_are_dirty = False
self.project_manager = ProjectManager(self, file_cfg=file_cfg, cfg_path=cfg_path)
# Initialization to ensure that saving can occur
for item in DATA_SCHEMA:
setattr(self, item["key"], {})
self.file_metadata = {} # AGE, SEX, HAND, GROUP
self.metadata_cache = {} # Internal file/path information metadata cache
self.bubble_widgets = {} # References to the UI "Bubble" objects
self.current_file = None # Tracks the currently selected absolute path
self.metadata_processed.connect(self._safe_ui_update)
self.metadata_ui_signal.connect(self._handle_metadata_ui_update)
self.files_total = 0 # total number of files to process
self.files_done = set() # set of file paths done (success or fail)
self.files_failed = set() # set of failed file paths
self.files_results = {} # dict for successful results (if needed)
self.updater = UpdateManager(
main_window=self,
api_url=API_URL,
api_url_sec=API_URL_SECONDARY,
current_version=CURRENT_VERSION,
platform_name=PLATFORM_NAME,
platform_suffix="-" + PLATFORM_NAME,
app_name=APP_NAME
)
self.init_ui()
self.create_menu_bar()
self.pending_update_version = None
self.pending_update_path = None
self.last_clicked_bubble = None
self.installEventFilter(self)
# Start local pending update check thread
self.local_check_thread = LocalPendingUpdateCheckThread(CURRENT_VERSION, "-" + PLATFORM_NAME, PLATFORM_NAME, APP_NAME)
self.local_check_thread.pending_update_found.connect(self.updater.on_pending_update_found)
self.local_check_thread.no_pending_update.connect(self.updater.on_no_pending_update)
self.local_check_thread.start()
self.show()
# Check if we should pop up the welcome screen
should_show_welcome = file_cfg.getboolean("Options", "show_welcome_dialog", fallback=True)
first_startup = file_cfg.getboolean("Options", "first_startup", fallback=False)
if first_startup:
file_cfg.set("Options", "first_startup", "false")
try:
with open(cfg_path, "w") as f:
file_cfg.write(f)
except Exception as e:
print(f"Warning: Could not save preference: {e}")
welcome = WelcomeDialog(self, direct=True, first=first_startup)
welcome.show()
elif should_show_welcome:
file_cfg.set("Options", "show_welcome_dialog", "false")
try:
with open(cfg_path, "w") as f:
file_cfg.write(f)
except Exception as e:
print(f"Warning: Could not save preference: {e}")
welcome = WelcomeDialog(self, direct=True, first=False)
welcome.show()
if file_to_open and os.path.exists(file_to_open):
self.project_manager.load_project(file_to_open)
def init_ui(self):
central = QWidget()
self.setCentralWidget(central)
main_layout = QHBoxLayout(central)
main_layout.setContentsMargins(5, 5, 5, 5)
self.main_h_splitter = QSplitter(Qt.Orientation.Horizontal)
self.main_h_splitter.setChildrenCollapsible(False)
main_layout.addWidget(self.main_h_splitter)
self.left_v_splitter = QSplitter(Qt.Orientation.Vertical)
self.left_v_splitter.setChildrenCollapsible(False)
self.left_v_splitter.setMinimumWidth(460)
top_left_container = QGroupBox("File information")
top_left_container.setStyleSheet("QGroupBox { font-weight: bold; }")
top_left_container.setMinimumHeight(240)
top_left_layout = QHBoxLayout(top_left_container)
self.top_left_widget = QTextEdit()
self.top_left_widget.setReadOnly(True)
self.top_left_widget.setPlaceholderText("Click a file below to get started! No files below? Open one with File -> Open File!")
top_left_layout.addWidget(self.top_left_widget, stretch=4)
self.right_column_widget = QWidget()
right_column_layout = QVBoxLayout(self.right_column_widget)
self.meta_fields = {"AGE": QLineEdit(), "SEX": QLineEdit(), "HAND": QLineEdit(), "GROUP": QLineEdit()}
font_metrics = QFontMetrics(self.font())
label_width = max(font_metrics.horizontalAdvance(key.capitalize()) for key in self.meta_fields) + 10
for key, field in self.meta_fields.items():
row_layout = QHBoxLayout()
row_layout.setContentsMargins(0, 0, 0, 0)
row_layout.setSpacing(0)
label = QLabel(key.capitalize() + ":")
label.setFixedWidth(label_width)
row_layout.addWidget(label)
row_layout.addWidget(field)
right_column_layout.addLayout(row_layout)
field.textChanged.connect(self.sync_bubble_data)
label_desc = QLabel('<a href="#">Why are these useful?</a>')
label_desc.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
label_desc.linkActivated.connect(lambda: QMessageBox.information(None, f"Info - {APP_NAME.upper()} ", "Age: Used in determing the participants PPF. Also used to assist in creating groups.\nGender: Used to assist in creating groups.\nHand: Used to assist in creating groups.\nGroup: Used to split participants into groups for comparisons between them."))
right_column_layout.addWidget(label_desc)
right_column_layout.addStretch()
self.right_column_widget.hide()
top_left_layout.addWidget(self.right_column_widget, stretch=1)
self.bubble_container = QWidget()
self.bubble_layout = QGridLayout(self.bubble_container)
self.bubble_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_area = QScrollArea()
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setWidget(self.bubble_container)
self.scroll_area.setMinimumHeight(200)
self.left_v_splitter.addWidget(top_left_container)
self.left_v_splitter.addWidget(self.scroll_area)
self.right_container = QWidget()
self.right_container.setMinimumWidth(440)
right_container_layout = QVBoxLayout(self.right_container)
self.right_content_widget = QWidget()
right_content_layout = QVBoxLayout(self.right_content_widget)
self.rows_container = QWidget()
self.rows_layout = QVBoxLayout(self.rows_container)
right_content_layout.addWidget(self.rows_container)
right_content_layout.addStretch()
self.right_scroll_area = QScrollArea()
self.right_scroll_area.setWidgetResizable(True)
self.right_scroll_area.setWidget(self.right_content_widget)
buttons_widget = QWidget()
buttons_layout = QHBoxLayout(buttons_widget)
buttons_layout.addStretch()
self.button1, self.button2, self.button3 = QPushButton("Process"), QPushButton("Clear"), QPushButton("Analysis")
for btn in [self.button1, self.button2, self.button3]:
btn.setMinimumSize(100, 40)
buttons_layout.addWidget(btn)
self.button1.setVisible(False)
self.button3.setVisible(False)
self.button1.clicked.connect(self.on_run_task)
self.button2.clicked.connect(self.clear_all)
self.button3.clicked.connect(self.open_launcher_window)
right_container_layout.addWidget(self.right_scroll_area)
right_container_layout.addWidget(buttons_widget)
self.main_h_splitter.addWidget(self.left_v_splitter)
self.main_h_splitter.addWidget(self.right_container)
self.main_h_splitter.setSizes([600, 400])
self.left_v_splitter.setSizes([300, 700])
self.progress_update_signal.connect(self.update_file_progress)
self.update_window_title()
self.update_sections(0)
#NOTE: leave this here for now
# def check_memory_leak(self):
# # 2. Take a snapshot
# snapshot = tracemalloc.take_snapshot()
# # 3. Filter to show the top 10 biggest "stayers"
# top_stats = snapshot.statistics('lineno')
# print("[ Top 10 Memory Consumers ]")
# for stat in top_stats[:10]:
# print(stat)
def create_menu_bar(self):
'''Menu Bar at the top of the screen'''
menu_bar = self.menuBar()
self.statusbar = self.statusBar()
def make_action(name, shortcut=None, slot=None, checkable=False, checked=False, icon=None):
action = QAction(name, self)
if shortcut:
action.setShortcut(QKeySequence(shortcut))
if slot:
action.triggered.connect(slot)
if checkable:
action.setCheckable(True)
action.setChecked(checked)
if icon:
action.setIcon(QIcon(icon))
return action
# File menu and actions
file_menu = menu_bar.addMenu("File")
file_actions = [
("Open File...", "Ctrl+O", self.project_manager.open_file_dialog, resource_path("icons/file_open_24dp_1F1F1F.svg")),
("Open Folder...", "Ctrl+Alt+O", self.project_manager.open_folder_dialog, resource_path("icons/folder_24dp_1F1F1F.svg")),
("Load Project...", "Ctrl+L", self.project_manager.load_project_dialog, resource_path("icons/article_24dp_1F1F1F.svg")),
("Save Project...", "Ctrl+S", lambda: self.project_manager.save_project(ask=False), resource_path("icons/save_24dp_1F1F1F.svg")),
("Save Project As...", "Ctrl+Shift+S", lambda: self.project_manager.save_project(ask=True), resource_path("icons/save_as_24dp_1F1F1F.svg")),
]
for i, (name, shortcut, slot, icon) in enumerate(file_actions):
file_menu.addAction(make_action(name, shortcut, slot, icon=icon))
if i == 1:
self.recent_files_menu = file_menu.addMenu("Recent Files")
self.recent_files_menu.setIcon(QIcon(resource_path("icons/history_24dp_1F1F1F.svg"))) # optional icon
file_menu.addSeparator()
elif i == 2:
self.recent_projects_menu = file_menu.addMenu("Recent Projects")
self.recent_projects_menu.setIcon(QIcon(resource_path("icons/history_2_24dp_1F1F1F.svg")))
file_menu.addSeparator()
file_menu.addSeparator()
file_menu.addAction(make_action("Exit", "Ctrl+Q", QApplication.instance().quit, icon=resource_path("icons/exit_to_app_24dp_1F1F1F.svg")))
# Edit menu
edit_menu = menu_bar.addMenu("Edit")
edit_actions = [
("Cut", "Ctrl+X", self.cut_text, resource_path("icons/content_cut_24dp_1F1F1F.svg")),
("Copy", "Ctrl+C", self.copy_text, resource_path("icons/content_copy_24dp_1F1F1F.svg")),
("Paste", "Ctrl+V", self.paste_text, resource_path("icons/content_paste_24dp_1F1F1F.svg"))
]
for name, shortcut, slot, icon in edit_actions:
edit_menu.addAction(make_action(name, shortcut, slot, icon=icon))
# View menu
# TODO: Pretty this like the rest of the menus?
view_menu = menu_bar.addMenu("View")
toggle_statusbar_action = make_action("Toggle Status Bar", checkable=True, checked=True, slot=None)
view_menu.addAction(toggle_statusbar_action)
toggle_statusbar_action.toggled.connect(self.statusbar.setVisible)
# Reset Layout Action
view_menu.addSeparator()
reset_layout_action = make_action(
"Reset Window Layout",
"Ctrl+Shift+R",
self.reset_window_layout,
icon=resource_path("icons/grid_layout_side_24dp_1F1F1F.svg")
)
view_menu.addAction(reset_layout_action)
# Options menu (Help & About)
options_menu = menu_bar.addMenu("Options")
options_actions = [
("User Guide", "F1", self.user_guide, resource_path("icons/help_24dp_1F1F1F.svg")),
("Regroup Files from Metadata", "F3", self.group_metadata, resource_path("icons/help_24dp_1F1F1F.svg")),
("Check for Updates", "F5", self.updater.manual_check_for_updates, resource_path("icons/update_24dp_1F1F1F.svg")),
("Show Update Changelog", "F6", self.show_update_changelog, resource_path("icons/article_shortcut_24dp_1F1F1.svg")),
("Update events in snirf file (BORIS)...", "F7", self.update_event_markers, resource_path("icons/upgrade_24dp_1F1F1F.svg")),
("Update events in snirf file (BLAZES)...", "F8", self.update_event_markers_blazes, resource_path("icons/upgrade_24dp_1F1F1F.svg")),
("Update optodes in snirf file...", "F9", self.update_optode_positions, resource_path("icons/upgrade_24dp_1F1F1F.svg")),
("Reset to Default Configuration", "F10", self.reset_to_default_configuration, resource_path("icons/reset_settings_24dp_1F1F1F.svg")),
("About", "F12", self.about_window, resource_path("icons/info_24dp_1F1F1F.svg"))
]
for i, (name, shortcut, slot, icon) in enumerate(options_actions):
options_menu.addAction(make_action(name, shortcut, slot, icon=icon))
if i == 3 or i == 6 or i == 7:
options_menu.addSeparator()
self.pref_actions = {}
preferences_menu = menu_bar.addMenu("Preferences")
preferences_actions = [
("2D Data Bypass", "", self.is_2d_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "2d_data_bypass"),
("Incompatible Save Bypass", "", self.incompatable_save_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "incompatible_save_bypass"),
("Missing Events Bypass", "", self.missing_events_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "missing_events_bypass"),
("Analysis Clearing Bypass", "", self.analysis_clearing_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "analysis_clearing_bypass"),
("Folding Bypass", "", self.folding_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "folding_bypass"),
("Show Advanced Parameters", "", self.advanced_parameters_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "advanced_parameters"),
]
for name, shortcut, slot, icon, config_key in preferences_actions:
action = make_action(name, shortcut, slot, icon=icon, checkable=True)
preferences_menu.addAction(action)
self.pref_actions[config_key] = action
terminal_menu = menu_bar.addMenu("Terminal")
terminal_actions = [
("New Terminal", "Ctrl+Alt+T", self.terminal_gui, resource_path("icons/terminal_24dp_1F1F1F.svg")),
]
for name, shortcut, slot, icon in terminal_actions:
terminal_menu.addAction(make_action(name, shortcut, slot, icon=icon))
self.sync_app_with_config()
self.statusbar.showMessage("Ready")
def update_sections(self, index):
self.current_section_index = index
# Build sections ONCE to avoid destroying/re-creating widgets in C++
if not hasattr(self, "_sections_built") or not self._sections_built:
self.param_sections.clear()
self.global_param_widgets = {}
if not hasattr(self, "section_dirty_states"):
self.section_dirty_states = {}
for section in SECTIONS:
section_widget = ParamSection(section, self.global_param_widgets)
self.section_dirty_states[section_widget] = False
if hasattr(section_widget, "dirty_state_changed"):
section_widget.dirty_state_changed.connect(
lambda is_dirty, sec=section_widget: self.on_section_dirty_changed(
sec, is_dirty
)
)
self.rows_layout.addWidget(section_widget)
self.param_sections.append(section_widget)
self._sections_built = True
# self.is_saved = True
# if hasattr(self, "update_window_title"):
# self.update_window_title()
# Defensive dictionary lookup for the preference action
pref_action = getattr(self, "pref_actions", {}).get("advanced_parameters")
show_advanced = pref_action.isChecked() if pref_action is not None else False
# Toggle visibility on all built sections
for sec in self.param_sections:
sec.set_advanced_visible(show_advanced)
sec.update_dependencies()
def on_section_dirty_changed(self, section_widget, is_dirty: bool):
"""Called whenever any ParamSection's dirty state changes."""
self.section_dirty_states[section_widget] = is_dirty
# App is dirty if ANY section is dirty
app_has_unsaved_changes = any(self.section_dirty_states.values())
# Update app-wide saved state
new_is_saved = not app_has_unsaved_changes
if self.is_saved != new_is_saved:
self.is_saved = new_is_saved
self.update_window_title()
def clear_all(self):
"""
Forcefully purges all data, kills background tasks,
and resets the memory heap.
"""
if hasattr(self, "result_process") and self.result_process and self.result_process.is_alive():
msg = QMessageBox(self)
msg.setWindowTitle(f"Confirm Clear - {APP_NAME.upper()}")
msg.setText("Data processing is currently active in the background. "
"Clearing now will forcefully kill all tasks and lose current progress.\n\n"
"Are you sure you want to proceed?")
msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel)
msg.setDefaultButton(QMessageBox.StandardButton.Cancel)
response = msg.exec()
if response == QMessageBox.StandardButton.Ok:
self.cancel_task()
else:
return
is_dirty = not getattr(self, "is_saved", True)
if is_dirty:
save_msg = QMessageBox(self)
save_msg.setWindowTitle(f"Save Changes - {APP_NAME.upper()}")
if self.current_project_path:
save_msg.setText(f"Do you want to save changes to '{os.path.basename(self.current_project_path)}' before clearing?")
else:
save_msg.setText("Do you want to save your current project before clearing?")
save_msg.setStandardButtons(
QMessageBox.StandardButton.Save |
QMessageBox.StandardButton.Discard |
QMessageBox.StandardButton.Cancel
)
save_msg.setDefaultButton(QMessageBox.StandardButton.Save)
response = save_msg.exec()
if response == QMessageBox.StandardButton.Save:
# Route to your project manager save logic
if hasattr(self, "project_manager") and hasattr(self.project_manager, "save_project"):
saved_successfully = self.project_manager.save_project()
if not saved_successfully:
return # Cancel clearing if saving was aborted or failed
elif hasattr(self, "save_project"):
saved_successfully = self.save_project()
if not saved_successfully:
return
elif response == QMessageBox.StandardButton.Cancel:
return
self.top_left_widget.clear()
if hasattr(self, "last_clicked_bubble"):
self.last_clicked_bubble = None
if hasattr(self, "result_timer") and self.result_timer:
self.result_timer.stop()
self.result_timer.deleteLater()
self.result_timer = None
if hasattr(self, "result_process") and self.result_process:
if self.result_process.is_alive():
self.result_process.terminate()
self.result_process.join(timeout=1)
self.result_process = None
if hasattr(self, "file_executor") and self.file_executor:
self.file_executor.shutdown(wait=False, cancel_futures=True)
self.file_executor = None
self.pending_files_count = 0
# Increment session so any 'in-flight' callbacks are ignored
if hasattr(self, "loading_session_id"):
self.loading_session_id += 1
# Disconnect the buttons to break potential closures
for btn in [self.button1, self.button3]:
try:
btn.clicked.disconnect()
except (TypeError, RuntimeError): #NOTE: Till raises RuntimeWarnings?
pass
# UI Cleanup
self.right_column_widget.hide()
while self.bubble_layout.count():
item = self.bubble_layout.takeAt(0)
widget = item.widget()
if widget:
# Forcefully disconnect signals to be safe
try:
widget.clicked.disconnect()
widget.rightClicked.disconnect()
except:
pass
widget.deleteLater()
self.bubble_layout.setSpacing(0)
self.bubble_layout.setContentsMargins(0, 0, 0, 0)
self.bubble_container.setMinimumSize(0, 0)
self.bubble_container.resize(0, 0)
self.scroll_area.updateGeometry()
# Data Purge
self.bubble_widgets = {}
self.files_results = {}
self.files_done = set()
self.files_failed = set()
for item in DATA_SCHEMA:
setattr(self, item["key"], {})
self.metadata_cache = {}
self.file_metadata = {}
if hasattr(self, "meta_fields"):
for field in self.meta_fields.values():
field.blockSignals(True)
field.clear()
field.blockSignals(False)
self.current_file = None
if hasattr(self, "selected_paths"): self.selected_paths = []
if hasattr(self, "selected_path"): self.selected_path = None
self.button1.setText("Process")
self.button1.clicked.connect(self.on_run_task)
self.button1.setVisible(False)
self.button3.setVisible(False)
self.current_project_path = None
self.saved_selected_paths = []
self.saved_file_metadata = {}
self.files_are_dirty = False
for section_widget in getattr(self, "param_sections", []):
if hasattr(section_widget, "reset_baseline_to_default"):
section_widget.reset_baseline_to_default()
elif hasattr(section_widget, "save_current_as_baseline"):
# Fallback if no explicit default reset method exists
section_widget.save_current_as_baseline()
# Recalculate app dirty state and update window title to show "Untitled" or "Untitled *"
if hasattr(self, "check_if_app_is_dirty"):
self.check_if_app_is_dirty()
elif hasattr(self, "update_window_title"):
self.update_window_title()
self.statusBar().showMessage("All data has been cleared.")
#NOTE: leave this here for now. needs other parts uncommented to work
# self.check_memory_leak()
# self.find_referrers()
# snapshot2 = tracemalloc.take_snapshot()
# # 4. Show the "Compare" - This shows what REFUSED to die
# stats = snapshot2.compare_to(snapshot1, 'lineno')
# print("[ Memory that stayed after Clear ]")
# for stat in stats[:10]:
# print(stat)
# print("Top 10 growing object types in RAM:")
# objgraph.show_most_common_types(limit=10)
def check_if_app_is_dirty(self):
# Files are dirty if selected_paths doesn't match saved snapshot
saved_paths = getattr(self, "saved_selected_paths", [])
current_paths = getattr(self, "selected_paths", [])
files_dirty = current_paths != saved_paths
params_dirty = any(s.has_any_changes() for s in getattr(self, "param_sections", []))
meta_dirty = self.project_manager.is_metadata_dirty()
self.is_saved = not (files_dirty or params_dirty or meta_dirty)
self.update_window_title()
def reset_window_layout(self):
"""
Snaps all draggable splitters back to their default proportional positions.
"""
total_width = self.main_h_splitter.width()
left_w = int(total_width * 26 / 45)
right_w = total_width - left_w
self.main_h_splitter.setSizes([left_w, right_w])
total_height = self.left_v_splitter.height()
top_h = int(total_height * 0.30)
bottom_h = total_height - top_h
self.left_v_splitter.setSizes([top_h, bottom_h])
self.statusBar().showMessage("Window layout has been reset to default.", 3000)
def open_launcher_window(self):
data_map = {item["key"]: getattr(self, item["key"]) for item in DATA_SCHEMA}
# 2. Extract values in the specific order the widget constructor expects
args = [
data_map["raw_haemo_dict"],
data_map["epochs_dict"],
data_map["cha_dict"],
data_map["df_ind_dict"],
data_map["design_matrix_dict"],
data_map["config_dict"],
data_map["fig_bytes_dict"],
data_map["contrast_results_dict"],
data_map["roi_channel_map_dict"],
self.folding_bypass,
]
self.launcher_window = ViewerLauncherWidget(*args)
self.launcher_window.show()
def copy_text(self):
self.top_left_widget.copy() # Trigger copy
self.statusbar.showMessage("Copied to clipboard") # Show status message
def cut_text(self):
self.top_left_widget.cut() # Trigger cut
self.statusbar.showMessage("Cut to clipboard") # Show status message
def paste_text(self):
self.top_left_widget.paste() # Trigger paste
self.statusbar.showMessage("Pasted from clipboard") # Show status message
def _update_config_setting(self, key, value):
"""Helper to update memory configuration and save to disk."""
# configparser expects string values
file_cfg.set("Preferences", key, str(value).lower())
try:
with open(cfg_path, "w") as f:
file_cfg.write(f)
except Exception as e:
print(f"Warning: Could not save setting '{key}' to disk: {e}")
def is_2d_bypass_func(self, checked):
self.is_2d_bypass = checked
self._update_config_setting("2d_data_bypass", checked)
def incompatable_save_bypass_func(self, checked):
self.incompatible_save_bypass = checked
self._update_config_setting("incompatible_save_bypass", checked)
def missing_events_bypass_func(self, checked):
self.missing_events_bypass = checked
self._update_config_setting("missing_events_bypass", checked)
def analysis_clearing_bypass_func(self, checked):
self.analysis_clearing_bypass = checked
self._update_config_setting("analysis_clearing_bypass", checked)
def folding_bypass_func(self, checked):
self.folding_bypass = checked
self._update_config_setting("folding_bypass", checked)
def advanced_parameters_func(self, checked):
self.advanced_parameters = checked
self._update_config_setting("advanced_parameters", checked)
self.update_sections(0)
def about_window(self):
if self.about is None or not self.about.isVisible():
self.about = AboutWindow(self)
self.about.show()
def user_guide(self):
if self.help is None or not self.help.isVisible():
self.help = UserGuideWindow(self)
self.help.show()
def group_metadata(self):
if self.regroup_metadata is None or not self.regroup_metadata.isVisible():
file_meta = getattr(self, "file_metadata", {})
if any(bool(meta) for meta in file_meta.values()):
result = GroupAssignmentDialog.run(
self, file_meta, field_names=list(BIDS_FIELD_MAP.values())
)
if result:
field_name, mappings = result
self._apply_group_mappings(mappings, field_name=field_name)
else:
QMessageBox.information(
None,
"No Data",
"This action is not available at this time.",
QMessageBox.Ok
)
def terminal_gui(self):
if self.terminal is None or not self.terminal.isVisible():
self.terminal = TerminalWindow(self)
self.terminal.show()
def update_optode_positions(self):
if self.optodes is None or not self.optodes.isVisible():
self.optodes = UpdateOptodesWindow(self)
self.optodes.show()
def update_event_markers(self):
if self.events is None or not self.events.isVisible():
self.events = UpdateEventsWindow(self, EventUpdateMode.WRITE_SNIRF, "Manual SNIRF Edit")
self.events.show()
def update_event_markers_blazes(self):
if self.events_blazes is None or not self.events_blazes.isVisible():
self.events_blazes = UpdateEventsBlazesWindow(self, EventUpdateMode.WRITE_SNIRF, "Manual SNIRF Edit")
self.events_blazes.show()
def show_update_changelog(self):
welcome = WelcomeDialog(self, direct=False)
welcome.show()
def reset_to_default_configuration(self):
"""Asks user for confirmation, then resets all settings to defaults."""
reply = QMessageBox.question(
self,
"Reset Configuration",
"Are you sure you want to reset the application and all settings to their default values? This cannot be undone.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No # Default focus on 'No'
)
# 2. If the user confirmed, perform the reset
if reply == QMessageBox.StandardButton.Yes:
try:
# Overwrite the file with the template string constant
with open(cfg_path, "w") as f:
f.write(DEFAULT_CONFIG.strip())
# Reload the config parser from the freshly written file
file_cfg.read(cfg_path)
print("Configuration reset to defaults successfully.")
except Exception as e:
print(f"Error resetting config file ({e}). Resetting in-memory only.")
# Fallback to loading the string into memory if file writing fails
file_cfg.read_string(DEFAULT_CONFIG)
for section in self.findChildren(ParamSection):
section.reset_to_defaults()
self.sync_app_with_config()
self.update_sections(0)
QTimer.singleShot(100, self._show_reset_success_dialog)
def _show_reset_success_dialog(self):
"""Helper method triggered after the UI has completely finished redrawing."""
QMessageBox.information(
self,
"Reset Successful",
"All application settings have been successfully restored to their default values."
)
self.statusbar.showMessage("All settings have been reset to their default values.", 5000)
def sync_app_with_config(self):
"""Reads values from file_cfg and updates both internal variables and UI checkmarks."""
# 1. Sync internal application state variables
self.is_2d_bypass = file_cfg.getboolean("Preferences", "2d_data_bypass", fallback=False)
self.incompatible_save_bypass = file_cfg.getboolean("Preferences", "incompatible_save_bypass", fallback=False)
self.missing_events_bypass = file_cfg.getboolean("Preferences", "missing_events_bypass", fallback=False)
self.analysis_clearing_bypass = file_cfg.getboolean("Preferences", "analysis_clearing_bypass", fallback=False)
self.folding_bypass = file_cfg.getboolean("Preferences", "folding_bypass", fallback=False)
self.advanced_parameters = file_cfg.getboolean("Preferences", "advanced_parameters", fallback=False)
# 2. Sync the UI Menu checkmarks visually
if hasattr(self, 'pref_actions'):
self.pref_actions["2d_data_bypass"].setChecked(self.is_2d_bypass)
self.pref_actions["incompatible_save_bypass"].setChecked(self.incompatible_save_bypass)
self.pref_actions["missing_events_bypass"].setChecked(self.missing_events_bypass)
self.pref_actions["analysis_clearing_bypass"].setChecked(self.analysis_clearing_bypass)
self.pref_actions["folding_bypass"].setChecked(self.folding_bypass)
self.pref_actions["advanced_parameters"].setChecked(self.advanced_parameters)
if self.advanced_parameters:
self.update_sections(0)
if hasattr(self, 'recent_files_menu'):
self.project_manager.update_recent_files_menu()
if hasattr(self, 'recent_projects_menu'):
self.project_manager.update_recent_projects_menu()
def restore_sections_from_config(self, config):
"""
Fill all ParamSection widgets with values from a participant's config.
"""
for section_widget in self.param_sections:
widgets_dict = getattr(section_widget, 'widgets', None)
if widgets_dict is None:
continue
for name, widget_info in widgets_dict.items():
if name not in config:
continue
value = config[name]
widget_info["saved_value"] = deepcopy(value)
widget = widget_info["widget"]
w_type = widget_info.get("type")
# QLineEdit (int, float, str)
if isinstance(widget, QLineEdit):
widget.blockSignals(True)
widget.setText(str(value))
widget.blockSignals(False)
widget.update()
elif isinstance(widget, FilePickerWidget):
widget.blockSignals(True)
widget.setText(str(value)) # Updates the internal QLineEdit text safely
widget.blockSignals(False)
widget.update()
# QComboBox (bool, list)
elif isinstance(widget, QComboBox):
widget.blockSignals(True)
widget.setCurrentText(str(value))
widget.blockSignals(False)
widget.update()
# QSpinBox (range)
elif isinstance(widget, QSpinBox):
widget.blockSignals(True)
try:
widget.setValue(int(value))
except Exception:
pass
widget.blockSignals(False)
widget.update()
if hasattr(section_widget, 'check_if_changed'):
if isinstance(widget, (QLineEdit, FilePickerWidget)):
section_widget.check_if_changed(name, widget.text())
elif isinstance(widget, QComboBox):
section_widget.check_if_changed(name, widget.currentText())
elif isinstance(widget, QSpinBox):
section_widget.check_if_changed(name, widget.value())
if hasattr(section_widget, "dirty_params"):
section_widget.dirty_params.clear()
# After restoring, make sure dependencies are updated
if hasattr(section_widget, 'update_dependencies'):
section_widget.update_dependencies()
if hasattr(self, "is_saved"):
self.is_saved = True
if hasattr(self, "update_window_title"):
self.update_window_title()
self.sync_file_baselines()
# def show_files_as_bubbles(self, folder_paths):
# if isinstance(folder_paths, str):
# folder_paths = [folder_paths]
# # Clear previous bubbles
# # while self.bubble_layout.count():
# # item = self.bubble_layout.takeAt(0)
# # widget = item.widget()
# # if widget:
# # widget.deleteLater()
# temp_bubble = ProgressBubble("Test Bubble", "") # A dummy bubble for measurement
# temp_bubble.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy. Preferred)
# # temp_bubble.setAttribute(Qt.WA_OpaquePaintEvent) # Improve rendering?
# temp_bubble.adjustSize() # Adjust size after the widget is created
# bubble_width = temp_bubble.width() # Get the actual width of a bubble
# available_width = self.bubble_container.width()
# cols = max(1, available_width // bubble_width) # Ensure at least 1 column
# index = 0
# if not hasattr(self, 'selected_paths'):
# self.selected_paths = []
# for folder_path in folder_paths:
# if not os.path.isdir(folder_path):
# continue
# snirf_files = [str(f) for f in Path(folder_path).glob("*.snirf")]
# for full_path in snirf_files:
# display_name = f"{os.path.basename(folder_path)} / {os.path.basename(full_path)}"
# bubble = ProgressBubble(display_name, full_path)
# bubble.set_loading_state(True)
# bubble.setCursor(Qt.CursorShape.WaitCursor)
# self.bubble_widgets[full_path] = bubble
# if full_path not in self.selected_paths:
# self.selected_paths.append(full_path)
# row = index // cols
# col = index % cols
# self.bubble_layout.addWidget(bubble, row, col)
# index += 1
# self.statusBar().showMessage(f"{index} file(s) loaded from: {', '.join(folder_paths)}")
def get_all_current_ui_params(self):
"""Gathers current values from all UI widgets across all sections."""
current_ui_config = {}
try:
for section in self.param_sections:
section_values = section.get_param_values()
current_ui_config.update(section_values)
return current_ui_config
except Exception as e:
print(f"Error reading UI parameters: {e}")
return None
def show_files_as_bubbles_from_list(self, file_list, progress_states=None, filenames=None):
if not hasattr(self, 'file_executor') or self.file_executor is None:
self.file_executor = concurrent.futures.ProcessPoolExecutor(max_workers=1)
progress_states = progress_states or {}
# Initialize trackers and clear layout
if not hasattr(self, 'selected_paths'):
self.selected_paths = []
self.bubble_widgets = {}
while self.bubble_layout.count():
item = self.bubble_layout.takeAt(0)
widget = item.widget()
if widget:
widget.deleteLater()
# Process the file list
for index, file_path in enumerate(file_list):
file_path = str(file_path)
display_name = f"{os.path.basename(os.path.dirname(file_path))} / {os.path.basename(file_path)}"
# Create bubble
bubble = ProgressBubble(display_name, file_path)
bubble.clicked.connect(self.on_bubble_clicked)
bubble.rightClicked.connect(self.on_bubble_right_clicked)
if hasattr(self, 'file_metadata') and file_path in self.file_metadata:
meta = self.file_metadata[file_path]
parts = []
for key in ["AGE", "SEX", "HAND", "GROUP"]:
value = meta.get(key, "").strip()
if value:
parts.append(f"{key}: {value}")
suffix = f"{', '.join(parts)}" if parts else ""
bubble.setSuffixText(suffix)
# Track it
self.bubble_widgets[file_path] = bubble
if file_path not in self.selected_paths:
self.selected_paths.append(file_path)
# Restore saved progress but keep loading state active
step = progress_states.get(file_path, 0)
bubble.update_progress(step, active=False)
# Add to layout
self.bubble_layout.addWidget(bubble, index, 1)
# 4. Status Bar
msg = f"Project loaded: {len(file_list)} files."
if filenames:
msg += f" Source: {os.path.basename(filenames)}"
self.statusBar().showMessage(msg)
def get_suffix_from_meta_fields(self, data_dict=None):
"""
Returns formatted suffix string.
If data_dict is passed, uses stored dictionary values.
Otherwise, reads directly from live UI QLineEdits.
"""
parts = []
if data_dict is not None:
for key in self.meta_fields.keys():
val = str(data_dict.get(key, '')).strip()
if val:
parts.append(f"{key}: {val}")
else:
for key, line_edit in self.meta_fields.items():
val = line_edit.text().strip()
if val:
parts.append(f"{key}: {val}")
return ", ".join(parts)
def on_bubble_clicked(self, bubble):
if self.current_file:
self.save_metadata(self.current_file)
if self.last_clicked_bubble and self.last_clicked_bubble != bubble:
suffix = self.get_suffix_from_meta_fields()
self.last_clicked_bubble.setSuffixText(suffix)
self.last_clicked_bubble = bubble
# show age / sex / hand / group
self.right_column_widget.show()
file_path = bubble.file_path
if not os.path.exists(file_path):
self.top_left_widget.setText("File not found.")
return
size = os.path.getsize(file_path)
created = time.ctime(os.path.getctime(file_path))
modified = time.ctime(os.path.getmtime(file_path))
snirf_info = self.get_snirf_metadata_mne(file_path)
lines = [
f"File: {os.path.basename(file_path)}",
f"Size: {size:,} bytes",
f"Created: {created}",
f"Modified: {modified}",
f"Full Path: {file_path}\n",
]
info = "\n".join(lines)
if snirf_info is None:
info += f"\nSNIRF Metadata could not be loaded!"
else:
info += "\nSNIRF Metadata:\n"
for k, v in snirf_info.items():
if isinstance(v, list):
info += f" {k}:\n"
for item in v:
info += f" - {item}\n"
else:
info += f" {k}: {v}\n"
self.top_left_widget.setText(info)
clicked_bubble = self.sender()
file_path = clicked_bubble.file_path
# Save current file's metadata
if self.current_file:
self.save_metadata(self.current_file)
# Update current file
self.current_file = file_path
if file_path not in self.file_metadata:
self.file_metadata[file_path] = {key: "" for key in self.meta_fields}
# Load new file's metadata into the fields
metadata = self.file_metadata.get(file_path, {})
for key, field in self.meta_fields.items():
field.blockSignals(True)
field.setText(metadata.get(key, ""))
field.blockSignals(False)
def on_bubble_right_clicked(self, bubble, global_pos):
menu = QMenu(self)
action1 = menu.addAction(QIcon(resource_path("icons/folder_eye_24dp_1F1F1F.svg")), "Reveal")
action2 = menu.addAction(QIcon(resource_path("icons/remove_24dp_1F1F1F.svg")), "Remove")
action = menu.exec(global_pos)
if action == action1:
path = bubble.file_path
if os.path.exists(path):
if PLATFORM_NAME == "windows":
subprocess.run(["explorer", "/select,", os.path.normpath(path)])
elif PLATFORM_NAME == "darwin": # macOS
subprocess.run(["open", "-R", path])
else: # Linux
folder = os.path.dirname(path)
subprocess.run(["xdg-open", folder])
else:
print("File not found:", path)
elif action == action2:
if self.button3.isVisible():
reply = QMessageBox.warning(
self,
"Confirm Remove",
"Are you sure you want to remove this file? This will remove the analysis option and the processing will have to be performed again.",
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel
)
if reply != QMessageBox.StandardButton.Ok:
return
else:
self.button3.setVisible(False)
self.top_left_widget.clear()
self.right_column_widget.hide()
target_path = bubble.file_path
if hasattr(self, 'file_metadata'):
self.file_metadata.pop(target_path, None)
if getattr(self, 'current_file', None) == target_path:
self.current_file = None
if hasattr(self, 'meta_fields'):
for field in self.meta_fields.values():
field.blockSignals(True)
field.clear()
field.blockSignals(False)
parent_layout = bubble.parent().layout()
if parent_layout is not None:
parent_layout.removeWidget(bubble)
key_to_delete = None
for path, b in self.bubble_widgets.items():
if b is bubble:
key_to_delete = path
break
if key_to_delete:
del self.bubble_widgets[key_to_delete]
# Remove from selected_paths
if hasattr(self, 'selected_paths'):
try:
self.selected_paths.remove(bubble.file_path)
except ValueError:
pass
# Remove from selected_path (if used)
if hasattr(self, 'selected_path') and self.selected_path == bubble.file_path:
self.selected_path = None
# for section_widget in self.param_sections:
# if hasattr(section_widget, 'update_annotation_dropdown_from_loaded_files'):
# if "REMOVE_EVENTS" in section_widget.widgets:
# section_widget.update_annotation_dropdown_from_loaded_files(self.bubble_widgets, self.button1)
# break
bubble.setParent(None)
bubble.deleteLater()
if getattr(self, 'last_clicked_bubble', None) is bubble:
self.last_clicked_bubble = None
self.check_files_dirty_state()
def check_files_dirty_state(self):
"""Compares currently loaded files against the saved baseline list."""
if not hasattr(self, "saved_selected_paths"):
self.saved_selected_paths = []
if not hasattr(self, "selected_paths"):
self.selected_paths = []
# Dirty if lists don't match exactly (order independent)
self.files_are_dirty = sorted(self.selected_paths) != sorted(
self.saved_selected_paths
)
self.evaluate_app_dirty_state()
def evaluate_app_dirty_state(self):
"""App is dirty if parameters OR loaded files differ from disk."""
params_dirty = any(getattr(self, "section_dirty_states", {}).values())
files_dirty = getattr(self, "files_are_dirty", False)
# True saved state: BOTH files and parameters must match disk
new_is_saved = not (params_dirty or files_dirty)
if self.is_saved != new_is_saved:
self.is_saved = new_is_saved
self.update_window_title()
def sync_file_baselines(self):
"""Call this inside save_project() and restore_sections_from_config()."""
# Baseline is now whatever files are currently loaded
self.saved_selected_paths = deepcopy(
getattr(self, "selected_paths", [])
)
self.files_are_dirty = False
self.evaluate_app_dirty_state()
def sync_bubble_data(self):
"""Refreshes the bubble and saves data in real-time."""
if self.current_file and self.last_clicked_bubble:
# Save the current state of all fields
self.save_metadata(self.current_file)
# Grab the updated suffix and apply it immediately
suffix = self.get_suffix_from_meta_fields()
self.last_clicked_bubble.setSuffixText(suffix)
def placeholder(self):
QMessageBox.information(self, "Placeholder", "This feature is not implemented yet.")
def save_metadata(self, file_path):
if not file_path:
return
self.file_metadata[file_path] = {
key: field.text()
for key, field in self.meta_fields.items()
}
def get_all_metadata(self):
# First, make sure current file's edits are saved
for field in self.meta_fields.values():
field.clearFocus()
# Save current file's metadata
if self.current_file:
self.save_metadata(self.current_file)
return self.file_metadata
def cancel_task(self):
self.button1.clicked.disconnect(self.cancel_task)
self.button1.setText("Stopping...")
if hasattr(self, "result_process") and self.result_process.is_alive():
parent = psutil.Process(self.result_process.pid)
children = parent.children(recursive=True)
for child in children:
try:
child.kill()
except psutil.NoSuchProcess:
pass
self.result_process.terminate()
self.result_process.join()
if hasattr(self, "result_timer") and self.result_timer.isActive():
self.result_timer.stop()
# if hasattr(self, "result_process") and self.result_process.is_alive():
# self.result_process.terminate() # Forcefully terminate the process
# self.result_process.join() # Wait for it to properly close
# # Stop the QTimer if running
# if hasattr(self, "result_timer") and self.result_timer.isActive():
# self.result_timer.stop()
for bubble in self.bubble_widgets.values():
bubble.mark_cancelled()
self.statusbar.showMessage("Processing cancelled.")
self.button1.clicked.connect(self.on_run_task)
self.button1.setText("Process")
'''MODULE FILE'''
def on_run_task(self):
#do the check
if not self.analysis_clearing_bypass:
if self.button3.isVisible():
msg = QMessageBox(self)
msg.setWindowTitle("Confirm - FLARES")
msg.setText("Processing new data will clear the current analysis. Continue? (If you do not want this dialog box to appear, toggle 'Analysis Clearing Bypass' from the Preferences menu.)")
# Add the OK and Cancel buttons
msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel)
# Set the default button (highlighted)
msg.setDefaultButton(QMessageBox.StandardButton.Cancel)
# Capture the result
response = msg.exec()
if response == QMessageBox.StandardButton.Ok:
print("User clicked OK")
else:
return
self.button3.setVisible(False)
for item in DATA_SCHEMA:
setattr(self, item["key"], {})
self.button1.clicked.disconnect(self.on_run_task)
self.button1.setText("Cancel")
self.button1.clicked.connect(self.cancel_task)
if not self.first_run:
for bubble in self.bubble_widgets.values():
pass
# bubble.mark_cancelled()
self.first_run = False
# Collect all selected snirf files in a flat list
snirf_files = []
if hasattr(self, "selected_paths") and self.selected_paths:
for path in self.selected_paths:
p = Path(path)
if p.is_dir():
snirf_files += [str(f) for f in p.glob("*.snirf")]
elif p.is_file() and p.suffix == ".snirf":
snirf_files.append(str(p))
elif hasattr(self, "selected_path") and self.selected_path:
p = Path(self.selected_path)
if p.is_dir():
snirf_files += [str(f) for f in p.glob("*.snirf")]
elif p.is_file() and p.suffix == ".snirf":
snirf_files.append(str(p))
else:
raise ValueError("No file(s) selected")
if not snirf_files:
raise ValueError("No .snirf files found in selection")
# TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed
# # validate
# for i in snirf_files:
# x_coords = set()
# y_coords = set()
# z_coords = set()
# raw = read_raw_snirf(i)
# dig = raw.info.get('dig', None)
# if dig is not None:
# for point in dig:
# if point['kind'] == 3:
# coord = point['r']
# x_coords.add(coord[0])
# y_coords.add(coord[1])
# z_coords.add(coord[2])
# print(f"Coord: {coord}")
# is_2d = (
# all(abs(x) < 1e-6 for x in x_coords) or
# all(abs(y) < 1e-6 for y in y_coords) or
# all(abs(z) < 1e-6 for z in z_coords)
# )
# if is_2d:
# if self.is_2d_bypass == False:
# QMessageBox.critical(None, "Error - 2D Data Detected - FLARES", f"Error: 2 dimensional data was found in {i}. "
# "Please update the coordinates using the 'Update optodes in snirf file...' option from the Options menu or by pressing 'F6'. "
# "You may also select the '2D Data Bypass' option from the Preferences menu to ignore this warning and process anyway. ")
# self.button1.clicked.disconnect(self.cancel_task)
# self.button1.setText("Process")
# self.button1.clicked.connect(self.on_run_task)
# return
# raw.close()
# del raw
self.files_total = len(snirf_files)
self.files_done = set()
self.files_failed = set()
self.files_results = {}
all_params = {}
for section_widget in self.param_sections:
section_params = section_widget.get_param_values()
all_params.update(section_params)
if self.folding_bypass:
all_params['FOLDING_BYP'] = True
collected_data = {
"SNIRF_FILES": snirf_files,
"PARAMS": all_params, # add this line
"METADATA": self.get_all_metadata(), # optionally add metadata if needed
}
# Start processing
if current_process().name == 'MainProcess':
self.result_queue = Queue()
self.ack_queue = Queue()
self.progress_queue = Queue()
self.result_process = Process(
target=run_gui_entry_wrapper,
args=(collected_data, self.result_queue, self.progress_queue, self.ack_queue)
)
self.result_process.daemon = False
self.result_process.start()
self.statusbar.showMessage("Running processing in background...")
self.result_timer = QTimer()
self.result_timer.timeout.connect(self.check_for_pipeline_results)
self.result_timer.start()
self.statusbar.showMessage("Task started in separate process.")
def _format_elapsed(self, seconds: float) -> str:
seconds = int(seconds)
h, rem = divmod(seconds, 3600)
m, s = divmod(rem, 60)
if h:
return f"{h:d}:{m:02d}:{s:02d}"
return f"{m:02d}:{s:02d}"
def check_for_pipeline_results(self):
try:
while True:
try:
msg = self.result_queue.get_nowait()
except Empty:
break
if isinstance(msg, dict) and msg.get("type") == "file_done":
file_path = msg["file"]
self.files_done.add(file_path)
# print(f"[DEBUG] File Done: {os.path.basename(file_path)}")
# print(f"[DEBUG] Progress: {len(self.files_done)} / {self.files_total}")
if msg.get("success"):
results = msg["result"]
self.files_results[file_path] = results
# Simple, clean assignment
for item, value in zip(DATA_SCHEMA, results):
getattr(self, item["key"])[file_path] = value
elapsed_str = self._format_elapsed(getattr(self, "_last_elapsed", 0))
self.statusbar.showMessage(
f"Processed: {os.path.basename(file_path)} | Elapsed: {elapsed_str}"
)
else:
self.files_failed.add(file_path)
error_msg = msg.get("error", "Unknown worker error")
print(f"[DEBUG] File Failed: {os.path.basename(file_path)} - {error_msg}")
self.mark_file_failed(file_path)
self.show_error_popup(f"Error: {file_path}", error_msg, msg.get("traceback", ""))
elapsed_str = self._format_elapsed(getattr(self, "_last_elapsed", 0))
self.statusbar.showMessage(
f"Failed: {os.path.basename(file_path)} | Elapsed: {elapsed_str}"
)
elif isinstance(msg, dict) and msg.get("type") == "elapsed":
# Live tick, once a second, independent of file completions
self._last_elapsed = msg["seconds"]
self.statusbar.showMessage(f"Processing... Elapsed: {self._format_elapsed(msg['seconds'])}")
elif isinstance(msg, dict) and msg.get("type") == "FINISHED_SUCCESSFULLY":
# The child has finished its work AND its own cleanup.
# It is now safe for the GUI to stop the timer and clean up.
try:
self.ack_queue.put("ACK")
except: pass
self.result_timer.stop()
self.cleanup_after_process()
success_count = len(self.files_results)
fail_count = self.files_total - success_count
elapsed_str = self._format_elapsed(msg.get("elapsed", getattr(self, "_last_elapsed", 0)))
speedup = msg.get("speedup")
speedup_str = f" | Speedup: {speedup:.1f}x" if speedup else ""
self.statusbar.showMessage(
f"Complete: {success_count} succeeded, {fail_count} failed. | Total time: {elapsed_str}{speedup_str}"
)
if success_count > 0:
self.is_saved = False
self.update_window_title()
self.button3.setVisible(True)
try:
self.button3.clicked.disconnect(self.open_launcher_window)
except (TypeError, RuntimeError):
pass
self.button3.clicked.connect(self.open_launcher_window)
# Reset the button
try: self.button1.clicked.disconnect()
except: pass
self.button1.setText("Process")
self.button1.clicked.connect(self.on_run_task)
return # Exit the method
elif isinstance(msg, dict) and msg.get("success") is True:
self.statusbar.showMessage("All files processed successfully!")
elif isinstance(msg, dict) and (msg.get("success") is False or msg.get("type") == "error"):
file_path = msg.get("file", "Process")
error_msg = msg.get("error", "Unknown error")
if file_path:
self.mark_file_failed(file_path)
self.files_done.add(file_path)
self.show_error_popup(f"Error: {file_path}", error_msg, msg.get("traceback", ""))
self.files_done.add(file_path)
if msg.get("success") is False: # Fatal crash
self.result_timer.stop()
self.cleanup_after_process()
return
elif isinstance(msg, tuple) and msg[0] == 'progress':
_, file_path, step_index = msg
self.progress_update_signal.emit(file_path, step_index)
except Exception as e:
print(f"Error in timer loop: {e}")
if not self.result_process.is_alive() and len(self.files_done) < self.files_total:
self.statusbar.showMessage("Background process died.")
self.result_timer.stop()
def mark_file_failed(self, file_path):
if not file_path:
return
key = os.path.normpath(file_path)
bubble = self.bubble_widgets.get(key)
if bubble:
bubble.mark_cancelled()
def show_error_popup(self, title, error_message, traceback_str=""):
msgbox = QMessageBox(self)
msgbox.setIcon(QMessageBox.Warning)
msgbox.setWindowTitle(f"Warning - {APP_NAME.upper()}")
message = (
f"{APP_NAME.upper()} has encountered an error processing the file {title}.<br><br>"
"This error was likely due to incorrect parameters on the right side of the screen and not an error with your data. "
"Processing of the remaining files continues in the background and this participant will be ignored in the analysis. "
f"If you think the parameters on the right side are correct for your data, raise an issue <a href='https://git.research.dezeeuw.ca/tyler/{APP_NAME}/issues'>here</a>.<br><br>"
f"Error message: {error_message}"
)
msgbox.setTextFormat(Qt.TextFormat.RichText)
msgbox.setText(message)
msgbox.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
# Add traceback to detailed text
if traceback_str:
msgbox.setDetailedText(traceback_str)
msgbox.setStandardButtons(QMessageBox.Ok)
msgbox.show()
def cleanup_after_process(self):
if hasattr(self, 'result_process'):
self.result_process.join(timeout=0)
if self.result_process.is_alive():
self.result_process.terminate()
self.result_process.join()
if hasattr(self, 'result_queue'):
if 'AutoProxy' in repr(self.result_queue):
pass
else:
self.result_queue.close()
self.result_queue.join_thread()
if hasattr(self, 'progress_queue'):
if 'AutoProxy' in repr(self.progress_queue):
pass
else:
self.progress_queue.close()
self.progress_queue.join_thread()
def update_file_progress(self, file_path, step_index):
key = os.path.normpath(file_path)
bubble = self.bubble_widgets.get(key)
if bubble:
bubble.update_progress(step_index)
def get_snirf_metadata_mne(self, file_name):
# Check if we already have it (we should?)
if file_name in self.metadata_cache:
return self.metadata_cache[file_name]
print(self.metadata_cache)
# If the user clicked so fast it's not ready, do a one-off blocking call
print(f"Cache miss for {file_name}, fetching now...")
future = self.file_executor.submit(self.project_manager.extract_metadata_worker, file_name)
return future.result(timeout=5)
def closeEvent(self, event):
# Gracefully shut down multiprocessing children
print("Window is closing. Cleaning up...")
if not self.is_saved:
reply = QMessageBox.question(
self,
f"Unsaved Changes - {APP_NAME.upper()}",
"You have unsaved changes. Save before exiting?",
QMessageBox.StandardButton.Save
| QMessageBox.StandardButton.Discard
| QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Save,
)
if reply == QMessageBox.StandardButton.Save:
if self.project_manager.save_project() is False:
event.ignore()
return
elif reply == QMessageBox.StandardButton.Discard:
pass
else:
event.ignore()
return
for widget in list(QApplication.topLevelWidgets()):
if widget is not self:
if not widget.close():
event.ignore()
return
if hasattr(self, 'loading_session_id'):
self.loading_session_id += 1
if hasattr(self, 'file_executor') and self.file_executor is not None:
try:
# cancel_futures=True drops pending tasks (Python 3.9+)
self.file_executor.shutdown(wait=False, cancel_futures=True)
except TypeError:
# Fallback for older Python versions
self.file_executor.shutdown(wait=False)
self.file_executor = None
if hasattr(self, 'result_process') and self.result_process is not None:
if self.result_process.is_alive():
self.result_process.terminate()
self.result_process.join(timeout=0.2)
if hasattr(self, 'manager'):
self.manager.shutdown()
kill_child_processes()
event.accept()
def _on_metadata_ready(self, future, file_path, session_id):
if session_id != self.loading_session_id:
return
if future.cancelled():
return
try:
result = future.result()
if result is None:
result = {'status': 'error', 'reason': 'Worker returned no data.'}
# If it's a successful extraction, it won't have 'status' set yet
elif 'status' not in result:
if file_path not in self.file_metadata:
self.file_metadata[file_path] = {}
for display_field, internal_key in BIDS_FIELD_MAP.items():
val = result.get(display_field, '')
if val:
self.file_metadata[file_path][internal_key] = val
result = {'status': 'success', 'data': result}
except Exception as e:
result = {'status': 'error', 'reason': str(e)}
# Safely emit to the Main thread. No brittle QMetaObject needed!
self.metadata_ui_signal.emit(result, file_path, session_id)
def _handle_metadata_ui_update(self, result, file_path, session_id):
"""Executes safely on the MAIN GUI thread via Signal connection."""
if result.get('status') == 'error':
# 1. Pop up the warning safely on the main thread
QMessageBox.warning(
self,
"Invalid File",
f"Could not read metadata from: {os.path.basename(file_path)}\n\n"
f"Details: {result.get('reason', 'Unknown error')}"
)
# 2. Run your clean tracking removal
self._remove_file_from_pipeline(file_path)
return
# Success path
data = result.get('data', result)
norm_path = os.path.normpath(file_path)
self.metadata_cache[norm_path] = data
# 3. Store extracted BIDS data into file_metadata store
if not hasattr(self, 'file_metadata'):
self.file_metadata = {}
if norm_path not in self.file_metadata:
self.file_metadata[norm_path] = {}
# 1. Store extracted BIDS metadata into file_metadata map
for display_field, internal_key in BIDS_FIELD_MAP.items():
val = data.get(display_field, '')
if val:
self.file_metadata[norm_path][internal_key] = str(val)
# 4. Update ONLY the text suffix on the bubble (spinner stays active!)
bubble = self.bubble_widgets.get(norm_path) or self.bubble_widgets.get(file_path)
if bubble:
suffix = self.get_suffix_from_meta_fields(self.file_metadata[norm_path])
bubble.setSuffixText(suffix)
# DO NOT call set_loading_state(False) here.
# The spinner keeps running while the rest of the pipeline executes.
# 5. Sync active form if this file is currently selected in UI
current_active = getattr(self, 'current_file', None)
if current_active and os.path.normpath(current_active) == norm_path:
self.populate_metadata_fields(file_path)
self.metadata_processed.emit(file_path, session_id)
def populate_metadata_fields(self, file_path: str):
"""
Populates the right-column metadata QLineEdits for the given file_path
without triggering textChanged sync events during the population.
"""
if not file_path:
return
# Normalize path to match key format in file_metadata
norm_path = os.path.normpath(file_path)
meta = self.file_metadata.get(
norm_path, self.file_metadata.get(file_path, {})
)
# Loop through all dynamically created fields (AGE, SEX, HAND, GROUP, etc.)
for key, line_edit in self.meta_fields.items():
val = str(meta.get(key, "")).strip()
# Block textChanged signals so sync_bubble_data doesn't fire redundant loops
line_edit.blockSignals(True)
line_edit.setText(val)
line_edit.blockSignals(False)
def _remove_file_from_pipeline(self, file_path):
"""Completely cleans up and removes all references to a file that failed to load."""
# 1. Decrement pending file count
if hasattr(self, 'pending_files_count') and self.pending_files_count > 0:
self.pending_files_count -= 1
# 2. Remove the UI widget cleanly
if hasattr(self, 'bubble_widgets') and file_path in self.bubble_widgets:
bubble = self.bubble_widgets.pop(file_path)
self.bubble_layout.removeWidget(bubble)
bubble.deleteLater() # Safely schedules the widget for deletion in Qt
# 3. Remove from tracking lists
if hasattr(self, 'selected_paths') and file_path in self.selected_paths:
self.selected_paths.remove(file_path)
# 4. Update Status Bar
if hasattr(self, 'pending_files_count') and self.pending_files_count == 0:
self.statusBar().showMessage("Ready.", 3000)
else:
self.statusBar().showMessage(f"Loading pending files... ({self.pending_files_count} left)")
def _safe_ui_update(self, file_path):
# 2. Update the Bubble safely
if file_path in self.bubble_widgets:
bubble = self.bubble_widgets[file_path]
# This is now thread-safe!
bubble.set_loading_state(False)
bubble.clicked.connect(self.on_bubble_clicked)
bubble.rightClicked.connect(self.on_bubble_right_clicked)
bubble.setCursor(Qt.CursorShape.PointingHandCursor)
# 3. Handle the global counter/cleanup
self.pending_files_count -= 1
if self.pending_files_count <= 0:
self._cleanup_executor()
self.statusbar.showMessage("All files loaded sucessfully.")
file_meta = getattr(self, "file_metadata", {})
if any(bool(meta) for meta in file_meta.values()):
reply = QMessageBox.question(
self,
"Metadata Detected",
"Extracted metadata was found in the loaded files. Would you like to assign groups based on the metadata?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.Yes,
)
if reply == QMessageBox.StandardButton.Yes:
result = GroupAssignmentDialog.run(self, file_meta, field_names=list(BIDS_FIELD_MAP.values()))
if result:
field_name, mappings = result
self._apply_group_mappings(mappings, field_name=field_name)
def _apply_group_mappings(self, mappings: dict, field_name: str = ""):
"""Applies group mappings to metadata and updates all UI widgets."""
# C. Update 'GROUP' in self.file_metadata for matching files
for path_key, meta in self.file_metadata.items():
val = str(meta.get(field_name, "")).strip()
if val in mappings:
meta["GROUP"] = mappings[val]
# D. Update text on ALL bubble widgets
for path_key, b_widget in self.bubble_widgets.items():
normalized_k = os.path.normpath(path_key)
meta_dict = self.file_metadata.get(
normalized_k, self.file_metadata.get(path_key, {})
)
suffix = self.get_suffix_from_meta_fields(meta_dict)
b_widget.setSuffixText(suffix)
# E. Refresh form fields for currently active file
current_active = getattr(self, "current_file", None)
if current_active:
self.populate_metadata_fields(current_active)
def _cleanup_executor(self):
"""Safely shuts down the executor and clears the reference."""
if hasattr(self, 'file_executor') and self.file_executor is not None:
self.file_executor.shutdown(wait=False)
self.file_executor = None
print("[System] Background worker dismissed. RAM reclaimed.")
def get_project_display_name(self) -> str:
"""Returns the filename or 'Untitled' if not yet saved to disk."""
if self.current_project_path:
return os.path.basename(self.current_project_path)
return "Untitled"
def update_window_title(self):
"""Builds the window title string with an asterisk if unsaved."""
project_name = self.get_project_display_name()
asterisk = "" if self.is_saved else "*"
title_str = f"{project_name}{asterisk} - {APP_NAME.upper()}"
self.setWindowTitle(title_str)
def mark_unsaved(self):
if self.is_saved:
self.is_saved = False
self.update_window_title()
print("[State] Unsaved changes detected.")
def mark_saved(self):
self.is_saved = True
self.update_window_title()
print("[State] All changes saved to disk.")
def run_gui_entry_wrapper(config, gui_queue, progress_queue, ack_queue):
"""
Where the processing happens
"""
# TODO: Are these needed?
os.environ["OMP_NUM_THREADS"] = "1"
os.environ["OPENBLAS_NUM_THREADS"] = "1"
os.environ["MKL_NUM_THREADS"] = "1"
os.environ["NUMEXPR_NUM_THREADS"] = "1"
os.environ["VECLIB_MAXIMUM_THREADS"] = "1"
try:
import flares as flares
flares.gui_entry(config, gui_queue, progress_queue, ack_queue)
gui_queue.close()
# gui_queue.join_thread()
progress_queue.close()
# progress_queue.join_thread()
os._exit(0)
except Exception as e:
tb_str = traceback.format_exc()
gui_queue.put({
"success": False,
"error": f"Child process crashed: {str(e)}\nTraceback:\n{tb_str}"
})
os._exit(1)
def resource_path(relative_path):
"""
Get absolute path to resource regardless of running directly or packaged using PyInstaller
"""
if hasattr(sys, '_MEIPASS'):
# PyInstaller bundle path
base_path = sys._MEIPASS
else:
base_path = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base_path, relative_path)
def kill_child_processes():
"""
Goodbye children
"""
try:
parent = psutil.Process(os.getpid())
children = parent.children(recursive=True)
for child in children:
try:
child.kill()
except psutil.NoSuchProcess:
pass
psutil.wait_procs(children, timeout=5)
except Exception as e:
print(f"Error killing child processes: {e}")
def exception_hook(exc_type, exc_value, exc_traceback):
"""
Method that will display a popup when the program hard crashes containg what went wrong
"""
error_msg = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback))
print(error_msg) # also print to console
kill_child_processes()
# Show error message box
# Make sure QApplication exists (or create a minimal one)
app = QApplication.instance()
if app is None:
app = QApplication(sys.argv)
show_critical_error(error_msg)
# Exit the app after user acknowledges
if getattr(sys, 'frozen', False):
sys.exit(1)
def show_critical_error(error_msg):
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Icon.Critical)
msg_box.setWindowTitle(f"Something went wrong! - {APP_NAME.upper()}")
if PLATFORM_NAME == "darwin":
log_path = os.path.join(os.path.dirname(sys.executable), "../../../flares.log")
error_path = os.path.join(os.path.dirname(sys.executable), "../../../flares_error.log")
save_path = os.path.join(os.path.dirname(sys.executable), "../../../flares_autosave.flare")
else:
log_path = os.path.join(os.getcwd(), "flares.log")
error_path = os.path.join(os.getcwd(), "flares_error.log")
save_path = os.path.join(os.getcwd(), "flares_autosave.flare")
shutil.copy(log_path, error_path)
error_path = Path(error_path).absolute().as_posix()
autosave_path = Path(save_path).absolute().as_posix()
error_link = f"file:///{error_path}"
autosave_link = f"file:///{autosave_path}"
try:
window.project_manager.save_project(True)
except:
pass
message = (
f"{APP_NAME.upper()} has encountered an unrecoverable error and needs to close.<br><br>"
f"We are sorry for the inconvenience. An autosave was attempted to be saved to <a href='{autosave_link}'>{autosave_path}</a>, but it may not have been saved. "
"If the file was saved, it still may not be intact, openable, or contain the correct data. Use the autosave at your own discretion.<br><br>"
f"This unrecoverable error was due to an error with {APP_NAME.upper()} and not your data.<br>"
f"If this crash occured inside a [BETA] branch, it is likely to eventually be fixed.<br>"
f"Please raise an issue <a href='https://git.research.dezeeuw.ca/tyler/{APP_NAME}/issues'>here</a> and attach the error file located at <a href='{error_link}'>{error_path}</a><br><br>"
f"<pre>{error_msg}</pre>"
)
msg_box.setTextFormat(Qt.TextFormat.RichText)
msg_box.setText(message)
msg_box.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
msg_box.setStandardButtons(QMessageBox.StandardButton.Ok)
msg_box.exec()
def config_init():
ref_cfg.read_string(DEFAULT_CONFIG)
if not os.path.exists(cfg_path):
try:
with open(cfg_path, "w") as f:
f.write(DEFAULT_CONFIG.strip())
print(f"Created default configuration file at {cfg_path}")
file_cfg.read_string(DEFAULT_CONFIG)
except Exception as e:
print(f"Warning: Could not create config file ({e}). Using in-memory defaults.")
file_cfg.read_string(DEFAULT_CONFIG)
else:
try:
# Load the user's actual file first
file_cfg.read(cfg_path)
has_changes = False
for section in file_cfg.sections():
if not ref_cfg.has_section(section):
file_cfg.remove_section(section)
has_changes = True
continue
for option in list(file_cfg.options(section)):
if not ref_cfg.has_option(section, option):
file_cfg.remove_option(section, option)
has_changes = True
for section in ref_cfg.sections():
if not file_cfg.has_section(section):
file_cfg.add_section(section)
has_changes = True
for option in list(ref_cfg.options(section)):
if not file_cfg.has_option(section, option):
default_val = ref_cfg.get(section, option)
file_cfg.set(section, option, default_val)
has_changes = True
# 4. If we added or removed anything, save the sanitized file back to disk
if has_changes:
with open(cfg_path, "w") as f:
file_cfg.write(f)
print("Configuration file synchronized: removed old keys and appended new ones.")
else:
print("Configuration loaded successfully. Schema is up to date.")
except Exception as e:
print(f"Error validating config file ({e}). Falling back completely to defaults.")
file_cfg.read_string(DEFAULT_CONFIG)
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == ELEVATION_FLAG:
_, _ext, _prog_id, _app_name = sys.argv[1:5]
_ok, _msg = register_file_association(ext=_ext, prog_id=_prog_id, app_name=_app_name)
sys.exit(0 if _ok else 1)
startup_args = parse_startup_args(sys.argv)
# Redirect exceptions to the popup window
sys.excepthook = exception_hook
# Set up application logging and configuration
if PLATFORM_NAME == "darwin":
log_path = os.path.join(os.path.dirname(sys.executable), f"../../../{APP_NAME}.log")
cfg_path = os.path.join(os.path.dirname(sys.executable), f"../../../{APP_NAME}.cfg")
else:
log_path = os.path.join(os.getcwd(), f"{APP_NAME}.log")
cfg_path = os.path.join(os.getcwd(), f"{APP_NAME}.cfg")
try:
os.remove(log_path)
except:
pass
sys.stdout = open(log_path, "a", buffering=1)
sys.stderr = sys.stdout
print(f"\n=== App started at {datetime.now()} ===\n")
file_cfg = configparser.ConfigParser()
ref_cfg = configparser.ConfigParser()
config_init()
freeze_support() # Required for PyInstaller + multiprocessing
set_start_method('spawn', force=True)
# Only run GUI in the main process
if current_process().name == 'MainProcess':
app = QApplication(sys.argv)
finish_update_if_needed(PLATFORM_NAME, APP_NAME, cfg_path, startup_args.finish_update)
icon_ext = "icns" if PLATFORM_NAME == "darwin" else "ico"
app.setWindowIcon(QIcon(resource_path(f"icons/main.{icon_ext}")))
window = MainApplication(file_to_open=startup_args.initial_file)
window.setWindowIcon(QIcon(resource_path(f"icons/main.{icon_ext}")))
window.show()
sys.exit(app.exec())
# Not 2600 lines yay!