6 Commits
Author SHA1 Message Date
tyler d1c9346e57 changelog 2026-10-14 20:18:48 -07:00
tyler 63785d3a55 Merge branch 'main' of https://git.research.dezeeuw.ca/tyler/flares 2026-10-14 20:18:45 -07:00
tyler 3a8ffbdf70 mainly whitespace, but also some generic qol improvements 2026-10-14 20:17:23 -07:00
tyler 4059a3ef93 v2 2026-09-10 13:56:07 -07:00
tyler 66242c1a5c changelog for hotfix 2026-09-10 13:55:58 -07:00
tyler 2a5e3f5606 fix plugins on macOS 2026-09-10 13:32:46 -07:00
13 changed files with 1080 additions and 401 deletions

No files matched your search

+24
View File
@@ -1,3 +1,27 @@
# Version 1.8.0
- Plugin Manager now supports checking and downloading plugin updates to ensure plugins can be updated
- Plugin Manager now properly uninstalls plugins when requested rather than doing nothing
- Installing plugins now displays a progress bar when downloading and no longer hangs the application when downloading a big plugin
- Project loading now displays a loading message when loading big projects and no longer hangs the application when loading
- New preferences options and menus are now available! All existing preferences have been moved into new sub categories
- The UI preference category now has the Show Advanced Parameters preference
- The Updates preference category has two ney options to prevent the automatic checking of application or plugin updates
- The Theme category has remain untouched and all other preferences have been moved to the Processing category
- Added a new preference in the Processing category called "Separate Events". This allows events to have their own values, and not be collapsed into a single value
- Export To CSV Viewer export options now perform the actions they claim rather than having no functionality
- Logs on a per participant basis are now available to be viewed from within the application
- They can be accessed by right clicking a participant bubble and selecting "Show Logs". Logs will be empty if they have not been processed
- Fixed a regression where opening a folder would no longer load all the files in that folder
- Improved code stability and functionality
# Version 1.7.2
- Fixed the Linux build not starting if shared library files were missing from the host machine
- Fixed the macOS version not loading plugins due to not having an HTTPS certificate
# Version 1.7.1
- Added support for Plugins! Plugins can be downloaded from the official repository or from custom ones. Support for creating a plugin can be found on the wiki
+19
View File
@@ -1,3 +1,22 @@
# Version 1.7.2
- Fixed the Linux build not starting if shared library files were missing from the host machine
- Fixed the macOS version not loading plugins due to not having an HTTPS certificate
# Version 1.7.1
- Added support for Plugins! Plugins can be downloaded from the official repository or from custom ones. Support for creating a plugin can be found on the wiki
- Files and folders can now be dragged and dropped into the loaded files area to be loaded instead of relying on the file menu
- Fixed an issue where shadow snirf files would attempt to be loaded by the application. Fixes [Issue 92](https://git.research.dezeeuw.ca/tyler/flares/issues/92)
- Fixed an issue where the application could not automatically update between versions 1.5.1 to 1.7.1. Sorry! 1.7.1 upgrading onward should be fixed and more robust
- Fixed an issue where some log files would not generate, or would generate in an incorrect location
- Fixed an issue where updating events in a snirf file would not release memory properly
- Fixed an issue where loading multiple files in quick succession could cause some of the files to never finish loading
- Fixed an issue where loading a saved project, pushing clear, and re-opening snirf files would cause them to appear incorrectly in the loaded files area
- Fixed a crucial bug where short channels were still being sent to the donor pool to assist in interpolating long channels from. Fixes [Issue 80](https://git.research.dezeeuw.ca/tyler/flares/issues/80)
# Version 1.7.0
- This is potentially a save-changing release due to adding more data into the save file. Please update your project files to ensure compatibility
+58 -19
View File
@@ -108,7 +108,6 @@ from mne_connectivity import envelope_correlation, spectral_connectivity_epochs,
from src.shared.shareddata import PLATFORM_NAME, resource_path, get_app_dir
PRIMARY_COLORS = {
"SCI only": "skyblue", # Scalp Coupling Index (Standard MNE)
"SNR only": "lightgreen", # Signal-to-Noise Ratio (Original)
@@ -301,6 +300,8 @@ GROUP: str = "Default"
FOLDING_BYP: bool = False
SEPARATE_EVENTS: bool = False
FEATURE_1: bool = False
FEATURE_2: bool = False
@@ -326,7 +327,6 @@ logging.basicConfig(
logger = logging.getLogger()
def set_config_me(config: dict[str, Any]) -> None:
"""
Validates and applies the given configuration dictionary.
@@ -422,6 +422,27 @@ def gui_entry(config: dict[str, Any], gui_queue: mp.Queue, progress_queue: mp.Qu
def process_participant_worker(file_path, file_params, file_metadata, result_queue, progress_queue):
file_start = time.time()
stage_tracker = {"value": 0.0}
class QueueStreamAlt:
def __init__(self, fp, q):
self.fp = fp
self.q = q
def write(self, msg):
if msg and self.q:
try:
self.q.put(("log", self.fp, msg))
except Exception:
pass
def flush(self):
pass
old_stdout = sys.stdout
old_stderr = sys.stderr
sys.stdout = QueueStreamAlt(file_path, progress_queue)
sys.stderr = QueueStreamAlt(file_path, progress_queue)
try:
set_config_me(file_params)
set_metadata(file_path, file_metadata)
@@ -446,6 +467,8 @@ def process_participant_worker(file_path, file_params, file_metadata, result_que
pass
finally:
sys.stdout = old_stdout
sys.stderr = old_stderr
try:
plt.close('all')
gc.collect()
@@ -2101,7 +2124,6 @@ def plot_glm_results(file_path, raw_haemo, glm_est, design_matrix):
axes[2].set_ylabel("Haemoglobin (ΔμMol)")
axes[2].legend(["Oxyhaemoglobin", "Deoxyhaemoglobin"])
print(f"Number of FIR bins: {len(vals_hbo)}")
print(f"Mean theta (HbO): {np.mean(vals_hbo):.4f}")
print(f"Sum of theta (HbO): {np.sum(vals_hbo):.4f}")
@@ -2372,7 +2394,6 @@ def brain_3d_visualization(
model_df = cast(DataFrame, statsmodels_to_results(ch_model, order=ch_summary["ch_name"].unique()))
valid_channels = ch_summary["ch_name"].unique().tolist() # type: ignore
raw_for_plot = raw_haemo.copy().pick(picks=valid_channels) # type: ignore
@@ -2476,7 +2497,6 @@ def brain_landmarks_3d(raw_haemo: BaseRaw, show_optodes: Literal['sensors', 'lab
scale=0.002
)
if show_brodmann:# Add Brodmann labels
labels = cast(list[Label], read_labels_from_annot("fsaverage", "PALS_B12_Brodmann", "lh", verbose=False)) # type: ignore
@@ -5990,6 +6010,24 @@ def process_participant(file_path, file_start, progress_callback=None):
logger.info("Step 21 Completed.")
step_start = lap(step_start, timings, "Step 21")
# Step 21.5: Make annotations trial-specific for individual trial GLM/FIR
if SEPARATE_EVENTS:
annots = raw_haemo.annotations
if len(annots) > 0:
new_descriptions = []
cond_counts = {}
for desc in annots.description:
# Optional: filter for specific task conditions if you have other markers like 'bad' or 'iseg'
cond_counts[desc] = cond_counts.get(desc, 0) + 1
new_descriptions.append(f"{desc}_trial_{cond_counts[desc]}")
# Update raw_haemo annotations with trial-specific names
raw_haemo.set_annotations(Annotations(
onset=annots.onset,
duration=annots.duration,
description=new_descriptions
))
# Step 22: Design Matrix
raw_haemo, raw_haemo_dm, df_design_matrix, fig_design_matrix = make_design_matrix(
raw_haemo=raw_haemo,
@@ -6071,7 +6109,6 @@ def process_participant(file_path, file_start, progress_callback=None):
except Exception as e:
logger.warning(f"Step 27.5 Failed to extract FIR features: {e}")
# Step 28: Finishing Up
png_queue.put(None) # sentinel
png_queue.join() # blocks only on remaining unfinished work
@@ -6404,11 +6441,6 @@ def _log_matrix_diagnostics(name: str, mat: np.ndarray) -> None:
# ============================================================================
# Shared: channel alignment across participants
# ============================================================================
@@ -6797,13 +6829,6 @@ def run_group_functional_connectivity_epochs(
def sparks_csv_export(
haemo_obj: BaseRaw,
save_path: str,
@@ -6828,7 +6853,6 @@ def sparks_csv_export(
df.to_csv(save_path, index=False)
def peak_power_fast(
raw,
time_window=10,
@@ -7413,6 +7437,21 @@ def normalize_roi_map(roi_map):
return {}
def extract_epoch_features(epochs, file_path, window=(4.0, 12.0)):
"""One row per (epoch, channel): mean haemoglobin amplitude within `window` seconds."""
tmask = (epochs.times >= window[0]) & (epochs.times <= window[1])
feat = epochs.get_data()[:, :, tmask].mean(axis=2) * 1e6 # (n_epochs, n_channels), µM
id_to_name = {v: k for k, v in epochs.event_id.items()}
df = pd.DataFrame(feat, columns=epochs.ch_names)
df["Condition"] = [id_to_name[e] for e in epochs.events[:, 2]]
df["trial"] = epochs.selection # index of the epoch in the original event list (survives rejection)
long = df.melt(id_vars=["Condition", "trial"], var_name="ch_name", value_name="theta")
long["Chroma"] = long["ch_name"].map(dict(zip(epochs.ch_names, epochs.get_channel_types())))
long["ID"] = file_path
return long[["ch_name", "Chroma", "ID", "Condition", "trial", "theta"]]
if __name__ == "__main__":
print("This file has no functionality when not used in tandem with the FLARES application.")
+389 -214
View File
@@ -15,6 +15,7 @@ import traceback
import subprocess
import configparser
import concurrent.futures
from typing import Any
from queue import Empty
from copy import deepcopy
from pathlib import Path
@@ -34,11 +35,11 @@ from PySide6.QtWidgets import (
QPushButton, QMainWindow, QLabel, QLineEdit, QGroupBox, QDialog, QMenu, QSpinBox, QTableWidget, QTableWidgetItem
)
from PySide6.QtCore import QEvent, QObject, Signal, Qt, QTimer
from PySide6.QtGui import QAction, QActionGroup, QFontMetrics, QKeySequence, QIcon
from PySide6.QtGui import QAction, QActionGroup, QFontMetrics, QKeySequence, QIcon, QTextCursor
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 plugin_manager import PluginManager
from plugin_manager import PluginManager, StartupUpdateWorker
from project_manager import ProjectManager
from src.window.about import AboutWindow
from src.window.plugins import PluginsWindow
@@ -49,7 +50,7 @@ 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, get_app_dir
from src.shared.shareddata import API_URL, API_URL_SECONDARY, APP_NAME, CURRENT_VERSION, PLATFORM_NAME, DATA_SCHEMA, PLUGINS_URL, get_app_dir
from startup_args import parse_startup_args
from updater import finish_update_if_needed, UpdateManager, LocalPendingUpdateCheckThread
@@ -71,13 +72,19 @@ show_welcome_dialog = false
first_startup = true
[Preferences]
theme = auto
2d_data_bypass = false
incompatible_save_bypass = false
missing_events_bypass = false
analysis_clearing_bypass = false
folding_bypass = false
advanced_parameters = false
theme_mode = auto
processing_2d_data_bypass = false
processing_incompatible_save_bypass = false
processing_missing_events_bypass = false
processing_analysis_clearing_bypass = false
processing_folding_bypass = false
processing_separate_events = false
ui_advanced_parameters = false
updates_check_plugins_automatic = true
updates_check_app_automatic = true
[Terminal]
@@ -85,6 +92,7 @@ advanced_parameters = false
"""
# Selectable parameters on the right side of the window
SECTIONS = [
{
@@ -334,6 +342,33 @@ BIDS_FIELD_MAP = {
class LogWindow(QDialog):
def __init__(self, title, file_path, log_text, parent=None):
super().__init__(parent)
self.file_path = file_path
self.setWindowTitle(title)
self.resize(700, 500)
layout = QVBoxLayout(self)
self.text_edit = QTextEdit(self)
self.text_edit.setReadOnly(True)
self.text_edit.setPlainText(log_text)
font = self.text_edit.font()
font.setFamily("Courier")
self.text_edit.setFont(font)
layout.addWidget(self.text_edit)
def append_log(self, text):
# Auto-scroll to the bottom and insert incoming text chunk
cursor = self.text_edit.textCursor()
cursor.movePosition(QTextCursor.MoveOperation.End)
self.text_edit.setTextCursor(cursor)
self.text_edit.insertPlainText(text)
class GroupAssignmentDialog(QDialog):
"""Dialog allowing users to create groups and assign unique metadata values to them."""
@@ -366,7 +401,6 @@ class GroupAssignmentDialog(QDialog):
def _init_ui(self):
layout = QVBoxLayout(self)
# Field selector
field_box_layout = QHBoxLayout()
field_box_layout.addWidget(QLabel("Group by:"))
@@ -607,25 +641,34 @@ class MainApplication(QMainWindow):
self.events_blazes = None
self.plugins = None
self.terminal = None
self.bubble_widgets = {}
self.param_sections = []
self.folder_paths = []
self.section_widget = None
self.first_run = True
self.show_status_bar = False
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.current_file = None
self.selected_paths = []
self.saved_selected_paths = []
self.files_are_dirty = False
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.participant_logs = {}
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.pending_update_version = None
self.pending_update_path = None
# are these used?
self.param_sections = []
self.folder_paths = []
self.last_clicked_bubble = None
self.project_manager = ProjectManager(self, file_cfg=file_cfg, cfg_path=cfg_path)
self.plugin_manager = PluginManager(self)
@@ -633,18 +676,35 @@ class MainApplication(QMainWindow):
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
config_mappings = {
"View": ["status_bar"],
"Preferences": [
"theme_mode",
"processing_2d_data_bypass",
"processing_incompatible_save_bypass",
"processing_missing_events_bypass",
"processing_analysis_clearing_bypass",
"processing_folding_bypass",
"processing_separate_events",
"ui_advanced_parameters",
"updates_check_plugins_automatic",
"updates_check_app_automatic",
]
}
for section, keys in config_mappings.items():
for key in keys:
if section == "Preferences" and key == "theme_mode":
val = file_cfg.get(section, key, fallback="auto")
elif key in ["status_bar", "updates_check_plugins_automatic", "updates_check_app_automatic"]:
val = file_cfg.getboolean(section, key, fallback=True)
else:
val = file_cfg.getboolean(section, key, fallback=False)
setattr(self, key, val)
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,
@@ -658,17 +718,23 @@ class MainApplication(QMainWindow):
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()
# Conditionally start background network threads using the loaded attributes
if self.updates_check_app_automatic:
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()
else:
print("[MainApplication] App automatic update check is disabled in preferences.")
if self.updates_check_plugins_automatic:
self._startup_update_worker = StartupUpdateWorker(self.plugin_manager, [PLUGINS_URL])
self._startup_update_worker.updates_checked.connect(self._on_startup_updates_found)
self._startup_update_worker.start()
else:
print("[MainApplication] Plugin automatic update check is disabled in preferences.")
self.plugin_manager.reload_plugins()
@@ -704,7 +770,6 @@ class MainApplication(QMainWindow):
self.project_manager.load_project(file_to_open)
def init_ui(self):
central = QWidget()
self.setCentralWidget(central)
@@ -830,19 +895,41 @@ class MainApplication(QMainWindow):
# for stat in top_stats[:10]:
# print(stat)
def create_menu_bar(self):
'''Menu Bar at the top of the screen'''
def _on_startup_updates_found(self, outdated_plugins: list[dict[str, Any]]) -> None:
"""Safely displays the update popup on the main thread once the background check finishes."""
if not outdated_plugins:
return
update_details = "\n".join(
f"• {p['name']}: Installed v{p['version']} ➔ New v{p['remote_version']}"
for p in outdated_plugins
)
summary_msg = f"The following plugin updates are available:\n\n{update_details}\n\nYou can update them in the Plugin Manager."
QMessageBox.information(
self,
"Plugin Updates Available",
summary_msg
)
def create_menu_bar(self):
"""Menu Bar at the top of the screen built dynamically from a unified schema."""
menu_bar = self.menuBar()
self.statusbar = self.statusBar()
self.view_actions = {}
self.pref_actions = {}
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:
# Checkable actions use 'toggled' to reliably pass the boolean state
action.toggled.connect(slot)
else:
action.triggered.connect(slot)
if checkable:
action.setCheckable(True)
action.setChecked(checked)
@@ -850,140 +937,142 @@ class MainApplication(QMainWindow):
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")),
# Unified Menu Bar Schema
MENU_BAR_SCHEMA = [
# === FILE MENU ===
{"menu": "File", "name": "Open File...", "shortcut": "Ctrl+O", "slot": self.project_manager.open_file_dialog, "icon": resource_path("icons/file_open_24dp_1F1F1F.svg")},
{"menu": "File", "name": "Open Folder...", "shortcut": "Ctrl+Alt+O", "slot": self.project_manager.open_folder_dialog, "icon": resource_path("icons/folder_24dp_1F1F1F.svg")},
{"menu": "File", "type": "recent_files"},
{"menu": "File", "type": "separator"},
{"menu": "File", "name": "Load Project...", "shortcut": "Ctrl+L", "slot": self.project_manager.load_project_dialog, "icon": resource_path("icons/article_24dp_1F1F1F.svg")},
{"menu": "File", "type": "recent_projects"},
{"menu": "File", "type": "separator"},
{"menu": "File", "name": "Save Project...", "shortcut": "Ctrl+S", "slot": lambda: self.project_manager.save_project(ask=False), "icon": resource_path("icons/save_24dp_1F1F1F.svg")},
{"menu": "File", "name": "Save Project As...", "shortcut": "Ctrl+Shift+S", "slot": lambda: self.project_manager.save_project(ask=True), "icon": resource_path("icons/save_as_24dp_1F1F1F.svg")},
{"menu": "File", "type": "separator"},
{"menu": "File", "name": "Exit", "shortcut": "Ctrl+Q", "slot": QApplication.instance().quit, "icon": resource_path("icons/exit_to_app_24dp_1F1F1F.svg")},
# === EDIT MENU ===
{"menu": "Edit", "name": "Cut", "shortcut": "Ctrl+X", "slot": self.cut_text, "icon": resource_path("icons/content_cut_24dp_1F1F1F.svg")},
{"menu": "Edit", "name": "Copy", "shortcut": "Ctrl+C", "slot": self.copy_text, "icon": resource_path("icons/content_copy_24dp_1F1F1F.svg")},
{"menu": "Edit", "name": "Paste", "shortcut": "Ctrl+V", "slot": self.paste_text, "icon": resource_path("icons/content_paste_24dp_1F1F1F.svg")},
# === VIEW MENU ===
{"menu": "View", "name": "Toggle Status Bar", "shortcut": "", "slot": self.toggle_statusbar, "checkable": True, "checked": True, "icon": resource_path("icons/short_text_24dp_1F1F1F.svg"), "key": "statusbar"},
{"menu": "View", "type": "separator"},
{"menu": "View", "name": "Reset Window Layout", "shortcut": "Ctrl+Shift+R", "slot": self.reset_window_layout, "icon": resource_path("icons/grid_layout_side_24dp_1F1F1F.svg")},
# === OPTIONS MENU ===
{"menu": "Options", "name": "User Guide", "shortcut": "F1", "slot": self.user_guide, "icon": resource_path("icons/help_24dp_1F1F1F.svg")},
{"menu": "Options", "name": "Regroup Files from Metadata", "shortcut": "F3", "slot": self.group_metadata, "icon": resource_path("icons/help_24dp_1F1F1F.svg")},
{"menu": "Options", "name": "Check for Updates", "shortcut": "F5", "slot": self.updater.manual_check_for_updates, "icon": resource_path("icons/update_24dp_1F1F1F.svg")},
{"menu": "Options", "name": "Show Update Changelog", "shortcut": "F6", "slot": self.show_update_changelog, "icon": resource_path("icons/article_shortcut_24dp_1F1F1F.svg")},
{"menu": "Options", "type": "separator"},
{"menu": "Options", "name": "Update events in snirf file (BORIS)...", "shortcut": "F7", "slot": self.update_event_markers, "icon": resource_path("icons/upgrade_24dp_1F1F1F.svg")},
{"menu": "Options", "name": "Update events in snirf file (BLAZES)...", "shortcut": "F8", "slot": self.update_event_markers_blazes, "icon": resource_path("icons/upgrade_24dp_1F1F1F.svg")},
{"menu": "Options", "name": "Update optodes in snirf file...", "shortcut": "F9", "slot": self.update_optode_positions, "icon": resource_path("icons/upgrade_24dp_1F1F1F.svg")},
{"menu": "Options", "type": "separator"},
{"menu": "Options", "name": "Reset to Default Configuration", "shortcut": "F10", "slot": self.reset_to_default_configuration, "icon": resource_path("icons/reset_settings_24dp_1F1F1F.svg")},
{"menu": "Options", "type": "separator"},
{"menu": "Options", "name": "About", "shortcut": "F12", "slot": self.about_window, "icon": resource_path("icons/info_24dp_1F1F1F.svg")},
# === PREFERENCES: THEME ===
{"menu": "Preferences", "submenu": "Theme", "name": "Auto", "mode": "auto", "slot": partial(self.theme_change_func, "auto"), "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg"), "key": "theme_auto", "group": "theme", "checkable": True},
{"menu": "Preferences", "submenu": "Theme", "name": "Light", "mode": "light", "slot": partial(self.theme_change_func, "light"), "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg"), "key": "theme_light", "group": "theme", "checkable": True},
{"menu": "Preferences", "submenu": "Theme", "name": "Dark", "mode": "dark", "slot": partial(self.theme_change_func, "dark"), "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg"), "key": "theme_dark", "group": "theme", "checkable": True},
# === PREFERENCES: PROCESSING ===
{"menu": "Preferences", "submenu": "Processing", "name": "2D Data Bypass", "key": "processing_2d_data_bypass", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")},
{"menu": "Preferences", "submenu": "Processing", "name": "Incompatible Save Bypass", "key": "processing_incompatible_save_bypass", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")},
{"menu": "Preferences", "submenu": "Processing", "name": "Missing Events Bypass", "key": "processing_missing_events_bypass", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")},
{"menu": "Preferences", "submenu": "Processing", "name": "Analysis Clearing Bypass", "key": "processing_analysis_clearing_bypass", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")},
{"menu": "Preferences", "submenu": "Processing", "name": "Folding Bypass", "key": "processing_folding_bypass", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")},
{"menu": "Preferences", "submenu": "Processing", "name": "Separate Events", "key": "processing_separate_events", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")},
# === PREFERENCES: UI ===
{"menu": "Preferences", "submenu": "UI", "name": "Show Advanced Parameters", "key": "ui_advanced_parameters", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")},
# === PREFERENCES: UPDATES ===
{"menu": "Preferences", "submenu": "Updates", "name": "Automatic App Updates", "key": "updates_check_app_automatic", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")},
{"menu": "Preferences", "submenu": "Updates", "name": "Automatic Plugin Updates", "key": "updates_check_plugins_automatic", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")},
# === PLUGINS MENU ===
{"menu": "Plugins", "name": "Plugin Manager", "shortcut": "Ctrl+Alt+P", "slot": self.plugins_gui, "icon": resource_path("icons/terminal_24dp_1F1F1F.svg")},
# === TERMINAL MENU ===
{"menu": "Terminal", "name": "New Terminal", "shortcut": "Ctrl+Alt+T", "slot": self.terminal_gui, "icon": resource_path("icons/terminal_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
self.view_actions = {}
view_menu = menu_bar.addMenu("View")
toggle_statusbar_action = make_action("Toggle Status Bar", "", self.toggle_statusbar, True, True, resource_path("icons/short_text_24dp_1F1F1F.svg"))
view_menu.addAction(toggle_statusbar_action)
self.view_actions["statusbar"] = toggle_statusbar_action
# 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")
theme_menu = preferences_menu.addMenu("Theme")
# Container references for submenus and groups
menus = {}
submenus = {}
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 item in MENU_BAR_SCHEMA:
menu_name = item["menu"]
if menu_name not in menus:
menus[menu_name] = menu_bar.addMenu(menu_name)
parent_menu = menus[menu_name]
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)
# Handle Submenus (e.g. Preferences -> Theme/Processing/Updates)
if "submenu" in item:
sub_name = item["submenu"]
sub_key = f"{menu_name}_{sub_name}"
if sub_key not in submenus:
submenus[sub_key] = parent_menu.addMenu(sub_name)
parent_menu = submenus[sub_key]
action = make_action(name, shortcut, slot, icon=icon, checkable=True)
theme_menu.addAction(action)
theme_group.addAction(action)
# Handle Special Types (Separators & Dynamic Submenus)
item_type = item.get("type")
if item_type == "separator":
parent_menu.addSeparator()
continue
elif item_type == "recent_files":
self.recent_files_menu = parent_menu.addMenu("Recent Files")
self.recent_files_menu.setIcon(QIcon(resource_path("icons/history_24dp_1F1F1F.svg")))
parent_menu.addSeparator()
continue
elif item_type == "recent_projects":
self.recent_projects_menu = parent_menu.addMenu("Recent Projects")
self.recent_projects_menu.setIcon(QIcon(resource_path("icons/history_2_24dp_1F1F1F.svg")))
parent_menu.addSeparator()
continue
self.pref_actions[config_key] = action
# Handle Normal/Preference/View Actions
name = item["name"]
shortcut = item.get("shortcut", "")
key = item.get("key")
# Set default selection (e.g., Auto)
self.pref_actions["theme_auto"].setChecked(True)
# If it's a preference toggle item without an explicit slot, bind it to _set_preference
slot = item.get("slot")
checkable = item.get("checkable", False)
checked = item.get("checked", False)
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"),
]
if not slot and key and "theme_" not in key:
slot = partial(self._set_preference, key)
checkable = True
checked = getattr(self, key, False)
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
action = make_action(name, shortcut, slot, checkable=checkable, checked=checked, icon=item.get("icon"))
parent_menu.addAction(action)
plugins_menu = menu_bar.addMenu("Plugins")
plugins_actions = [
("Plugin Manager", "Ctrl+Alt+P", self.plugins_gui, resource_path("icons/terminal_24dp_1F1F1F.svg")),
]
for name, shortcut, slot, icon in plugins_actions:
plugins_menu.addAction(make_action(name, shortcut, slot, icon=icon))
# Register into appropriate tracking dictionaries
if key == "statusbar":
self.view_actions["statusbar"] = action
elif item.get("group") == "theme":
theme_group.addAction(action)
self.pref_actions[key] = action
elif key:
self.pref_actions[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))
# Set default theme selection if available
if "theme_auto" in self.pref_actions:
self.pref_actions["theme_auto"].setChecked(True)
self.sync_app_with_config()
self.statusbar.showMessage("Ready")
def update_theme(self):
text = self.label_desc.text()
self.label_desc.setText("")
@@ -1039,7 +1128,7 @@ class MainApplication(QMainWindow):
# self.update_window_title()
# Defensive dictionary lookup for the preference action
pref_action = getattr(self, "pref_actions", {}).get("advanced_parameters")
pref_action = getattr(self, "pref_actions", {}).get("ui_advanced_parameters")
show_advanced = pref_action.isChecked() if pref_action is not None else False
# Toggle visibility on all built sections
@@ -1280,7 +1369,7 @@ class MainApplication(QMainWindow):
data_map["roi_channel_map_dict"],
data_map["fir_feature_dict"],
data_map["qc_dict"],
self.folding_bypass,
self.processing_folding_bypass,
]
self.launcher_window = ViewerLauncherWidget(*args)
@@ -1298,19 +1387,25 @@ 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):
def theme_change_func(self, mode: str, checked: bool = True) -> None:
"""Handles theme switching when toggled from the menu."""
if not checked:
return # Ignore the 'False' state when an option gets unchecked
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)
# Save the change to your config file
self.theme_mode = mode
self._update_config_setting("Preferences", "theme_mode", mode)
def _update_config_setting(self, group, key, value):
"""Helper to update memory configuration and save to disk."""
# configparser expects string values
@@ -1326,30 +1421,12 @@ class MainApplication(QMainWindow):
self._update_config_setting("View", "status_bar", checked)
self.statusbar.setVisible(checked)
def is_2d_bypass_func(self, checked):
self.is_2d_bypass = checked
self._update_config_setting("Preferences", "2d_data_bypass", checked)
def incompatable_save_bypass_func(self, checked):
self.incompatible_save_bypass = checked
self._update_config_setting("Preferences", "incompatible_save_bypass", checked)
def missing_events_bypass_func(self, checked):
self.missing_events_bypass = checked
self._update_config_setting("Preferences", "missing_events_bypass", checked)
def analysis_clearing_bypass_func(self, checked):
self.analysis_clearing_bypass = checked
self._update_config_setting("Preferences", "analysis_clearing_bypass", checked)
def folding_bypass_func(self, checked):
self.folding_bypass = checked
self._update_config_setting("Preferences", "folding_bypass", checked)
def advanced_parameters_func(self, checked):
self.advanced_parameters = checked
self._update_config_setting("Preferences", "advanced_parameters", checked)
self.update_sections(0)
def _set_preference(self, config_key: str, checked: bool) -> None:
"""Generic handler for updating boolean preferences and config settings."""
setattr(self, config_key, checked)
self._update_config_setting("Preferences", config_key, checked)
if config_key == "ui_advanced_parameters":
self.update_sections(0)
def about_window(self):
if self.about is None or not self.about.isVisible():
@@ -1454,32 +1531,65 @@ class MainApplication(QMainWindow):
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)
"""Reads values from file_cfg and updates both internal variables and UI checkmarks,
using fallbacks parsed directly from DEFAULT_CONFIG."""
self.show_status_bar = file_cfg.getboolean("View", "status_bar", fallback=False)
self.main_h_ratio = file_cfg.getfloat("View", "main_h_ratio", fallback=26 / 45)
self.left_v_ratio = file_cfg.getfloat("View", "left_v_ratio", fallback=0.30)
# Parse DEFAULT_CONFIG to use as the single source of truth for fallbacks
default_parser = configparser.ConfigParser()
default_parser.read_string(DEFAULT_CONFIG)
# 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)
# Define mapping of sections, keys, and their expected type
config_mappings = {
"View": [
("status_bar", "boolean"),
("main_h_ratio", "float"),
("left_v_ratio", "float")
],
"Preferences": [
("theme_mode", "string"),
("processing_2d_data_bypass", "boolean"),
("processing_incompatible_save_bypass", "boolean"),
("processing_missing_events_bypass", "boolean"),
("processing_analysis_clearing_bypass", "boolean"),
("processing_folding_bypass", "boolean"),
("processing_separate_events", "boolean"),
("ui_advanced_parameters", "boolean"),
("updates_check_plugins_automatic", "boolean"),
("updates_check_app_automatic", "boolean"),
]
}
if hasattr(self, 'view_actions'):
self.view_actions["statusbar"].setChecked(self.show_status_bar)
# 1. Sync internal state variables and UI checkmarks dynamically
for section, items in config_mappings.items():
for key, val_type in items:
# Retrieve the exact fallback specified in DEFAULT_CONFIG
if val_type == "boolean":
fallback = default_parser.getboolean(section, key)
val = file_cfg.getboolean(section, key, fallback=fallback)
elif val_type == "float":
fallback = default_parser.getfloat(section, key)
val = file_cfg.getfloat(section, key, fallback=fallback)
else:
fallback = default_parser.get(section, key)
val = file_cfg.get(section, key, fallback=fallback)
if self.advanced_parameters:
# Set attribute on self
setattr(self, key, val)
# Sync UI Menu checkmarks visually
if key in self.pref_actions:
self.pref_actions[key].setChecked(val)
elif key == "status_bar" and "statusbar" in self.view_actions:
self.view_actions["statusbar"].setChecked(val)
# 2. Sync Theme Radio Action Group based on theme_mode string (e.g., "auto", "light", "dark")
theme_mode = getattr(self, "theme_mode", "auto")
theme_action_key = f"theme_{theme_mode}"
if theme_action_key in self.pref_actions:
self.pref_actions[theme_action_key].setChecked(True)
# 3. Trigger dependent UI updates and handlers
if getattr(self, "ui_advanced_parameters", False):
self.update_sections(0)
if hasattr(self, 'recent_files_menu'):
@@ -1489,6 +1599,7 @@ class MainApplication(QMainWindow):
self.project_manager.update_recent_projects_menu()
QTimer.singleShot(0, self.apply_splitter_ratios)
self.statusbar.showMessage("Ready")
def save_splitter_ratios(self):
@@ -1814,6 +1925,7 @@ class MainApplication(QMainWindow):
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")
action3 = menu.addAction(QIcon(resource_path("icons/remove_24dp_1F1F1F.svg")), "View Logs")
action = menu.exec(global_pos)
if action == action1:
@@ -1899,6 +2011,36 @@ class MainApplication(QMainWindow):
self.check_files_dirty_state()
elif action == action3:
self.open_log_window(bubble)
def open_log_window(self, bubble):
target_path = bubble.file_path
log_content = getattr(self, "participant_logs", {}).get(
target_path, "No logs generated yet for this participant."
)
window_title = f"Logs — {os.path.basename(target_path)}"
if not hasattr(self, "active_log_windows"):
self.active_log_windows = []
self.active_log_windows = [
w for w in self.active_log_windows if w.isVisible()
]
# If a log window for this specific file is already open, focus it instead of opening a new one
for win in self.active_log_windows:
if getattr(win, "file_path", None) == target_path:
win.raise_()
win.activateWindow()
return
log_win = LogWindow(window_title, target_path, log_content, self)
log_win.show()
self.active_log_windows.append(log_win)
def check_files_dirty_state(self):
"""Compares currently loaded files against the saved baseline list."""
if not hasattr(self, "saved_selected_paths"):
@@ -2010,7 +2152,7 @@ class MainApplication(QMainWindow):
def on_run_task(self):
#do the check
if not self.analysis_clearing_bypass:
if not self.processing_analysis_clearing_bypass:
if self.button3.isVisible():
msg = QMessageBox(self)
msg.setWindowTitle("Confirm - FLARES")
@@ -2120,9 +2262,12 @@ class MainApplication(QMainWindow):
for section_widget in self.param_sections:
section_params = section_widget.get_param_values()
all_params.update(section_params)
if self.folding_bypass:
if self.processing_folding_bypass:
all_params['FOLDING_BYP'] = True
if self.processing_separate_events:
all_params['SEPARATE_EVENTS'] = True
collected_data = {
"SNIRF_FILES": snirf_files,
"PARAMS": all_params, # add this line
@@ -2258,6 +2403,36 @@ class MainApplication(QMainWindow):
_, file_path, step_index = msg
self.progress_update_signal.emit(file_path, step_index)
elif isinstance(msg, tuple) and len(msg) == 3 and msg[0] == "log":
_, file_path, text = msg
if not hasattr(self, 'participant_logs'):
self.participant_logs = {}
if file_path not in self.participant_logs:
self.participant_logs[file_path] = ""
self.participant_logs[file_path] += text
# Push live update to any open log window for this file
if hasattr(self, 'active_log_windows'):
for win in self.active_log_windows:
if getattr(win, 'file_path', None) == file_path:
win.append_log(text)
elif isinstance(msg, dict) and msg.get("type") == "log":
file_path = msg.get("file")
text = msg.get("message")
if file_path:
if not hasattr(self, 'participant_logs'):
self.participant_logs = {}
if file_path not in self.participant_logs:
self.participant_logs[file_path] = ""
self.participant_logs[file_path] += text
# Push live update to any open log window for this file
if hasattr(self, 'active_log_windows'):
for win in self.active_log_windows:
if getattr(win, 'file_path', None) == file_path:
win.append_log(text)
except Exception as e:
print(f"Error in timer loop: {e}")
@@ -2847,4 +3022,4 @@ if __name__ == "__main__":
window.show()
sys.exit(app.exec())
# Not 2800 lines yay!
# Not 3100 lines yay!
+105 -3
View File
@@ -9,6 +9,7 @@ License: GPL-3.0
# Built-in imports
import io
import ssl
import sys
import json
import shutil
@@ -20,13 +21,19 @@ from pathlib import Path
from typing import Any, cast
# External library imports
from PySide6.QtCore import QObject, QUrl, Signal
import certifi
from PySide6.QtCore import QObject, QThread, QUrl, Signal
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import QMainWindow, QMenu, QMessageBox
from src.shared.shareddata import APP_NAME, CURRENT_VERSION, PLATFORM_NAME
def get_ssl_context() -> ssl.SSLContext:
"""Returns a cross-platform SSL context configured with certifi's CA bundle."""
return ssl.create_default_context(cafile=certifi.where())
def get_current_platform_id() -> str:
"""Returns standardized platform identifier (win_x64, darwin_arm64, etc.)."""
sys_name = sys.platform
@@ -282,7 +289,7 @@ class PluginManager(QObject):
url,
headers={"User-Agent": f"{APP_NAME}-PluginManager"},
)
with urllib.request.urlopen(req, timeout=5) as response:
with urllib.request.urlopen(req, timeout=5, context=get_ssl_context()) as response:
if response.status == 200:
raw_data = response.read().decode("utf-8")
data = json.loads(raw_data)
@@ -305,7 +312,7 @@ class PluginManager(QObject):
headers={"User-Agent": f"{APP_NAME}-PluginManager"},
)
with urllib.request.urlopen(req, timeout=15) as response:
with urllib.request.urlopen(req, timeout=15, context=get_ssl_context()) as response:
if response.status != 200:
raise RuntimeError(f"Download failed with HTTP status code {response.status}")
@@ -322,3 +329,98 @@ class PluginManager(QObject):
if self.plugins_dir.exists():
return QDesktopServices.openUrl(QUrl.fromLocalFile(str(self.plugins_dir)))
return False
def check_for_updates(self, repo_urls: list[str]) -> list[dict[str, Any]]:
"""
Fetches remote repositories and compares server plugin versions
against installed plugins, returning the installed plugins list
enriched with update metadata.
"""
installed_plugins = self.get_installed_plugins_info()
remote_data, _ = self.fetch_remote_repositories(repo_urls)
# Index remote plugins by their ID (fallback to name if id is missing)
remote_map: dict[str, dict[str, Any]] = {}
for remote_plugin in remote_data:
plugin_id = remote_plugin.get("id") or remote_plugin.get("name")
if plugin_id:
remote_map[str(plugin_id)] = remote_plugin
# Compare versions and annotate each installed plugin dictionary
for plugin in installed_plugins:
plugin_id = str(plugin.get("id", ""))
local_version_str = str(plugin.get("version", "0.0.0"))
local_tuple = parse_version(local_version_str)
remote_plugin = remote_map.get(plugin_id)
if remote_plugin:
remote_version_str = str(remote_plugin.get("version", "0.0.0"))
remote_tuple = parse_version(remote_version_str)
if remote_tuple > local_tuple:
plugin["update_available"] = True
plugin["remote_version"] = remote_version_str
plugin["download_url"] = str(remote_plugin.get("download_url", ""))
else:
plugin["update_available"] = False
plugin["remote_version"] = remote_version_str
plugin["download_url"] = ""
else:
plugin["update_available"] = False
plugin["remote_version"] = None
plugin["download_url"] = ""
return installed_plugins
class StartupUpdateWorker(QThread):
updates_checked = Signal(list)
def __init__(self, manager: Any, repo_urls: list[str]) -> None:
super().__init__()
self.manager = manager
self.repo_urls = repo_urls
def run(self) -> None:
try:
print("[StartupWorker] Starting background update check...")
installed_plugins = self.manager.get_installed_plugins_info()
print(f"[StartupWorker] Found {len(installed_plugins)} installed local plugins.")
remote_data, all_failed = self.manager.fetch_remote_repositories(self.repo_urls)
print(f"[StartupWorker] Fetch remote complete. Failed: {all_failed}, Items fetched: {len(remote_data)}")
if all_failed or not remote_data:
print("[StartupWorker] Aborting: Remote fetch failed or returned empty data.")
return
remote_map: dict[str, dict[str, Any]] = {}
for raw_plugin in remote_data:
if isinstance(raw_plugin, dict):
remote_plugin = cast(dict[str, Any], raw_plugin)
plugin_id = remote_plugin.get("id") or remote_plugin.get("name")
if isinstance(plugin_id, str):
remote_map[plugin_id] = remote_plugin
outdated_plugins: list[dict[str, Any]] = []
for plugin in installed_plugins:
plugin_id = str(plugin.get("id", ""))
local_version_str = str(plugin.get("version", "0.0.0"))
local_tuple = parse_version(local_version_str)
remote_plugin = remote_map.get(plugin_id)
if remote_plugin:
remote_version_str = str(remote_plugin.get("version", "0.0.0"))
remote_tuple = parse_version(remote_version_str)
print(f"[StartupWorker] Comparing '{plugin.get('name')}': Local v{local_version_str} vs Remote v{remote_version_str}")
if remote_tuple > local_tuple:
plugin["remote_version"] = remote_version_str
plugin["download_url"] = str(remote_plugin.get("download_url", ""))
outdated_plugins.append(plugin)
print(f"[StartupWorker] Outdated plugins found: {len(outdated_plugins)}")
self.updates_checked.emit(outdated_plugins)
except Exception as e:
print(f"[StartupWorker] Error during startup update check: {e}")
+69 -10
View File
@@ -38,6 +38,23 @@ if TYPE_CHECKING:
from main import MainApplication
class LoadProjectThread(QThread):
finished_signal = Signal(object) # Emits the loaded project data dictionary
error_signal = Signal(str) # Emits the error message string
def __init__(self, filename: str) -> None:
super().__init__()
self.filename = filename
def run(self) -> None:
try:
with open(self.filename, "rb") as f:
project_data = pickle.load(f)
self.finished_signal.emit(project_data)
except Exception as e:
self.error_signal.emit(str(e))
class SaveProjectThread(QThread):
finished_signal = Signal(str)
error_signal = Signal(str)
@@ -56,6 +73,25 @@ class SaveProjectThread(QThread):
self.error_signal.emit(str(e))
class LoadingOverlay(QDialog):
def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
self.setModal(True)
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
layout = QVBoxLayout()
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
label = QLabel("Loading Project…")
label.setStyleSheet(
"font-size: 18px; color: white; background-color: rgba(0,0,0,150);"
" padding: 20px; border-radius: 10px;"
)
layout.addWidget(label)
self.setLayout(layout)
class SavingOverlay(QDialog):
def __init__(self, parent: Optional[QWidget] = None) -> None:
@@ -171,6 +207,8 @@ class ProjectManager:
)
return
self._load_files_into_pipeline(snirf_files)
def load_dropped_files(self, file_paths: List[str]) -> None:
"""Public entry point for handling files or folders dropped onto the UI."""
if not file_paths:
@@ -339,12 +377,31 @@ class ProjectManager:
self.load_project(filename)
def load_project(self, filename: str) -> None:
"""Loads a .flare project file into the application."""
"""Triggers asynchronous loading of a .flare project file."""
app = self.app
# 1. Show the custom loading overlay matching your saver design
self.loading_overlay = LoadingOverlay(app)
self.loading_overlay.resize(app.size())
self.loading_overlay.move(app.pos())
self.loading_overlay.show()
# 2. Fire up the background thread for pickle loading
self.loader_thread = LoadProjectThread(filename)
self.loader_thread.finished_signal.connect(
lambda data: self._handle_loaded_data(filename, data)
)
self.loader_thread.error_signal.connect(self._handle_load_error)
self.loader_thread.start()
def _handle_loaded_data(self, filename: str, data: dict) -> None:
# Close overlay
if hasattr(self, "loading_overlay"):
self.loading_overlay.close()
self.loading_overlay.deleteLater()
app = self.app
try:
with open(filename, "rb") as f:
data = pickle.load(f)
checks = [
("version", "<=1.1.7"),
("file_metadata", "<=1.2.2"),
@@ -358,7 +415,7 @@ class ProjectManager:
f"This project was saved in an earlier version of {APP_NAME.upper()} ({ver_str}) "
"and is potentially not compatible with this version. "
)
if getattr(app, "incompatible_save_bypass", False):
if getattr(app, "processing_incompatible_save_bypass", False):
QMessageBox.warning(
app, f"Warning - {APP_NAME.upper()}", msg + "Attempting load."
)
@@ -438,16 +495,19 @@ class ProjectManager:
if hasattr(app, "button3"):
app.button3.setVisible(has_data)
self.add_to_recent_projects(os.path.normpath(filename))
# Reset baselines cleanly
self.reset_all_dirty_states()
QMessageBox.information(app, "Loaded", f"Project loaded from:\n{filename}")
except Exception as e:
QMessageBox.critical(app, "Error", f"Failed to load project:\n{e}")
QMessageBox.critical(app, "Error", f"Failed to process loaded project data:\n{e}")
def _handle_load_error(self, error_msg: str) -> None:
if hasattr(self, "loading_overlay"):
self.loading_overlay.close()
self.loading_overlay.deleteLater()
QMessageBox.critical(self.app, "Error", f"Failed to read project file:\n{error_msg}")
# =========================================================================
# Project Saving (Save / Save As)
@@ -863,7 +923,6 @@ def _get_bids_demographics(snirf_path: str) -> dict[str, str]:
return {}
def extract_metadata_worker(file_name: str) -> dict[str, Any]:
"""Runs in the separate worker process. Returns a clean dict."""
+58 -56
View File
@@ -12,6 +12,7 @@ import os
from typing import Any
# External library imports
from mne import Epochs
from pandas import DataFrame
from mne.io.base import BaseRaw
@@ -27,6 +28,7 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
def __init__(
self,
haemo_dict: dict[str, BaseRaw],
epochs_dict: dict[str, Epochs],
cha_dict: dict[str, DataFrame],
df_ind_dict: dict[str, DataFrame],
design_matrix_dict: dict[str, DataFrame],
@@ -40,6 +42,7 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
super().__init__("ExportToCSV")
self.setWindowTitle(f"Export To CSV Viewer - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict
self.epochs_dict = epochs_dict
self.cha_dict = cha_dict
self.df_ind_dict = df_ind_dict
self.design_matrix = design_matrix_dict
@@ -50,8 +53,7 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
self.qc_dict = qc_dict
self.setup_csv_ui(["0 (Export Data to CSV)", "1 (CSV for SPARKS)", "2 (Export Configuration to CSV)", "3 (Paragraph of Configuration)", "4 (Export FIR Waveform Features to CSV)",
"5 (Export Quality Control Metrics to CSV)",
"6 (Export Master Consolidated Matrix [All Participants into 1 CSV])"
"5 (Export Quality Control Metrics to CSV)", "6 (Export Master Consolidated Matrix [All Participants into 1 CSV])"
])
@@ -120,67 +122,67 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
magic_string = self.gen_magic_str(first_params)
self.placeholder_label.setText(magic_string)
# elif idx == 4:
# # FIR Waveform Features Export
# fir_data = self.fir_feature_dict.get(file_path)
# if fir_data and isinstance(fir_data, dict):
# names = fir_data.get("feature_names", [])
# vals = fir_data.get("features", [])
# chans = fir_data.get("feature_channels", [])
elif idx == 4:
# FIR Waveform Features Export
fir_data = self.fir_feature_dict.get(file_path)
if fir_data:
names = fir_data.get("feature_names", [])
vals = fir_data.get("features", [])
chans = fir_data.get("feature_channels", [])
# fir_df = DataFrame({
# "Feature_Name": names,
# "Channel": chans if len(chans) == len(names) else ["N/A"] * len(names),
# "Value": vals
# })
# save_path = os.path.join(output_dir, f"{base_filename}_fir_features.csv")
# fir_df.to_csv(save_path, index=False)
# success_count += 1
fir_df = DataFrame({
"Feature_Name": names,
"Channel": chans if len(chans) == len(names) else ["N/A"] * len(names),
"Value": vals
})
save_path = os.path.join(output_dir, f"{base_filename}_fir_features.csv")
fir_df.to_csv(save_path, index=False)
success_count += 1
# elif idx == 5:
# # Quality Control (QC) Metrics Export
# qc_data = self.qc_dict.get(file_path)
# if qc_data and isinstance(qc_data, dict):
# qc_df = DataFrame(list(qc_data.items()), columns=["Metric", "Value"])
# save_path = os.path.join(output_dir, f"{base_filename}_qc_metrics.csv")
# qc_df.to_csv(save_path, index=False)
# success_count += 1
elif idx == 5:
# Quality Control (QC) Metrics Export
qc_data = self.qc_dict.get(file_path)
if qc_data and isinstance(qc_data, dict):
qc_df = DataFrame(list(qc_data.items()), columns=["Metric", "Value"])
save_path = os.path.join(output_dir, f"{base_filename}_qc_metrics.csv")
qc_df.to_csv(save_path, index=False)
success_count += 1
# elif idx == 6:
# # Master Consolidated Matrix (1 single CSV combining all selected participants)
# save_path = os.path.join(output_dir, f"{APP_NAME}_master_consolidated.csv")
# if not os.path.exists(save_path):
# master_rows: list[dict[str, Any]] = []
# for fp in selected_file_paths:
# abs_path = os.path.abspath(fp)
# grp = self.group_dict.get(fp, "Unknown")
# row: dict[str, Any] = {"Participant": abs_path, "Group": grp}
elif idx == 6:
# Master Consolidated Matrix (1 single CSV combining all selected participants)
save_path = os.path.join(output_dir, f"{APP_NAME}_master_consolidated.csv")
if not os.path.exists(save_path):
master_rows: list[dict[str, Any]] = []
for fp in selected_file_paths:
abs_path = os.path.abspath(fp)
grp = self.group_dict.get(fp, "Unknown")
row: dict[str, Any] = {"Participant": abs_path, "Group": grp}
# # QC Metrics
# qc_info = self.qc_dict.get(fp, {})
# if isinstance(qc_info, dict):
# for mk, mv in qc_info.items():
# row[f"QC_{mk}"] = mv
# QC Metrics
qc_info = self.qc_dict.get(fp, {})
if isinstance(qc_info, dict):
for mk, mv in qc_info.items():
row[f"QC_{mk}"] = mv
# # FIR Features
# fir_info = self.fir_feature_dict.get(fp, {})
# print("1")
# if isinstance(fir_info, dict):
# print("2")
# f_names = fir_info.get("feature_names", [])
# f_vals = fir_info.get("features", [])
# print("3")
# if len(f_names) == len(f_vals):
# print("4")
# for fn, fv in zip(f_names, f_vals):
# row[f"FIR_{fn}"] = fv
# FIR Features
fir_info = self.fir_feature_dict.get(fp, {})
print("1")
if isinstance(fir_info, dict):
print("2")
f_names = fir_info.get("feature_names", [])
f_vals = fir_info.get("features", [])
print("3")
if len(f_names) == len(f_vals):
print("4")
for fn, fv in zip(f_names, f_vals):
row[f"FIR_{fn}"] = fv
# master_rows.append(row)
master_rows.append(row)
# if master_rows:
# df_master = DataFrame(master_rows)
# df_master.to_csv(save_path, index=False)
# success_count += 1
if master_rows:
df_master = DataFrame(master_rows)
df_master.to_csv(save_path, index=False)
success_count += 1
else:
print(f"No method defined for index {idx}")
-1
View File
@@ -211,7 +211,6 @@ class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget):
continue
cha_df = self.cha_dict.get(file_path)
if cha_df is not None:
all_cha = pd.concat([all_cha, cha_df], ignore_index=True)
+1 -3
View File
@@ -283,7 +283,6 @@ class StaticChannelCanvas(FigureCanvas):
self.draw_idle()
class StandaloneLegendDialog(QWidget):
def __init__(self, canvas_engine: Any, title_prefix: str, parent: QWidget | None = None) -> None:
super().__init__(None)
@@ -317,7 +316,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
num_channels = len(channels_data)
# --- FIX: DYNAMICALLY CALCULATE OPTIMAL 16:9 COLUMNS ---
# Dynamically calculate best ratio for 16:9
target_ratio = 16 / 9
best_cols = 4
min_ratio_error = float('inf')
@@ -678,7 +677,6 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
scroll_layout.setSpacing(6)
scroll_layout.setContentsMargins(0, 0, 0, 0)
true_color_map = get_landmark_color_map()
# Iterate over the sorted keys directly from your method
+7 -12
View File
@@ -14,11 +14,12 @@ 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 QPalette, QStandardItemModel, QStandardItem, QPixmap, QIntValidator, QDoubleValidator
from PySide6.QtGui import QStandardItemModel, QStandardItem, QPixmap, QIntValidator, QDoubleValidator
from PySide6.QtCore import QEvent, QPoint, QSize, QTimer, Qt, Signal
from src.shared.shareddata import APP_NAME, PIPELINE_STAGES
class FullClickComboBox(QComboBox):
def __init__(self, parent=None):
super().__init__(parent)
@@ -1290,7 +1291,6 @@ class FlaresBaseWidget(QWidget):
model.itemChanged.connect(on_item_changed)
def update_participant_list_for_group(self, group_name=None, combo=None):
target_combo = combo if combo is not None else getattr(self, "participant_dropdown", None)
@@ -1492,8 +1492,6 @@ class InterGroupUIMixin:
dropdown.blockSignals(False)
def _get_file_paths_from_labels(self, labels, group_name):
file_paths = []
@@ -1868,7 +1866,6 @@ class IntraGroupUIMixin:
param_info["options"] = dynamic_contrasts
param_info["default"] = dynamic_contrasts[0] if dynamic_contrasts else ""
param_values = {}
if indexes_needing_params:
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
@@ -1886,6 +1883,7 @@ class IntraGroupUIMixin:
param_values,
)
class ParticipantUIMixin:
def setup_participant_ui(
self,
@@ -1913,7 +1911,6 @@ class ParticipantUIMixin:
self.event_dropdown = QComboBox()
self.event_dropdown.addItem("<None Selected>")
self.index_texts = index_texts
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
@@ -1968,8 +1965,6 @@ class ParticipantUIMixin:
]
# Inject full_text from index_texts
for idx, params_list in parameterized_indexes.items():
full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)"
@@ -1989,8 +1984,8 @@ class ParticipantUIMixin:
return
return (
selected_event,
selected_file_paths,
selected_indexes,
param_values,
selected_event,
selected_file_paths,
selected_indexes,
param_values,
)
+1 -1
View File
@@ -13,7 +13,7 @@ import sys
import platform
CURRENT_VERSION = "1.7.1"
CURRENT_VERSION = "1.7.2"
APP_NAME = "flares"
APP_NAME_EXPANDED = "fNIRS Lightweight Analysis, Research, & Evaluation Suite"
API_URL = f"https://git.research.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases"
+348 -81
View File
@@ -8,6 +8,10 @@ License: GPL-3.0
"""
# Built-in imports
import io
import shutil
import urllib.request
import zipfile
from pathlib import Path
from typing import Any, cast
@@ -15,26 +19,95 @@ from typing import Any, cast
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
QMessageBox, QPushButton, QTabWidget, QTextBrowser, QVBoxLayout, QWidget
QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMessageBox,
QPushButton, QTabWidget, QTextBrowser, QVBoxLayout, QWidget, QProgressDialog
)
from plugin_manager import PluginManager, parse_version
from plugin_manager import PluginManager, get_ssl_context, parse_version
from src.shared.shareddata import APP_NAME, CURRENT_VERSION, PLUGINS_URL
class RemoteFetchWorker(QThread):
"""Background thread to fetch remote repository data without lagging the main UI."""
fetched = Signal(list, bool) # (plugins_data, all_failed)
fetched = Signal(list, bool, str) # (plugins_data, all_failed, fetch_type)
def __init__(self, manager: PluginManager, urls: list[str]) -> None:
def __init__(self, manager: PluginManager, urls: list[str], fetch_type: str = "browser") -> None:
super().__init__()
self.manager = manager
self.urls = urls
self.fetch_type = fetch_type
def run(self) -> None:
plugins, all_failed = self.manager.fetch_remote_repositories(self.urls)
self.fetched.emit(plugins, all_failed)
self.fetched.emit(plugins, all_failed, self.fetch_type)
class PluginInstallWorker(QThread):
"""Background worker to download, unpack, and install/update plugins with progress reporting."""
progress = Signal(int, int) # (downloaded_bytes, total_bytes)
status_message = Signal(str)
finished = Signal(bool, str) # (success, message_or_path)
def __init__(
self,
manager: PluginManager,
download_url: str,
plugin_id: str,
is_update: bool = False,
plugin_path_to_remove: Path | None = None
) -> None:
super().__init__()
self.manager = manager
self.download_url = download_url
self.plugin_id = plugin_id
self.is_update = is_update
self.plugin_path_to_remove = plugin_path_to_remove
def run(self) -> None:
try:
self.status_message.emit("Connecting to server...")
req = urllib.request.Request(
self.download_url,
headers={"User-Agent": f"{APP_NAME}-PluginManager"},
)
with urllib.request.urlopen(req, timeout=30, context=get_ssl_context()) as response:
if response.status != 200:
self.finished.emit(False, f"Download failed with HTTP status code {response.status}")
return
total_size_str = response.headers.get("Content-Length")
total_size = int(total_size_str) if total_size_str and total_size_str.isdigit() else 0
self.status_message.emit("Downloading plugin archive...")
chunk_size = 8192
downloaded = 0
buffer = io.BytesIO()
while True:
chunk = response.read(chunk_size)
if not chunk:
break
buffer.write(chunk)
downloaded += len(chunk)
self.progress.emit(downloaded, total_size)
zip_bytes = buffer.getvalue()
if self.is_update and self.plugin_path_to_remove and self.plugin_path_to_remove.exists():
self.status_message.emit("Removing old version...")
if self.plugin_path_to_remove.is_dir():
shutil.rmtree(self.plugin_path_to_remove)
else:
self.plugin_path_to_remove.unlink()
self.status_message.emit("Unpacking and extracting files...")
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zip_ref:
zip_ref.extractall(self.manager.plugins_dir)
self.finished.emit(True, str(self.manager.plugins_dir))
except Exception as e:
self.finished.emit(False, str(e))
class PluginsWindow(QWidget):
@@ -52,6 +125,8 @@ class PluginsWindow(QWidget):
self.remote_plugins_data: list[dict[str, Any]] = []
self._has_fetched_remote: bool = False
self._fetch_worker: RemoteFetchWorker | None = None
self._install_worker: PluginInstallWorker | None = None
self._progress_dialog: QProgressDialog | None = None
# Refresh UI automatically if manager updates state
self.manager.plugins_changed.connect(self.refresh_installed_plugins)
@@ -70,40 +145,75 @@ class PluginsWindow(QWidget):
self.setLayout(main_layout)
self.refresh_installed_plugins()
self.check_installed_updates()
def check_installed_updates(self) -> None:
"""Asynchronously checks remote repositories for installed plugin updates."""
if self._fetch_worker is not None and self._fetch_worker.isRunning():
return
self._fetch_worker = RemoteFetchWorker(self.manager, self.repository_urls, fetch_type="updates")
self._fetch_worker.fetched.connect(self._on_remote_fetched)
self._fetch_worker.start()
def refresh_installed_plugins(self) -> None:
"""Refreshes installed list using metadata from PluginManager."""
"""Refreshes installed list using metadata from PluginManager and attaches update info."""
self.installed_list.clear()
plugins_info = self.manager.get_installed_plugins_info()
remote_map: dict[str, dict[str, Any]] = {}
if self._has_fetched_remote and self.remote_plugins_data:
for p in self.remote_plugins_data:
p_id = p.get("id") or p.get("name")
if p_id:
remote_map[str(p_id)] = p
for info in plugins_info:
p_id = str(info.get("id", ""))
local_tuple = parse_version(str(info.get("version", "0.0.0")))
remote_plugin = remote_map.get(p_id)
if remote_plugin:
remote_ver = str(remote_plugin.get("version", "0.0.0"))
if parse_version(remote_ver) > local_tuple:
info["update_available"] = True
info["remote_version"] = remote_ver
info["download_url"] = str(remote_plugin.get("download_url", ""))
display_text = f"{info['name']} (v{info.get('version', '1.0.0')})"
if info.get("update_available"):
display_text += f" [Update Available: v{info.get('remote_version')}]"
if info["is_disabled"]:
display_text += " [Disabled]"
item = QListWidgetItem(display_text, self.installed_list)
item.setData(Qt.ItemDataRole.UserRole, info)
if info["is_disabled"]:
if info.get("update_available"):
item.setForeground(QColor("#1976d2"))
elif info["is_disabled"]:
item.setForeground(QColor("#757575"))
if self.installed_list.count() == 0:
self.installed_list.addItem("No plugins installed.")
self._clear_details_panel()
# Re-populate browser list if remote data was already fetched to reflect newly installed/uninstalled plugins
if self._has_fetched_remote and self.remote_plugins_data:
if not self._has_fetched_remote:
self.check_installed_updates()
elif self.remote_plugins_data:
self._populate_browser_list()
def _on_installed_item_changed(self, current: QListWidgetItem | None, _: Any) -> None:
"""Updates the right-hand details panel when a plugin is selected."""
if not current:
self._clear_details_panel()
self.btn_update_plugin.setEnabled(False)
return
info = current.data(Qt.ItemDataRole.UserRole)
if not isinstance(info, dict):
self._clear_details_panel()
self.btn_update_plugin.setEnabled(False)
return
plugin_info = cast(dict[str, Any], info)
@@ -114,83 +224,161 @@ class PluginsWindow(QWidget):
desc = str(plugin_info.get("description", "No description provided."))
is_disabled = bool(plugin_info.get("is_disabled", False))
path = str(plugin_info.get("path", ""))
update_available = bool(plugin_info.get("update_available", False))
self.lbl_plugin_title.setText(name)
self.lbl_plugin_meta.setText(f"<b>Version:</b> {version} &nbsp;|&nbsp; <b>Author:</b> {author}")
if is_disabled:
if update_available:
rem_ver = plugin_info.get("remote_version", "")
self.lbl_plugin_status.setText(f"<font color='#1976d2'><b>Status: Update v{rem_ver} Available!</b></font>")
elif is_disabled:
self.lbl_plugin_status.setText("<font color='#d32f2f'><b>Status: Disabled</b></font>")
else:
self.lbl_plugin_status.setText("<font color='#2e7d32'><b>Status: Active</b></font>")
self.txt_plugin_desc.setHtml(desc)
self.lbl_plugin_path.setText(f"<b>Path:</b> <code>{path}</code>")
self.btn_update_plugin.setEnabled(update_available)
def _clear_details_panel(self) -> None:
"""Clears details display when nothing is selected."""
self.lbl_plugin_title.setText("Select a plugin")
self.lbl_plugin_meta.setText("")
self.lbl_plugin_status.setText("")
self.txt_plugin_desc.clear()
self.lbl_plugin_path.setText("")
def execute_plugin_installation(self, download_url: str, plugin_id: str, plugin_name: str, is_update: bool, plugin_path: Path | None) -> None:
"""Sets up the progress dialog and starts the background installation/update worker."""
if self._install_worker is not None and self._install_worker.isRunning():
return
def toggle_enable_disable(self) -> None:
self._installation_finished_flag = False
action_label = "Updating" if is_update else "Installing"
self._progress_dialog = QProgressDialog(f"{action_label} '{plugin_name}'...", "Cancel", 0, 100, self)
self._progress_dialog.setMinimumDuration(0) # Show the progress dialog instantly
self._progress_dialog.setWindowTitle(f"{action_label} Plugin")
self._progress_dialog.setWindowModality(Qt.WindowModality.WindowModal)
self._progress_dialog.setAutoClose(False)
self._progress_dialog.setAutoReset(False)
self._progress_dialog.canceled.connect(self._cancel_installation)
self._progress_dialog.show()
self._install_worker = PluginInstallWorker(self.manager, download_url, plugin_id, is_update, plugin_path)
self._install_worker.progress.connect(self._on_install_progress)
self._install_worker.status_message.connect(self._on_install_status)
self._install_worker.finished.connect(lambda success, msg: self._on_install_finished(success, msg, plugin_name, is_update))
self._install_worker.start()
def _cancel_installation(self) -> None:
"""Handles manual user cancellation of the download/install progress."""
if getattr(self, "_installation_finished_flag", False):
return # Ignore if triggered by closing the dialog programmatically on success
if self._install_worker and self._install_worker.isRunning():
self._install_worker.terminate()
self._install_worker.wait()
QMessageBox.warning(self, "Cancelled", "Plugin installation was cancelled.")
def _on_install_finished(self, success: bool, message: str, plugin_name: str, is_update: bool) -> None:
self._installation_finished_flag = True # Prevent false cancellation triggers
if self._progress_dialog:
self._progress_dialog.close()
self._progress_dialog.deleteLater()
self._progress_dialog = None
action_word = "updated" if is_update else "installed"
action_verb = "update" if is_update else "install"
if success:
self.manager.reload_plugins()
QMessageBox.information(self, "Success", f"Successfully {action_word} '{plugin_name}'!")
self.refresh_installed_plugins()
if self._has_fetched_remote:
self._populate_browser_list()
else:
QMessageBox.critical(self, "Installation Failed", f"Could not {action_verb} plugin:\n{message}")
def _on_install_progress(self, downloaded: int, total: int) -> None:
if self._progress_dialog:
if total > 0:
self._progress_dialog.setMaximum(total)
self._progress_dialog.setValue(downloaded)
mb_down = downloaded / (1024 * 1024)
mb_total = total / (1024 * 1024)
self._progress_dialog.setLabelText(f"Downloading... ({mb_down:.2f} MB / {mb_total:.2f} MB)")
else:
self._progress_dialog.setMaximum(0) # Infinite marquee progress if size is unknown
mb_down = downloaded / (1024 * 1024)
self._progress_dialog.setLabelText(f"Downloading... ({mb_down:.2f} MB downloaded)")
def _on_install_status(self, message: str) -> None:
if self._progress_dialog:
self._progress_dialog.setLabelText(message)
def update_selected_plugin(self) -> None:
"""Triggers the background update process for the selected installed plugin."""
selected = self.installed_list.currentItem()
if not selected:
return
raw_info = selected.data(Qt.ItemDataRole.UserRole)
if isinstance(raw_info, dict):
info = cast(dict[str, Any], raw_info)
plugin_path = info.get("path")
if isinstance(plugin_path, str) and plugin_path:
self.manager.toggle_plugin_state(Path(plugin_path))
def uninstall_plugin(self) -> None:
selected = self.installed_list.currentItem()
if not selected:
return
raw_info = selected.data(Qt.ItemDataRole.UserRole)
if not isinstance(raw_info, dict):
return
info = cast(dict[str, Any], raw_info)
download_url = info.get("download_url", "")
plugin_id = info.get("id", "unnamed")
plugin_name = info.get("name", "Plugin")
plugin_path = info.get("path")
plugin_name = info.get("name", "this plugin")
if not isinstance(plugin_path, str) or not plugin_path:
if not download_url:
QMessageBox.warning(self, "Update Failed", "No download URL provided for this update.")
return
reply = QMessageBox.question(
self,
"Confirm Uninstall",
f"Are you sure you want to delete '{plugin_name}'?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
self.manager.uninstall_plugin(Path(plugin_path))
path_obj = Path(plugin_path) if isinstance(plugin_path, str) and plugin_path else (plugin_path if isinstance(plugin_path, Path) else None)
self.execute_plugin_installation(download_url, plugin_id, plugin_name, is_update=True, plugin_path=path_obj)
def fetch_remote_plugins(self) -> None:
"""Asynchronously fetches remote plugins on a background thread."""
if self._fetch_worker is not None and self._fetch_worker.isRunning():
def install_selected_plugin(self) -> None:
"""Triggers the background installation/update process from the browser tab."""
selected = self.browser_list.currentItem()
if not selected:
return
data = selected.data(Qt.ItemDataRole.UserRole)
if not isinstance(data, dict):
return
self.browser_list.clear()
self.browser_list.addItem("Fetching remote repositories...")
self.btn_install.setEnabled(False)
self.btn_fetch.setEnabled(False)
plugin_data = cast(dict[str, Any], data)
# Defer network call to background worker to avoid UI freeze
self._fetch_worker = RemoteFetchWorker(self.manager, self.repository_urls)
self._fetch_worker.fetched.connect(self._on_remote_fetched)
self._fetch_worker.start()
update_available = bool(selected.data(Qt.ItemDataRole.UserRole + 3))
plugin_id = str(plugin_data.get("id", "unnamed"))
plugin_name = str(plugin_data.get("name", "Plugin"))
download_url = str(plugin_data.get("download_url", ""))
def _on_remote_fetched(self, plugins: list[dict[str, Any]], all_failed: bool) -> None:
if not download_url:
QMessageBox.warning(self, "Error", "No download URL provided for this plugin.")
return
# Find existing local path if updating an installed plugin
existing_path: Path | None = None
installed_info = self.manager.get_installed_plugins_info()
for info in installed_info:
if info.get("id") == plugin_id or info.get("name") == plugin_name:
p = info.get("path")
if isinstance(p, Path):
existing_path = p
elif isinstance(p, str):
existing_path = Path(p)
break
self.execute_plugin_installation(download_url, plugin_id, plugin_name, is_update=update_available, plugin_path=existing_path)
def _on_remote_fetched(self, plugins: list[dict[str, Any]], all_failed: bool, fetch_type: str) -> None:
"""Callback executed on the main UI thread when remote fetching finishes."""
self.remote_plugins_data = plugins
self._has_fetched_remote = True
self.btn_fetch.setEnabled(True)
if fetch_type == "updates":
self.refresh_installed_plugins()
return
if plugins:
self._populate_browser_list()
elif all_failed:
@@ -200,13 +388,33 @@ class PluginsWindow(QWidget):
self.browser_list.clear()
self.browser_list.addItem("No plugins found across configured repositories.")
def fetch_remote_plugins(self) -> None:
"""Asynchronously fetches remote plugins on a background thread for the browser."""
if self._fetch_worker is not None and self._fetch_worker.isRunning():
return
self.browser_list.clear()
self.browser_list.addItem("Fetching remote repositories...")
self.btn_install.setEnabled(False)
self.btn_fetch.setEnabled(False)
self._fetch_worker = RemoteFetchWorker(self.manager, self.repository_urls, fetch_type="browser")
self._fetch_worker.fetched.connect(self._on_remote_fetched)
self._fetch_worker.start()
def _populate_browser_list(self) -> None:
"""Populates the browser tab list and evaluates compatibility and installation state."""
"""Populates the browser tab list and evaluates compatibility, installation, and updates."""
self.browser_list.clear()
installed_info = self.manager.get_installed_plugins_info()
installed_ids = {p.get("id") for p in installed_info if p.get("id")}
installed_names = {p.get("name") for p in installed_info if p.get("name")}
installed_map: dict[str, dict[str, Any]] = {}
for p in installed_info:
p_id = p.get("id")
p_name = p.get("name")
if p_id:
installed_map[str(p_id)] = p
if p_name:
installed_map[str(p_name)] = p
for plugin in self.remote_plugins_data:
name = plugin.get("name", "Unknown")
@@ -220,7 +428,6 @@ class PluginsWindow(QWidget):
is_version_ok = self.manager.current_app_version >= parse_version(min_v_str)
is_compatible = is_platform_ok and is_version_ok
# Evaluate reasons for incompatibility
incompat_reasons: list[str] = []
if not is_platform_ok:
plat_str = ", ".join(platforms) if isinstance(platforms, list) else str(platforms)
@@ -228,10 +435,20 @@ class PluginsWindow(QWidget):
if not is_version_ok:
incompat_reasons.append(f"Requires App v{min_v_str}+")
is_installed = (bool(p_id) and p_id in installed_ids) or (bool(name) and name in installed_names)
installed_match = installed_map.get(str(p_id)) or installed_map.get(str(name))
is_installed = installed_match is not None
update_available = False
remote_ver_str = str(plugin.get("version", "0.0.0"))
if is_installed:
local_ver_str = str(installed_match.get("version", "0.0.0"))
if parse_version(remote_ver_str) > parse_version(local_ver_str):
update_available = True
display_text = f"{name} (v{version}) - {desc}"
if is_installed:
if update_available:
display_text += f" [Update Available: v{remote_ver_str}]"
elif is_installed:
display_text += " [Installed]"
elif incompat_reasons:
reason_str = "; ".join(incompat_reasons)
@@ -241,28 +458,14 @@ class PluginsWindow(QWidget):
item.setData(Qt.ItemDataRole.UserRole, plugin)
item.setData(Qt.ItemDataRole.UserRole + 1, is_compatible)
item.setData(Qt.ItemDataRole.UserRole + 2, is_installed)
item.setData(Qt.ItemDataRole.UserRole + 3, update_available)
if is_installed:
item.setForeground(QColor("#2e7d32")) # Green for installed
if update_available:
item.setForeground(QColor("#1976d2"))
elif is_installed:
item.setForeground(QColor("#2e7d32"))
elif not is_compatible:
item.setForeground(QColor("#d32f2f")) # Red for incompatible
def install_selected_plugin(self) -> None:
selected = self.browser_list.currentItem()
if not selected:
return
data = selected.data(Qt.ItemDataRole.UserRole)
if not data:
return
try:
target_path = self.manager.install_plugin_from_url(
data.get("download_url", ""),
data.get("id", "unnamed")
)
QMessageBox.information(self, "Success", f"Installed plugin to:\n{target_path}")
except Exception as e:
QMessageBox.critical(self, "Installation Failed", f"Could not install plugin:\n{e}")
item.setForeground(QColor("#d32f2f"))
def _on_tab_changed(self, index: int) -> None:
if index == 1 and not self._has_fetched_remote:
@@ -272,10 +475,19 @@ class PluginsWindow(QWidget):
if current:
is_compatible = bool(current.data(Qt.ItemDataRole.UserRole + 1))
is_installed = bool(current.data(Qt.ItemDataRole.UserRole + 2))
update_available = bool(current.data(Qt.ItemDataRole.UserRole + 3))
# Button lights up ONLY if plugin is compatible and NOT yet installed
self.btn_install.setEnabled(is_compatible and not is_installed)
if update_available:
self.btn_install.setText("Update Plugin")
self.btn_install.setEnabled(is_compatible)
elif is_installed:
self.btn_install.setText("Install Plugin")
self.btn_install.setEnabled(False)
else:
self.btn_install.setText("Install Plugin")
self.btn_install.setEnabled(is_compatible)
else:
self.btn_install.setText("Install Plugin")
self.btn_install.setEnabled(False)
def _on_add_repo(self) -> None:
@@ -334,14 +546,19 @@ class PluginsWindow(QWidget):
btn_layout = QHBoxLayout()
btn_refresh = QPushButton("Refresh List", tab)
self.btn_update_plugin = QPushButton("Update Plugin", tab)
self.btn_update_plugin.setEnabled(False)
self.btn_update_plugin.setStyleSheet("font-weight: bold; color: #1976d2;")
btn_enable = QPushButton("Enable / Disable", tab)
btn_uninstall = QPushButton("Uninstall", tab)
btn_refresh.clicked.connect(self.refresh_installed_plugins)
self.btn_update_plugin.clicked.connect(self.update_selected_plugin)
btn_enable.clicked.connect(self.toggle_enable_disable)
btn_uninstall.clicked.connect(self.uninstall_plugin)
btn_layout.addWidget(btn_refresh)
btn_layout.addWidget(self.btn_update_plugin)
btn_layout.addStretch()
btn_layout.addWidget(btn_enable)
btn_layout.addWidget(btn_uninstall)
@@ -389,3 +606,53 @@ class PluginsWindow(QWidget):
layout.addWidget(self.browser_list)
layout.addLayout(btn_layout)
return tab
def toggle_enable_disable(self) -> None:
selected = self.installed_list.currentItem()
if not selected:
return
raw_info = selected.data(Qt.ItemDataRole.UserRole)
if isinstance(raw_info, dict):
info = cast(dict[str, Any], raw_info)
plugin_path = info.get("path")
if isinstance(plugin_path, str) and plugin_path:
self.manager.toggle_plugin_state(Path(plugin_path))
elif isinstance(plugin_path, Path):
self.manager.toggle_plugin_state(plugin_path)
def uninstall_plugin(self) -> None:
selected = self.installed_list.currentItem()
if not selected:
return
raw_info = selected.data(Qt.ItemDataRole.UserRole)
if not isinstance(raw_info, dict):
return
info = cast(dict[str, Any], raw_info)
plugin_path = info.get("path")
plugin_name = info.get("name", "this plugin")
if not plugin_path:
return
reply = QMessageBox.question(
self,
"Confirm Uninstall",
f"Are you sure you want to delete '{plugin_name}'?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
if isinstance(plugin_path, str):
self.manager.uninstall_plugin(Path(plugin_path))
elif isinstance(plugin_path, Path):
self.manager.uninstall_plugin(plugin_path)
def _clear_details_panel(self) -> None:
"""Clears details display when nothing is selected."""
self.lbl_plugin_title.setText("Select a plugin")
self.lbl_plugin_meta.setText("")
self.lbl_plugin_status.setText("")
self.txt_plugin_desc.clear()
self.lbl_plugin_path.setText("")
+1 -1
View File
@@ -64,7 +64,7 @@ class ViewerLauncherWidget(QWidget):
("Inter-Group Stats Viewer", InterGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, roi_channel_map_dict, group_dict], True),
("Intra-Group Brain and Image Viewer", IntraGroupBrainImageWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True),
("Inter-Group Brain and Image Viewer", InterGroupBrainImageWidget, [haemo_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True),
("Export To CSV Viewer", ExportToCSVWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict, config_dict, fir_feature_dict, qc_dict], True)
("Export To CSV Viewer", ExportToCSVWidget, [haemo_dict, epochs_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict, config_dict, fir_feature_dict, qc_dict], True)
]
layout = QVBoxLayout(self)