From a9ea0efcb47604812232292559490c7a0e8aecb1 Mon Sep 17 00:00:00 2001 From: Tyler Date: Thu, 27 Aug 2026 17:50:23 -0700 Subject: [PATCH] dark mode toggle + enhancements --- flares.py | 7 +- main.py | 130 +++++++++++++++++++++--- src/analysis/participantfoldchannels.py | 3 +- src/shared/flaresbasewidget.py | 22 +++- src/window/updateoptodes.py | 102 ++++++++++++++----- 5 files changed, 211 insertions(+), 53 deletions(-) diff --git a/flares.py b/flares.py index ee17870..7e6f61f 100644 --- a/flares.py +++ b/flares.py @@ -1239,13 +1239,16 @@ def calculate_peak_power(data: BaseRaw, time_window: int = 3, threshold: float = psp = scores.mean(axis=1) bad_channels = list(compress(cast(list[str], data.ch_names), psp < threshold)) + plot_data = data.copy() + plot_data.info["bads"] = bad_channels + existing_bads = set(data.info.get("bads", [])) data.info["bads"] = list(existing_bads | set(bad_channels)) # Determine the colors based on the threshold, and create the figures color_stops = ([0.0, threshold, threshold+0.1, threshold+0.2, 1.0], [0.0, threshold, threshold, 1.0]) - psp1, psp2 = plot_timechannel_quality_metrics(data, scores, times, color_stops, threshold, "Peak Spectral Power") - + psp1, psp2 = plot_timechannel_quality_metrics(plot_data, scores, times, color_stops, threshold, "Peak Spectral Power") + print(f"thresh: {threshold}") return list(compress(cast(list[str], getattr(data, "ch_names")), psp < threshold)), psp1, psp2 diff --git a/main.py b/main.py index 180f00f..e7546fb 100644 --- a/main.py +++ b/main.py @@ -19,6 +19,7 @@ from queue import Empty from copy import deepcopy from pathlib import Path from datetime import datetime +from functools import partial from multiprocessing import Process, current_process, freeze_support, Queue, set_start_method # External library imports @@ -28,8 +29,8 @@ 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 QEvent, Signal, Qt, QTimer -from PySide6.QtGui import QAction, QFontMetrics, QKeySequence, QIcon +from PySide6.QtCore import QEvent, QObject, Signal, Qt, QTimer +from PySide6.QtGui import QAction, QActionGroup, 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 @@ -64,6 +65,7 @@ show_welcome_dialog = false first_startup = true [Preferences] +theme = auto 2d_data_bypass = false incompatible_save_bypass = false missing_events_bypass = false @@ -321,7 +323,7 @@ SECTIONS = [ BIDS_FIELD_MAP = { "BIDS - Age": "AGE", "BIDS - Sex": "SEX", - "BIDS - Hand": "HAND", + "BIDS - Handedness": "HAND", } @@ -509,6 +511,27 @@ class CustomApplication(QApplication): return super().event(e) +class ThemeChangeWatcher(QObject): + def __init__(self, main_window): + super().__init__() + self.main_window = main_window + self._theme_timer = QTimer(self) + self._theme_timer.setSingleShot(True) + self._theme_timer.setInterval(100) + self._theme_timer.timeout.connect(self._apply_theme) + + def eventFilter(self, obj, event): + if event.type() == QEvent.Type.ApplicationPaletteChange: + # Restart the timer instead of updating immediately. + # Multiple palette-change events collapse into one update. + self._theme_timer.start() + + return False + + def _apply_theme(self): + print("OS theme changed") + self.main_window.update_theme() + class MainApplication(QMainWindow): """ @@ -643,10 +666,10 @@ class MainApplication(QMainWindow): 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_container = QGroupBox("File Information") + self.top_left_container.setStyleSheet("QGroupBox { font-weight: bold; }") + self.top_left_container.setMinimumHeight(240) + top_left_layout = QHBoxLayout(self.top_left_container) self.top_left_widget = QTextEdit() self.top_left_widget.setReadOnly(True) @@ -659,22 +682,25 @@ class MainApplication(QMainWindow): font_metrics = QFontMetrics(self.font()) label_width = max(font_metrics.horizontalAdvance(key.capitalize()) for key in self.meta_fields) + 10 + self.meta_labels = {} + 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() + ":") + self.meta_labels[key] = label 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('Why are these useful?') - 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) + self.label_desc = QLabel('Why are these useful?') + self.label_desc.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) + self.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(self.label_desc) right_column_layout.addStretch() self.right_column_widget.hide() top_left_layout.addWidget(self.right_column_widget, stretch=1) @@ -688,7 +714,7 @@ class MainApplication(QMainWindow): 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.top_left_container) self.left_v_splitter.addWidget(self.scroll_area) self.right_container = QWidget() @@ -845,6 +871,31 @@ class MainApplication(QMainWindow): self.pref_actions = {} preferences_menu = menu_bar.addMenu("Preferences") + theme_menu = preferences_menu.addMenu("Theme") + + theme_group = QActionGroup(self) + theme_group.setExclusive(True) + + # 4. Define actions for the submenu + theme_actions = [ + ("Auto", "", "auto", resource_path("icons/warning_off_24dp_1F1F1F.svg"), "theme_auto"), + ("Light", "", "light", resource_path("icons/warning_off_24dp_1F1F1F.svg"), "theme_light"), + ("Dark", "", "dark", resource_path("icons/warning_off_24dp_1F1F1F.svg"), "theme_dark"), + ] + + for name, shortcut, mode, icon, config_key in theme_actions: + # Use partial to pass 'mode' to self.theme_change_func on click + slot = partial(self.theme_change_func, mode) + + action = make_action(name, shortcut, slot, icon=icon, checkable=True) + theme_menu.addAction(action) + theme_group.addAction(action) + + self.pref_actions[config_key] = action + + # Set default selection (e.g., Auto) + self.pref_actions["theme_auto"].setChecked(True) + 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"), @@ -871,6 +922,31 @@ class MainApplication(QMainWindow): self.statusbar.showMessage("Ready") + def update_theme(self): + text = self.label_desc.text() + self.label_desc.setText("") + self.label_desc.setText(text) + + widgets = [ + self.label_desc, + self.top_left_widget, + self.right_column_widget, + self.top_left_container, + ] + + widgets.extend(self.meta_fields.values()) + widgets.extend(self.meta_labels.values()) + + for widget in widgets: + widget.style().unpolish(widget) + widget.style().polish(widget) + widget.update() + + for section in self.param_sections: + print("hi") + section.update_theme_colors() + + def update_sections(self, index): self.current_section_index = index @@ -1157,6 +1233,19 @@ class MainApplication(QMainWindow): self.top_left_widget.paste() # Trigger paste self.statusbar.showMessage("Pasted from clipboard") # Show status message + def theme_change_func(self, mode): + app = QApplication.instance() + style_hints = app.styleHints() + + if mode == "auto": + style_hints.setColorScheme(Qt.ColorScheme.Unknown) + + elif mode == "light": + style_hints.setColorScheme(Qt.ColorScheme.Light) + + elif mode == "dark": + style_hints.setColorScheme(Qt.ColorScheme.Dark) + def _update_config_setting(self, group, key, value): """Helper to update memory configuration and save to disk.""" # configparser expects string values @@ -1855,7 +1944,7 @@ class MainApplication(QMainWindow): 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.)") + msg.setText("Processing new data will clear the current analysis and close all other windows. 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) @@ -1871,11 +1960,18 @@ class MainApplication(QMainWindow): else: return - self.button3.setVisible(False) + self.button3.setVisible(False) - for item in DATA_SCHEMA: - setattr(self, item["key"], {}) + for item in DATA_SCHEMA: + setattr(self, item["key"], {}) + + for bubble in self.bubble_widgets.values(): + bubble.reset() + for widget in QApplication.topLevelWidgets(): + if widget is not self and widget.isVisible(): + widget.close() + self.button1.clicked.disconnect(self.on_run_task) self.button1.setText("Cancel") self.button1.clicked.connect(self.cancel_task) @@ -2673,6 +2769,8 @@ if __name__ == "__main__": 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) + app.theme_watcher = ThemeChangeWatcher(window) + app.installEventFilter(app.theme_watcher) app.file_open_requested.connect(window.project_manager.load_project) window.setWindowIcon(QIcon(resource_path(f"icons/main.{icon_ext}"))) window.show() diff --git a/src/analysis/participantfoldchannels.py b/src/analysis/participantfoldchannels.py index 846e177..6b6c6cc 100644 --- a/src/analysis/participantfoldchannels.py +++ b/src/analysis/participantfoldchannels.py @@ -831,11 +831,10 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget): self.scroll_area.setWidgetResizable(True) self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) - self.scroll_area.setStyleSheet("QScrollArea { border: none; background-color: #f1f3f5; }") + self.scroll_area.setStyleSheet("QScrollArea { border: none; }") # 2. Create the central canvas widget that inside the scroll block self.scroll_content_widget = QWidget() - self.scroll_content_widget.setStyleSheet("background-color: #f1f3f5;") # 3. Establish the strict 3-column layout grid engine self.grid_layout = QGridLayout(self.scroll_content_widget) diff --git a/src/shared/flaresbasewidget.py b/src/shared/flaresbasewidget.py index 2162ecf..767cc49 100644 --- a/src/shared/flaresbasewidget.py +++ b/src/shared/flaresbasewidget.py @@ -14,7 +14,7 @@ import pandas as pd from pandas import DataFrame from PySide6.QtWidgets import QApplication, QComboBox, QDialog, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QListView, QMessageBox, QPushButton, QScrollArea, QSizePolicy, QVBoxLayout, QWidget, QFrame, QSpinBox, QFileDialog -from PySide6.QtGui import QStandardItemModel, QStandardItem, QPixmap, QIntValidator, QDoubleValidator +from PySide6.QtGui import QPalette, QStandardItemModel, QStandardItem, QPixmap, QIntValidator, QDoubleValidator from PySide6.QtCore import QEvent, QPoint, QSize, QTimer, Qt, Signal from src.shared.shareddata import APP_NAME, PIPELINE_STAGES @@ -323,10 +323,10 @@ class ParamSection(QWidget): self._updating_checkstates = False # Title label - title_label = QLabel(section_data["title"]) - title_label.setStyleSheet("font-weight: bold; font-size: 14px; margin-top: 10px; margin-bottom: 5px;") - layout.addWidget(title_label) - self.header_widgets.append(title_label) + self.title_label = QLabel(section_data["title"]) + self.title_label.setStyleSheet("font-weight: bold; font-size: 14px; margin-top: 10px; margin-bottom: 5px;") + layout.addWidget(self.title_label) + self.header_widgets.append(self.title_label) # Horizontal line line = QFrame() @@ -444,6 +444,10 @@ class ParamSection(QWidget): self.update_dependencies() + def update_theme_colors(self): + self.title_label.style().unpolish(self.title_label) + self.title_label.style().polish(self.title_label) + def is_different(self, val_a, val_b, param_type=None): """Compares two parameter values to determine if they differ.""" type_str = str(param_type).lower() @@ -890,6 +894,7 @@ class ProgressBubble(QWidget): border-radius: 10px; padding: 8px 12px; background-color: #e0f0ff; + color: #000000; } """) @@ -985,6 +990,13 @@ class ProgressBubble(QWidget): self.spinner_idx += 1 self._update_label_text() + def reset(self): + """Resets the bubble's visual state, progress bars, timers, and labels back to initial state.""" + # Reset progress metrics and visual rectangles back to white + self.current_step = 0 + for rect in self.rects: + rect.setStyleSheet("background-color: white; border: 1px solid gray;") + class FlaresBaseWidget(QWidget): def __init__(self, caller): diff --git a/src/window/updateoptodes.py b/src/window/updateoptodes.py index 5c8be66..6a09b55 100644 --- a/src/window/updateoptodes.py +++ b/src/window/updateoptodes.py @@ -34,7 +34,9 @@ class UpdateOptodesWindow(QWidget): self.setWindowTitle(f"Update optode positions - {APP_NAME.upper()}") self.resize(760, 200) - self.label_file_a = QLabel("SNIRF file:") + self.selected_snirf_files: list[str] = [] + + self.label_file_a = QLabel("SNIRF files:") self.line_edit_file_a = QLineEdit() self.line_edit_file_a.setReadOnly(True) self.btn_browse_a = QPushButton("Browse .snirf") @@ -171,9 +173,16 @@ class UpdateOptodesWindow(QWidget): msg.exec() def browse_file_a(self) -> None: - file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)") - if file_path: - self.line_edit_file_a.setText(file_path) + file_paths, _ = QFileDialog.getOpenFileNames( + self, + "Select SNIRF Files", + "", + "SNIRF Files (*.snirf)" + ) + + if file_paths: + self.selected_snirf_files = file_paths + self.line_edit_file_a.setText("; ".join(Path(p).name for p in file_paths)) def browse_file_b(self) -> None: file_path, _ = QFileDialog.getOpenFileName(self, "Select File", "", "Supported Files (*.txt *.xlsx)") @@ -181,6 +190,7 @@ class UpdateOptodesWindow(QWidget): self.line_edit_file_b.setText(file_path) def clear_files(self) -> None: + self.selected_snirf_files.clear() self.line_edit_file_a.clear() self.line_edit_file_b.clear() @@ -189,40 +199,76 @@ class UpdateOptodesWindow(QWidget): file_b = self.line_edit_file_b.text() suffix = self.line_edit_suffix.text().strip() or "flare" - if not file_a: - QMessageBox.critical(self, "Missing File", "Please select a SNIRF file.") + if not self.selected_snirf_files: + QMessageBox.critical(self, "Missing File", "Please select at least one SNIRF file.") return if not file_b: - QMessageBox.critical(self, "Missing File", "Please select a TXT file.") + QMessageBox.critical(self, "Missing File", "Please select a TXT or XLSX digitization file.") return - # Get original filename without extension - base_name = os.path.splitext(os.path.basename(file_a))[0] - suggested_name = f"{base_name}_{suffix}.snirf" - - # Open save dialog with default name - save_path, _ = QFileDialog.getSaveFileName( + output_dir = QFileDialog.getExistingDirectory( self, - "Save SNIRF File As", - suggested_name, - "SNIRF Files (*.snirf)" + "Select Output Directory" ) - if not save_path: + if not output_dir: print("Save cancelled.") return - # Ensure .snirf extension - if not save_path.lower().endswith(".snirf"): - save_path += ".snirf" + output_path = Path(output_dir) - try: - self.update_optode_positions(file_a=file_a, file_b=file_b, save_path=save_path) - except Exception as e: - QMessageBox.critical(self, "Error", f"Failed to write file:\n{e}") - return - - QMessageBox.information(self, "File Saved", f"File was saved to:\n{save_path}") + successful_files: list[str] = [] + failed_files: list[str] = [] + + for file_a in self.selected_snirf_files: + input_path = Path(file_a) + + # Keep original filename and independently add suffix + save_path = output_path / f"{input_path.stem}_{suffix}.snirf" + + try: + self.update_optode_positions( + file_a=file_a, + file_b=file_b, + save_path=save_path + ) + + successful_files.append(save_path.name) + + except Exception as e: + failed_files.append( + f"{input_path.name}: {e}" + ) + + # Build summary + message_parts: list[str] = [] + + if successful_files: + message_parts.append( + f"Successfully processed {len(successful_files)} " + f"SNIRF file(s):\n\n" + + "\n".join(successful_files) + ) + + if failed_files: + message_parts.append( + f"Failed to process {len(failed_files)} " + f"SNIRF file(s):\n\n" + + "\n".join(failed_files) + ) + + if failed_files: + QMessageBox.warning( + self, + "Processing Complete", + "\n\n".join(message_parts) + ) + else: + QMessageBox.information( + self, + "Files Saved", + "\n\n".join(message_parts) + ) def update_optode_positions( self, @@ -306,6 +352,6 @@ class UpdateOptodesWindow(QWidget): initial_montage = make_dig_montage(ch_pos=ch_positions, nasion=fiducials.get('nz'), lpa=fiducials.get('lpa'), rpa=fiducials.get('rpa'), coord_frame='head') # type: ignore # Read the SNIRF file, set the montage, and write it back - raw = read_raw_snirf(file_a, preload=True) + raw = read_raw_snirf(str(file_a), preload=True) raw.set_montage(initial_montage) # type: ignore write_raw_snirf(raw, save_path) \ No newline at end of file