diff --git a/main.py b/main.py index 79154a1..534e4a8 100644 --- a/main.py +++ b/main.py @@ -63,6 +63,7 @@ right = 0 [Options] show_welcome_dialog = true +first_startup = true [Preferences] 2d_data_bypass = false @@ -539,8 +540,20 @@ class MainApplication(QMainWindow): # 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 should_show_welcome: + 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: @@ -548,9 +561,11 @@ class MainApplication(QMainWindow): except Exception as e: print(f"Warning: Could not save preference: {e}") - welcome = WelcomeDialog(self, direct=True) + welcome = WelcomeDialog(self, direct=True, first=False) welcome.show() + + def init_ui(self): central = QWidget() @@ -1150,7 +1165,6 @@ class MainApplication(QMainWindow): 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.show_welcome_dialog = file_cfg.getboolean("Options", "show_welcome_dialog", fallback=True) # 2. Sync the UI Menu checkmarks visually if hasattr(self, 'pref_actions'): @@ -2535,7 +2549,7 @@ def config_init(): has_changes = True continue - for option in file_cfg.options(section): + 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 @@ -2545,7 +2559,7 @@ def config_init(): file_cfg.add_section(section) has_changes = True - for option in ref_cfg.options(section): + 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) diff --git a/src/analysis/participantfoldchannels.py b/src/analysis/participantfoldchannels.py index 8a100c3..790e221 100644 --- a/src/analysis/participantfoldchannels.py +++ b/src/analysis/participantfoldchannels.py @@ -19,11 +19,11 @@ from matplotlib.figure import Figure from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas from PySide6.QtWidgets import QFrame, QGridLayout, QHBoxLayout, QLabel, QProgressBar, QPushButton, QScrollArea, QSizePolicy, QWidget, QDialog, QVBoxLayout -from PySide6.QtCore import Qt, QSize, QTimer +from PySide6.QtCore import QThread, Qt, QSize, QTimer from PySide6.QtGui import QPixmap, QImage from src.shared.flaresbasewidget import FlaresBaseWidget -from src.shared.shareddata import APP_NAME +from src.shared.shareddata import APP_NAME, resource_path class MultiProgressDialog(QDialog): @@ -106,9 +106,10 @@ class StaticChannelCanvas(FigureCanvas): Renders the interactive pie chart on the left, and a matching PNG image on the right. """ def __init__(self, channel_name, data_list, color_map, image_path=None, parent=None): - # Create a 1-row, 2-column subplot array - # figsize=(11.0, 5.5) creates a wide 2:1 widescreen aspect window layout - self.fig, self.ax = plt.subplots(1, 2, figsize=(11.0, 5.5)) + self.fig = Figure(figsize=(11.0, 5.5)) + + self.ax = self.fig.subplots(1, 2) + super().__init__(self.fig) self.setParent(parent) @@ -516,7 +517,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas): layout = QVBoxLayout(popup) layout.setContentsMargins(0, 0, 0, 0) # Strip extra outer layout spacing - target_png_path = "images/brain.png" + target_png_path = resource_path("../../images/brain.png") expanded_canvas = StaticChannelCanvas( channel_name, @@ -571,7 +572,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas): header.setAlignment(Qt.AlignmentFlag.AlignCenter) card_layout.addWidget(header) - target_png_path = "images/brain.png" + target_png_path = resource_path("../../images/brain.png") # 3. Instantiate the canvas with a custom size flag or constraint # Adjust your StaticChannelCanvas __init__ to check if it should render in 'compact' mode @@ -687,7 +688,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas): layout = QVBoxLayout(popup) layout.setContentsMargins(10, 10, 10, 10) - target_png_path = "images/brain.png" + target_png_path = resource_path("../../images/brain.png") # This one renders full size (900x520) for analytical reading expanded_canvas = StaticChannelCanvas( @@ -707,6 +708,44 @@ class InteractiveParticipantGridCanvas(FigureCanvas): self._summary_popups.append(popup) +from PySide6.QtCore import QObject, Signal +from multiprocessing import Manager, Process + +class ProcessOrchestrator(QObject): + # Fires when Manager + Processes are completely ready + # Emits: (manager_instance, result_queue, progress_queue, active_processes_list) + setup_finished = Signal(object, object, object, list) + setup_failed = Signal(str) + + def __init__(self, selected_files, haemo_dict, worker_func): + super().__init__() + self.selected_files = selected_files + self.haemo_dict = haemo_dict + self.worker_func = worker_func + + def run(self): + try: + # 🟢 [Delay 1 Fix] Instantiate Manager completely off the main thread + manager = Manager() + result_queue = manager.Queue() + progress_queue = manager.Queue() + active_processes = [] + + # 🟢 [Delay 2 Fix] Perform heavy pickling loop safely in the background + for file_path in self.selected_files: + p = Process( + target=self.worker_func, + args=(file_path, self.haemo_dict[file_path], result_queue, progress_queue) + ) + p.start() + active_processes.append(p) + + # Deliver setup assets back to the GUI Main Thread + self.setup_finished.emit(manager, result_queue, progress_queue, active_processes) + except Exception as e: + self.setup_failed.emit(str(e)) + + class ParticipantFoldChannelsWidget(FlaresBaseWidget): def __init__(self, haemo_dict, cha_dict): super().__init__("ParticipantFoldChannels") @@ -811,31 +850,53 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget): self.multi_progress.add_participant(os.path.basename(file_path), total_channels) + from datetime import datetime + + print(f"Before: {datetime.now()}") self.multi_progress.show() + print(f"After 1: {datetime.now()}") if current_process().name == 'MainProcess': + # Create a clean background thread worker execution channel + self.orchestrator_thread = QThread() + self.orchestrator = ProcessOrchestrator(selected_files, self.haemo_dict, single_participant_worker) + self.orchestrator.moveToThread(self.orchestrator_thread) - # 2. Setup Multiprocessing Manager - self.manager = Manager() - self.result_queue = self.manager.Queue() - self.progress_queue = self.manager.Queue() - self.active_processes = [] + # Signal Routing + self.orchestrator_thread.started.connect(self.orchestrator.run) + self.orchestrator.setup_finished.connect(self.on_orchestration_success) + self.orchestrator.setup_failed.connect(self.on_orchestration_failed) - # 3. Start ALL processes at once - for file_path in selected_files: - p = Process( - target=single_participant_worker, - args=(file_path, self.haemo_dict[file_path], self.result_queue, self.progress_queue) - ) - p.start() - self.active_processes.append(p) + # Automatic lifecycle cleanup configuration + self.orchestrator.setup_finished.connect(self.orchestrator_thread.quit) + self.orchestrator.setup_failed.connect(self.orchestrator_thread.quit) + self.orchestrator_thread.finished.connect(self.orchestrator_thread.deleteLater) + self.orchestrator.setup_finished.connect(self.orchestrator.deleteLater) + self.orchestrator.setup_failed.connect(self.orchestrator.deleteLater) + + self.orchestrator_thread.start() + print(f"After 4: {datetime.now()}") + + def on_orchestration_success(self, manager, result_queue, progress_queue, active_processes): + """ Executed on the Main GUI Thread once background process setup finishes """ + self.manager = manager + self.result_queue = result_queue + self.progress_queue = progress_queue + self.active_processes = active_processes + + # 🟢 Safely initialize and trigger your polling listener + self.completed_count = 0 + self.result_timer = QTimer() + self.result_timer.timeout.connect(self.check_parallel_results) + self.result_timer.start() + + def on_orchestration_failed(self, error_msg): + """ Fallback handler if Windows permissions or pickling fails in background """ + if hasattr(self, 'multi_progress'): + self.multi_progress.close() + print(f"[CRITICAL FAILURE] Background Orchestration Failed:\n{error_msg}") - # 4. Start the GUI listener - self.completed_count = 0 - self.result_timer = QTimer() - self.result_timer.timeout.connect(self.check_parallel_results) - self.result_timer.start() def check_parallel_results(self): diff --git a/src/window/welcome.py b/src/window/welcome.py index ceb96f5..423502c 100644 --- a/src/window/welcome.py +++ b/src/window/welcome.py @@ -15,7 +15,7 @@ from src.shared.shareddata import APP_NAME, CURRENT_VERSION, CHANGELOG_URL, reso class WelcomeDialog(QDialog): - def __init__(self, parent=None, direct=True): + def __init__(self, parent=None, direct=True, first=False): super().__init__(parent) self.setWindowTitle(f"What's New - {APP_NAME.upper()}") self.setMinimumSize(550, 450) @@ -27,8 +27,10 @@ class WelcomeDialog(QDialog): logo_label = QLabel(self) # NOTE: might not work on mac and need the icns file - logo_label.setPixmap(QIcon(resource_path("icons/main.ico")).pixmap(48, 48)) - if direct: + logo_label.setPixmap(QIcon(resource_path("../../icons/main.ico")).pixmap(48, 48)) + if first: + title_label = QLabel(f"