mainly whitespace, but also some generic qol improvements

This commit is contained in:
tyler committed 2026-10-14 20:17:23 -07:00
1 parent 2a5e3f5606
commit 3a8ffbdf70
11 files changed
+1038 -391

No files matched your search

+18
View File
@@ -1,3 +1,21 @@
# 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.1 # 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 - 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
+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 from src.shared.shareddata import PLATFORM_NAME, resource_path, get_app_dir
PRIMARY_COLORS = { PRIMARY_COLORS = {
"SCI only": "skyblue", # Scalp Coupling Index (Standard MNE) "SCI only": "skyblue", # Scalp Coupling Index (Standard MNE)
"SNR only": "lightgreen", # Signal-to-Noise Ratio (Original) "SNR only": "lightgreen", # Signal-to-Noise Ratio (Original)
@@ -301,6 +300,8 @@ GROUP: str = "Default"
FOLDING_BYP: bool = False FOLDING_BYP: bool = False
SEPARATE_EVENTS: bool = False
FEATURE_1: bool = False FEATURE_1: bool = False
FEATURE_2: bool = False FEATURE_2: bool = False
@@ -326,7 +327,6 @@ logging.basicConfig(
logger = logging.getLogger() logger = logging.getLogger()
def set_config_me(config: dict[str, Any]) -> None: def set_config_me(config: dict[str, Any]) -> None:
""" """
Validates and applies the given configuration dictionary. 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): def process_participant_worker(file_path, file_params, file_metadata, result_queue, progress_queue):
file_start = time.time() file_start = time.time()
stage_tracker = {"value": 0.0} 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: try:
set_config_me(file_params) set_config_me(file_params)
set_metadata(file_path, file_metadata) set_metadata(file_path, file_metadata)
@@ -446,6 +467,8 @@ def process_participant_worker(file_path, file_params, file_metadata, result_que
pass pass
finally: finally:
sys.stdout = old_stdout
sys.stderr = old_stderr
try: try:
plt.close('all') plt.close('all')
gc.collect() 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].set_ylabel("Haemoglobin (ΔμMol)")
axes[2].legend(["Oxyhaemoglobin", "Deoxyhaemoglobin"]) axes[2].legend(["Oxyhaemoglobin", "Deoxyhaemoglobin"])
print(f"Number of FIR bins: {len(vals_hbo)}") print(f"Number of FIR bins: {len(vals_hbo)}")
print(f"Mean theta (HbO): {np.mean(vals_hbo):.4f}") print(f"Mean theta (HbO): {np.mean(vals_hbo):.4f}")
print(f"Sum of theta (HbO): {np.sum(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())) 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 valid_channels = ch_summary["ch_name"].unique().tolist() # type: ignore
raw_for_plot = raw_haemo.copy().pick(picks=valid_channels) # 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 scale=0.002
) )
if show_brodmann:# Add Brodmann labels if show_brodmann:# Add Brodmann labels
labels = cast(list[Label], read_labels_from_annot("fsaverage", "PALS_B12_Brodmann", "lh", verbose=False)) # type: ignore 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.") logger.info("Step 21 Completed.")
step_start = lap(step_start, timings, "Step 21") 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 # Step 22: Design Matrix
raw_haemo, raw_haemo_dm, df_design_matrix, fig_design_matrix = make_design_matrix( raw_haemo, raw_haemo_dm, df_design_matrix, fig_design_matrix = make_design_matrix(
raw_haemo=raw_haemo, raw_haemo=raw_haemo,
@@ -6071,7 +6109,6 @@ def process_participant(file_path, file_start, progress_callback=None):
except Exception as e: except Exception as e:
logger.warning(f"Step 27.5 Failed to extract FIR features: {e}") logger.warning(f"Step 27.5 Failed to extract FIR features: {e}")
# Step 28: Finishing Up # Step 28: Finishing Up
png_queue.put(None) # sentinel png_queue.put(None) # sentinel
png_queue.join() # blocks only on remaining unfinished work 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 # Shared: channel alignment across participants
# ============================================================================ # ============================================================================
@@ -6797,13 +6829,6 @@ def run_group_functional_connectivity_epochs(
def sparks_csv_export( def sparks_csv_export(
haemo_obj: BaseRaw, haemo_obj: BaseRaw,
save_path: str, save_path: str,
@@ -6828,7 +6853,6 @@ def sparks_csv_export(
df.to_csv(save_path, index=False) df.to_csv(save_path, index=False)
def peak_power_fast( def peak_power_fast(
raw, raw,
time_window=10, time_window=10,
@@ -7413,6 +7437,21 @@ def normalize_roi_map(roi_map):
return {} 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__": if __name__ == "__main__":
print("This file has no functionality when not used in tandem with the FLARES application.") print("This file has no functionality when not used in tandem with the FLARES application.")
+386 -211
View File
@@ -15,6 +15,7 @@ import traceback
import subprocess import subprocess
import configparser import configparser
import concurrent.futures import concurrent.futures
from typing import Any
from queue import Empty from queue import Empty
from copy import deepcopy from copy import deepcopy
from pathlib import Path from pathlib import Path
@@ -34,11 +35,11 @@ from PySide6.QtWidgets import (
QPushButton, QMainWindow, QLabel, QLineEdit, QGroupBox, QDialog, QMenu, QSpinBox, QTableWidget, QTableWidgetItem QPushButton, QMainWindow, QLabel, QLineEdit, QGroupBox, QDialog, QMenu, QSpinBox, QTableWidget, QTableWidgetItem
) )
from PySide6.QtCore import QEvent, QObject, Signal, Qt, QTimer 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 PySide6.QtSvgWidgets import QSvgWidget # needed to show svgs when app is not frozen
from file_ext_registration import register_file_association, ELEVATION_FLAG 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 project_manager import ProjectManager
from src.window.about import AboutWindow from src.window.about import AboutWindow
from src.window.plugins import PluginsWindow 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.viewerlauncher import ViewerLauncherWidget
from src.window.welcome import WelcomeDialog from src.window.welcome import WelcomeDialog
from src.shared.flaresbasewidget import FilePickerWidget, ParamSection, ProgressBubble 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 startup_args import parse_startup_args
from updater import finish_update_if_needed, UpdateManager, LocalPendingUpdateCheckThread from updater import finish_update_if_needed, UpdateManager, LocalPendingUpdateCheckThread
@@ -71,13 +72,19 @@ show_welcome_dialog = false
first_startup = true first_startup = true
[Preferences] [Preferences]
theme = auto theme_mode = auto
2d_data_bypass = false
incompatible_save_bypass = false processing_2d_data_bypass = false
missing_events_bypass = false processing_incompatible_save_bypass = false
analysis_clearing_bypass = false processing_missing_events_bypass = false
folding_bypass = false processing_analysis_clearing_bypass = false
advanced_parameters = false processing_folding_bypass = false
processing_separate_events = false
ui_advanced_parameters = false
updates_check_plugins_automatic = true
updates_check_app_automatic = true
[Terminal] [Terminal]
@@ -85,6 +92,7 @@ advanced_parameters = false
""" """
# Selectable parameters on the right side of the window # Selectable parameters on the right side of the window
SECTIONS = [ 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): class GroupAssignmentDialog(QDialog):
"""Dialog allowing users to create groups and assign unique metadata values to them.""" """Dialog allowing users to create groups and assign unique metadata values to them."""
@@ -366,7 +401,6 @@ class GroupAssignmentDialog(QDialog):
def _init_ui(self): def _init_ui(self):
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
# Field selector # Field selector
field_box_layout = QHBoxLayout() field_box_layout = QHBoxLayout()
field_box_layout.addWidget(QLabel("Group by:")) field_box_layout.addWidget(QLabel("Group by:"))
@@ -607,25 +641,34 @@ class MainApplication(QMainWindow):
self.events_blazes = None self.events_blazes = None
self.plugins = None self.plugins = None
self.terminal = None self.terminal = None
self.bubble_widgets = {}
self.param_sections = []
self.folder_paths = []
self.section_widget = None self.section_widget = None
self.first_run = True 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.is_saved = True
self.current_project_path = None self.current_project_path = None
self.current_file = None
self.selected_paths = [] self.selected_paths = []
self.saved_selected_paths = [] self.saved_selected_paths = []
self.files_are_dirty = False 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.project_manager = ProjectManager(self, file_cfg=file_cfg, cfg_path=cfg_path)
self.plugin_manager = PluginManager(self) self.plugin_manager = PluginManager(self)
@@ -633,18 +676,35 @@ class MainApplication(QMainWindow):
for item in DATA_SCHEMA: for item in DATA_SCHEMA:
setattr(self, item["key"], {}) setattr(self, item["key"], {})
self.file_metadata = {} # AGE, SEX, HAND, GROUP config_mappings = {
self.metadata_cache = {} # Internal file/path information metadata cache "View": ["status_bar"],
self.bubble_widgets = {} # References to the UI "Bubble" objects "Preferences": [
self.current_file = None # Tracks the currently selected absolute path "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_processed.connect(self._safe_ui_update)
self.metadata_ui_signal.connect(self._handle_metadata_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( self.updater = UpdateManager(
main_window=self, main_window=self,
@@ -658,17 +718,23 @@ class MainApplication(QMainWindow):
self.init_ui() self.init_ui()
self.create_menu_bar() self.create_menu_bar()
self.pending_update_version = None
self.pending_update_path = None
self.last_clicked_bubble = None
self.installEventFilter(self) self.installEventFilter(self)
# Start local pending update check thread # 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 = 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.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.no_pending_update.connect(self.updater.on_no_pending_update)
self.local_check_thread.start() 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() self.plugin_manager.reload_plugins()
@@ -704,7 +770,6 @@ class MainApplication(QMainWindow):
self.project_manager.load_project(file_to_open) self.project_manager.load_project(file_to_open)
def init_ui(self): def init_ui(self):
central = QWidget() central = QWidget()
self.setCentralWidget(central) self.setCentralWidget(central)
@@ -830,18 +895,40 @@ class MainApplication(QMainWindow):
# for stat in top_stats[:10]: # for stat in top_stats[:10]:
# print(stat) # print(stat)
def create_menu_bar(self): def _on_startup_updates_found(self, outdated_plugins: list[dict[str, Any]]) -> None:
'''Menu Bar at the top of the screen''' """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() menu_bar = self.menuBar()
self.statusbar = self.statusBar() self.statusbar = self.statusBar()
self.view_actions = {}
self.pref_actions = {}
def make_action(name, shortcut=None, slot=None, checkable=False, checked=False, icon=None): def make_action(name, shortcut=None, slot=None, checkable=False, checked=False, icon=None):
action = QAction(name, self) action = QAction(name, self)
if shortcut: if shortcut:
action.setShortcut(QKeySequence(shortcut)) action.setShortcut(QKeySequence(shortcut))
if slot: if slot:
if checkable:
# Checkable actions use 'toggled' to reliably pass the boolean state
action.toggled.connect(slot)
else:
action.triggered.connect(slot) action.triggered.connect(slot)
if checkable: if checkable:
action.setCheckable(True) action.setCheckable(True)
@@ -850,140 +937,142 @@ class MainApplication(QMainWindow):
action.setIcon(QIcon(icon)) action.setIcon(QIcon(icon))
return action return action
# File menu and actions # Unified Menu Bar Schema
file_menu = menu_bar.addMenu("File") MENU_BAR_SCHEMA = [
file_actions = [ # === FILE MENU ===
("Open File...", "Ctrl+O", self.project_manager.open_file_dialog, resource_path("icons/file_open_24dp_1F1F1F.svg")), {"menu": "File", "name": "Open File...", "shortcut": "Ctrl+O", "slot": self.project_manager.open_file_dialog, "icon": 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")), {"menu": "File", "name": "Open Folder...", "shortcut": "Ctrl+Alt+O", "slot": self.project_manager.open_folder_dialog, "icon": resource_path("icons/folder_24dp_1F1F1F.svg")},
("Load Project...", "Ctrl+L", self.project_manager.load_project_dialog, resource_path("icons/article_24dp_1F1F1F.svg")), {"menu": "File", "type": "recent_files"},
("Save Project...", "Ctrl+S", lambda: self.project_manager.save_project(ask=False), resource_path("icons/save_24dp_1F1F1F.svg")), {"menu": "File", "type": "separator"},
("Save Project As...", "Ctrl+Shift+S", lambda: self.project_manager.save_project(ask=True), resource_path("icons/save_as_24dp_1F1F1F.svg")), {"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): # Container references for submenus and groups
file_menu.addAction(make_action(name, shortcut, slot, icon=icon)) menus = {}
if i == 1: submenus = {}
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")
theme_group = QActionGroup(self) theme_group = QActionGroup(self)
theme_group.setExclusive(True) theme_group.setExclusive(True)
# 4. Define actions for the submenu for item in MENU_BAR_SCHEMA:
theme_actions = [ menu_name = item["menu"]
("Auto", "", "auto", resource_path("icons/warning_off_24dp_1F1F1F.svg"), "theme_auto"), if menu_name not in menus:
("Light", "", "light", resource_path("icons/warning_off_24dp_1F1F1F.svg"), "theme_light"), menus[menu_name] = menu_bar.addMenu(menu_name)
("Dark", "", "dark", resource_path("icons/warning_off_24dp_1F1F1F.svg"), "theme_dark"), parent_menu = menus[menu_name]
]
for name, shortcut, mode, icon, config_key in theme_actions: # Handle Submenus (e.g. Preferences -> Theme/Processing/Updates)
# Use partial to pass 'mode' to self.theme_change_func on click if "submenu" in item:
slot = partial(self.theme_change_func, mode) 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) # Handle Special Types (Separators & Dynamic Submenus)
theme_menu.addAction(action) 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
# Handle Normal/Preference/View Actions
name = item["name"]
shortcut = item.get("shortcut", "")
key = item.get("key")
# 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)
if not slot and key and "theme_" not in key:
slot = partial(self._set_preference, key)
checkable = True
checked = getattr(self, key, False)
action = make_action(name, shortcut, slot, checkable=checkable, checked=checked, icon=item.get("icon"))
parent_menu.addAction(action)
# Register into appropriate tracking dictionaries
if key == "statusbar":
self.view_actions["statusbar"] = action
elif item.get("group") == "theme":
theme_group.addAction(action) theme_group.addAction(action)
self.pref_actions[key] = action
elif key:
self.pref_actions[key] = action
self.pref_actions[config_key] = action # Set default theme selection if available
if "theme_auto" in self.pref_actions:
# Set default selection (e.g., Auto)
self.pref_actions["theme_auto"].setChecked(True) 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"),
("Missing Events Bypass", "", self.missing_events_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "missing_events_bypass"),
("Analysis Clearing Bypass", "", self.analysis_clearing_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "analysis_clearing_bypass"),
("Folding Bypass", "", self.folding_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "folding_bypass"),
("Show Advanced Parameters", "", self.advanced_parameters_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "advanced_parameters"),
]
for name, shortcut, slot, icon, config_key in preferences_actions:
action = make_action(name, shortcut, slot, icon=icon, checkable=True)
preferences_menu.addAction(action)
self.pref_actions[config_key] = action
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))
terminal_menu = menu_bar.addMenu("Terminal")
terminal_actions = [
("New Terminal", "Ctrl+Alt+T", self.terminal_gui, resource_path("icons/terminal_24dp_1F1F1F.svg")),
]
for name, shortcut, slot, icon in terminal_actions:
terminal_menu.addAction(make_action(name, shortcut, slot, icon=icon))
self.sync_app_with_config() self.sync_app_with_config()
self.statusbar.showMessage("Ready") self.statusbar.showMessage("Ready")
def update_theme(self): def update_theme(self):
text = self.label_desc.text() text = self.label_desc.text()
self.label_desc.setText("") self.label_desc.setText("")
@@ -1039,7 +1128,7 @@ class MainApplication(QMainWindow):
# self.update_window_title() # self.update_window_title()
# Defensive dictionary lookup for the preference action # 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 show_advanced = pref_action.isChecked() if pref_action is not None else False
# Toggle visibility on all built sections # Toggle visibility on all built sections
@@ -1280,7 +1369,7 @@ class MainApplication(QMainWindow):
data_map["roi_channel_map_dict"], data_map["roi_channel_map_dict"],
data_map["fir_feature_dict"], data_map["fir_feature_dict"],
data_map["qc_dict"], data_map["qc_dict"],
self.folding_bypass, self.processing_folding_bypass,
] ]
self.launcher_window = ViewerLauncherWidget(*args) self.launcher_window = ViewerLauncherWidget(*args)
@@ -1298,19 +1387,25 @@ class MainApplication(QMainWindow):
self.top_left_widget.paste() # Trigger paste self.top_left_widget.paste() # Trigger paste
self.statusbar.showMessage("Pasted from clipboard") # Show status message 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() app = QApplication.instance()
style_hints = app.styleHints() style_hints = app.styleHints()
if mode == "auto": if mode == "auto":
style_hints.setColorScheme(Qt.ColorScheme.Unknown) style_hints.setColorScheme(Qt.ColorScheme.Unknown)
elif mode == "light": elif mode == "light":
style_hints.setColorScheme(Qt.ColorScheme.Light) style_hints.setColorScheme(Qt.ColorScheme.Light)
elif mode == "dark": elif mode == "dark":
style_hints.setColorScheme(Qt.ColorScheme.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): def _update_config_setting(self, group, key, value):
"""Helper to update memory configuration and save to disk.""" """Helper to update memory configuration and save to disk."""
# configparser expects string values # configparser expects string values
@@ -1326,29 +1421,11 @@ class MainApplication(QMainWindow):
self._update_config_setting("View", "status_bar", checked) self._update_config_setting("View", "status_bar", checked)
self.statusbar.setVisible(checked) self.statusbar.setVisible(checked)
def is_2d_bypass_func(self, checked): def _set_preference(self, config_key: str, checked: bool) -> None:
self.is_2d_bypass = checked """Generic handler for updating boolean preferences and config settings."""
self._update_config_setting("Preferences", "2d_data_bypass", checked) setattr(self, config_key, checked)
self._update_config_setting("Preferences", config_key, checked)
def incompatable_save_bypass_func(self, checked): if config_key == "ui_advanced_parameters":
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) self.update_sections(0)
def about_window(self): def about_window(self):
@@ -1454,32 +1531,65 @@ class MainApplication(QMainWindow):
def sync_app_with_config(self): def sync_app_with_config(self):
"""Reads values from file_cfg and updates both internal variables and UI checkmarks.""" """Reads values from file_cfg and updates both internal variables and UI checkmarks,
# 1. Sync internal application state variables using fallbacks parsed directly from DEFAULT_CONFIG."""
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)
self.show_status_bar = file_cfg.getboolean("View", "status_bar", fallback=False) # Parse DEFAULT_CONFIG to use as the single source of truth for fallbacks
self.main_h_ratio = file_cfg.getfloat("View", "main_h_ratio", fallback=26 / 45) default_parser = configparser.ConfigParser()
self.left_v_ratio = file_cfg.getfloat("View", "left_v_ratio", fallback=0.30) default_parser.read_string(DEFAULT_CONFIG)
# 2. Sync the UI Menu checkmarks visually # Define mapping of sections, keys, and their expected type
if hasattr(self, 'pref_actions'): config_mappings = {
self.pref_actions["2d_data_bypass"].setChecked(self.is_2d_bypass) "View": [
self.pref_actions["incompatible_save_bypass"].setChecked(self.incompatible_save_bypass) ("status_bar", "boolean"),
self.pref_actions["missing_events_bypass"].setChecked(self.missing_events_bypass) ("main_h_ratio", "float"),
self.pref_actions["analysis_clearing_bypass"].setChecked(self.analysis_clearing_bypass) ("left_v_ratio", "float")
self.pref_actions["folding_bypass"].setChecked(self.folding_bypass) ],
self.pref_actions["advanced_parameters"].setChecked(self.advanced_parameters) "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'): # 1. Sync internal state variables and UI checkmarks dynamically
self.view_actions["statusbar"].setChecked(self.show_status_bar) 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) self.update_sections(0)
if hasattr(self, 'recent_files_menu'): if hasattr(self, 'recent_files_menu'):
@@ -1489,6 +1599,7 @@ class MainApplication(QMainWindow):
self.project_manager.update_recent_projects_menu() self.project_manager.update_recent_projects_menu()
QTimer.singleShot(0, self.apply_splitter_ratios) QTimer.singleShot(0, self.apply_splitter_ratios)
self.statusbar.showMessage("Ready")
def save_splitter_ratios(self): def save_splitter_ratios(self):
@@ -1814,6 +1925,7 @@ class MainApplication(QMainWindow):
menu = QMenu(self) menu = QMenu(self)
action1 = menu.addAction(QIcon(resource_path("icons/folder_eye_24dp_1F1F1F.svg")), "Reveal") 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") 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) action = menu.exec(global_pos)
if action == action1: if action == action1:
@@ -1899,6 +2011,36 @@ class MainApplication(QMainWindow):
self.check_files_dirty_state() 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): def check_files_dirty_state(self):
"""Compares currently loaded files against the saved baseline list.""" """Compares currently loaded files against the saved baseline list."""
if not hasattr(self, "saved_selected_paths"): if not hasattr(self, "saved_selected_paths"):
@@ -2010,7 +2152,7 @@ class MainApplication(QMainWindow):
def on_run_task(self): def on_run_task(self):
#do the check #do the check
if not self.analysis_clearing_bypass: if not self.processing_analysis_clearing_bypass:
if self.button3.isVisible(): if self.button3.isVisible():
msg = QMessageBox(self) msg = QMessageBox(self)
msg.setWindowTitle("Confirm - FLARES") msg.setWindowTitle("Confirm - FLARES")
@@ -2120,9 +2262,12 @@ class MainApplication(QMainWindow):
for section_widget in self.param_sections: for section_widget in self.param_sections:
section_params = section_widget.get_param_values() section_params = section_widget.get_param_values()
all_params.update(section_params) all_params.update(section_params)
if self.folding_bypass: if self.processing_folding_bypass:
all_params['FOLDING_BYP'] = True all_params['FOLDING_BYP'] = True
if self.processing_separate_events:
all_params['SEPARATE_EVENTS'] = True
collected_data = { collected_data = {
"SNIRF_FILES": snirf_files, "SNIRF_FILES": snirf_files,
"PARAMS": all_params, # add this line "PARAMS": all_params, # add this line
@@ -2258,6 +2403,36 @@ class MainApplication(QMainWindow):
_, file_path, step_index = msg _, file_path, step_index = msg
self.progress_update_signal.emit(file_path, step_index) 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: except Exception as e:
print(f"Error in timer loop: {e}") print(f"Error in timer loop: {e}")
@@ -2847,4 +3022,4 @@ if __name__ == "__main__":
window.show() window.show()
sys.exit(app.exec()) sys.exit(app.exec())
# Not 2800 lines yay! # Not 3100 lines yay!
+96 -1
View File
@@ -22,7 +22,7 @@ from typing import Any, cast
# External library imports # External library imports
import certifi import certifi
from PySide6.QtCore import QObject, QUrl, Signal from PySide6.QtCore import QObject, QThread, QUrl, Signal
from PySide6.QtGui import QDesktopServices from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import QMainWindow, QMenu, QMessageBox from PySide6.QtWidgets import QMainWindow, QMenu, QMessageBox
@@ -329,3 +329,98 @@ class PluginManager(QObject):
if self.plugins_dir.exists(): if self.plugins_dir.exists():
return QDesktopServices.openUrl(QUrl.fromLocalFile(str(self.plugins_dir))) return QDesktopServices.openUrl(QUrl.fromLocalFile(str(self.plugins_dir)))
return False 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 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): class SaveProjectThread(QThread):
finished_signal = Signal(str) finished_signal = Signal(str)
error_signal = Signal(str) error_signal = Signal(str)
@@ -56,6 +73,25 @@ class SaveProjectThread(QThread):
self.error_signal.emit(str(e)) 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): class SavingOverlay(QDialog):
def __init__(self, parent: Optional[QWidget] = None) -> None: def __init__(self, parent: Optional[QWidget] = None) -> None:
@@ -171,6 +207,8 @@ class ProjectManager:
) )
return return
self._load_files_into_pipeline(snirf_files)
def load_dropped_files(self, file_paths: List[str]) -> None: def load_dropped_files(self, file_paths: List[str]) -> None:
"""Public entry point for handling files or folders dropped onto the UI.""" """Public entry point for handling files or folders dropped onto the UI."""
if not file_paths: if not file_paths:
@@ -339,12 +377,31 @@ class ProjectManager:
self.load_project(filename) self.load_project(filename)
def load_project(self, filename: str) -> None: 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 app = self.app
try: try:
with open(filename, "rb") as f:
data = pickle.load(f)
checks = [ checks = [
("version", "<=1.1.7"), ("version", "<=1.1.7"),
("file_metadata", "<=1.2.2"), ("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}) " f"This project was saved in an earlier version of {APP_NAME.upper()} ({ver_str}) "
"and is potentially not compatible with this version. " "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( QMessageBox.warning(
app, f"Warning - {APP_NAME.upper()}", msg + "Attempting load." app, f"Warning - {APP_NAME.upper()}", msg + "Attempting load."
) )
@@ -438,16 +495,19 @@ class ProjectManager:
if hasattr(app, "button3"): if hasattr(app, "button3"):
app.button3.setVisible(has_data) app.button3.setVisible(has_data)
self.add_to_recent_projects(os.path.normpath(filename)) self.add_to_recent_projects(os.path.normpath(filename))
# Reset baselines cleanly
self.reset_all_dirty_states() self.reset_all_dirty_states()
QMessageBox.information(app, "Loaded", f"Project loaded from:\n{filename}") QMessageBox.information(app, "Loaded", f"Project loaded from:\n{filename}")
except Exception as e: 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) # Project Saving (Save / Save As)
@@ -863,7 +923,6 @@ def _get_bids_demographics(snirf_path: str) -> dict[str, str]:
return {} return {}
def extract_metadata_worker(file_name: str) -> dict[str, Any]: def extract_metadata_worker(file_name: str) -> dict[str, Any]:
"""Runs in the separate worker process. Returns a clean dict.""" """Runs in the separate worker process. Returns a clean dict."""
+58 -56
View File
@@ -12,6 +12,7 @@ import os
from typing import Any from typing import Any
# External library imports # External library imports
from mne import Epochs
from pandas import DataFrame from pandas import DataFrame
from mne.io.base import BaseRaw from mne.io.base import BaseRaw
@@ -27,6 +28,7 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
def __init__( def __init__(
self, self,
haemo_dict: dict[str, BaseRaw], haemo_dict: dict[str, BaseRaw],
epochs_dict: dict[str, Epochs],
cha_dict: dict[str, DataFrame], cha_dict: dict[str, DataFrame],
df_ind_dict: dict[str, DataFrame], df_ind_dict: dict[str, DataFrame],
design_matrix_dict: dict[str, DataFrame], design_matrix_dict: dict[str, DataFrame],
@@ -40,6 +42,7 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
super().__init__("ExportToCSV") super().__init__("ExportToCSV")
self.setWindowTitle(f"Export To CSV Viewer - {APP_NAME.upper()}") self.setWindowTitle(f"Export To CSV Viewer - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict self.haemo_dict = haemo_dict
self.epochs_dict = epochs_dict
self.cha_dict = cha_dict self.cha_dict = cha_dict
self.df_ind_dict = df_ind_dict self.df_ind_dict = df_ind_dict
self.design_matrix = design_matrix_dict self.design_matrix = design_matrix_dict
@@ -50,8 +53,7 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
self.qc_dict = qc_dict 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)", 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)", "5 (Export Quality Control Metrics to CSV)", "6 (Export Master Consolidated Matrix [All Participants into 1 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) magic_string = self.gen_magic_str(first_params)
self.placeholder_label.setText(magic_string) self.placeholder_label.setText(magic_string)
# elif idx == 4: elif idx == 4:
# # FIR Waveform Features Export # FIR Waveform Features Export
# fir_data = self.fir_feature_dict.get(file_path) fir_data = self.fir_feature_dict.get(file_path)
# if fir_data and isinstance(fir_data, dict): if fir_data:
# names = fir_data.get("feature_names", []) names = fir_data.get("feature_names", [])
# vals = fir_data.get("features", []) vals = fir_data.get("features", [])
# chans = fir_data.get("feature_channels", []) chans = fir_data.get("feature_channels", [])
# fir_df = DataFrame({ fir_df = DataFrame({
# "Feature_Name": names, "Feature_Name": names,
# "Channel": chans if len(chans) == len(names) else ["N/A"] * len(names), "Channel": chans if len(chans) == len(names) else ["N/A"] * len(names),
# "Value": vals "Value": vals
# }) })
# save_path = os.path.join(output_dir, f"{base_filename}_fir_features.csv") save_path = os.path.join(output_dir, f"{base_filename}_fir_features.csv")
# fir_df.to_csv(save_path, index=False) fir_df.to_csv(save_path, index=False)
# success_count += 1 success_count += 1
# elif idx == 5: elif idx == 5:
# # Quality Control (QC) Metrics Export # Quality Control (QC) Metrics Export
# qc_data = self.qc_dict.get(file_path) qc_data = self.qc_dict.get(file_path)
# if qc_data and isinstance(qc_data, dict): if qc_data and isinstance(qc_data, dict):
# qc_df = DataFrame(list(qc_data.items()), columns=["Metric", "Value"]) qc_df = DataFrame(list(qc_data.items()), columns=["Metric", "Value"])
# save_path = os.path.join(output_dir, f"{base_filename}_qc_metrics.csv") save_path = os.path.join(output_dir, f"{base_filename}_qc_metrics.csv")
# qc_df.to_csv(save_path, index=False) qc_df.to_csv(save_path, index=False)
# success_count += 1 success_count += 1
# elif idx == 6: elif idx == 6:
# # Master Consolidated Matrix (1 single CSV combining all selected participants) # Master Consolidated Matrix (1 single CSV combining all selected participants)
# save_path = os.path.join(output_dir, f"{APP_NAME}_master_consolidated.csv") save_path = os.path.join(output_dir, f"{APP_NAME}_master_consolidated.csv")
# if not os.path.exists(save_path): if not os.path.exists(save_path):
# master_rows: list[dict[str, Any]] = [] master_rows: list[dict[str, Any]] = []
# for fp in selected_file_paths: for fp in selected_file_paths:
# abs_path = os.path.abspath(fp) abs_path = os.path.abspath(fp)
# grp = self.group_dict.get(fp, "Unknown") grp = self.group_dict.get(fp, "Unknown")
# row: dict[str, Any] = {"Participant": abs_path, "Group": grp} row: dict[str, Any] = {"Participant": abs_path, "Group": grp}
# # QC Metrics # QC Metrics
# qc_info = self.qc_dict.get(fp, {}) qc_info = self.qc_dict.get(fp, {})
# if isinstance(qc_info, dict): if isinstance(qc_info, dict):
# for mk, mv in qc_info.items(): for mk, mv in qc_info.items():
# row[f"QC_{mk}"] = mv row[f"QC_{mk}"] = mv
# # FIR Features # FIR Features
# fir_info = self.fir_feature_dict.get(fp, {}) fir_info = self.fir_feature_dict.get(fp, {})
# print("1") print("1")
# if isinstance(fir_info, dict): if isinstance(fir_info, dict):
# print("2") print("2")
# f_names = fir_info.get("feature_names", []) f_names = fir_info.get("feature_names", [])
# f_vals = fir_info.get("features", []) f_vals = fir_info.get("features", [])
# print("3") print("3")
# if len(f_names) == len(f_vals): if len(f_names) == len(f_vals):
# print("4") print("4")
# for fn, fv in zip(f_names, f_vals): for fn, fv in zip(f_names, f_vals):
# row[f"FIR_{fn}"] = fv row[f"FIR_{fn}"] = fv
# master_rows.append(row) master_rows.append(row)
# if master_rows: if master_rows:
# df_master = DataFrame(master_rows) df_master = DataFrame(master_rows)
# df_master.to_csv(save_path, index=False) df_master.to_csv(save_path, index=False)
# success_count += 1 success_count += 1
else: else:
print(f"No method defined for index {idx}") print(f"No method defined for index {idx}")
-1
View File
@@ -211,7 +211,6 @@ class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget):
continue continue
cha_df = self.cha_dict.get(file_path) cha_df = self.cha_dict.get(file_path)
if cha_df is not None: if cha_df is not None:
all_cha = pd.concat([all_cha, cha_df], ignore_index=True) 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() self.draw_idle()
class StandaloneLegendDialog(QWidget): class StandaloneLegendDialog(QWidget):
def __init__(self, canvas_engine: Any, title_prefix: str, parent: QWidget | None = None) -> None: def __init__(self, canvas_engine: Any, title_prefix: str, parent: QWidget | None = None) -> None:
super().__init__(None) super().__init__(None)
@@ -317,7 +316,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
num_channels = len(channels_data) num_channels = len(channels_data)
# --- FIX: DYNAMICALLY CALCULATE OPTIMAL 16:9 COLUMNS --- # Dynamically calculate best ratio for 16:9
target_ratio = 16 / 9 target_ratio = 16 / 9
best_cols = 4 best_cols = 4
min_ratio_error = float('inf') min_ratio_error = float('inf')
@@ -678,7 +677,6 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
scroll_layout.setSpacing(6) scroll_layout.setSpacing(6)
scroll_layout.setContentsMargins(0, 0, 0, 0) scroll_layout.setContentsMargins(0, 0, 0, 0)
true_color_map = get_landmark_color_map() true_color_map = get_landmark_color_map()
# Iterate over the sorted keys directly from your method # Iterate over the sorted keys directly from your method
+3 -8
View File
@@ -14,11 +14,12 @@ import pandas as pd
from pandas import DataFrame from pandas import DataFrame
from PySide6.QtWidgets import QApplication, QComboBox, QDialog, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QListView, QMessageBox, QPushButton, QScrollArea, QSizePolicy, QVBoxLayout, QWidget, QFrame, QSpinBox, QFileDialog 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 PySide6.QtCore import QEvent, QPoint, QSize, QTimer, Qt, Signal
from src.shared.shareddata import APP_NAME, PIPELINE_STAGES from src.shared.shareddata import APP_NAME, PIPELINE_STAGES
class FullClickComboBox(QComboBox): class FullClickComboBox(QComboBox):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -1290,7 +1291,6 @@ class FlaresBaseWidget(QWidget):
model.itemChanged.connect(on_item_changed) model.itemChanged.connect(on_item_changed)
def update_participant_list_for_group(self, group_name=None, combo=None): 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) target_combo = combo if combo is not None else getattr(self, "participant_dropdown", None)
@@ -1492,8 +1492,6 @@ class InterGroupUIMixin:
dropdown.blockSignals(False) dropdown.blockSignals(False)
def _get_file_paths_from_labels(self, labels, group_name): def _get_file_paths_from_labels(self, labels, group_name):
file_paths = [] file_paths = []
@@ -1868,7 +1866,6 @@ class IntraGroupUIMixin:
param_info["options"] = dynamic_contrasts param_info["options"] = dynamic_contrasts
param_info["default"] = dynamic_contrasts[0] if dynamic_contrasts else "" param_info["default"] = dynamic_contrasts[0] if dynamic_contrasts else ""
param_values = {} param_values = {}
if indexes_needing_params: if indexes_needing_params:
dialog = ParameterInputDialog(indexes_needing_params, parent=self) dialog = ParameterInputDialog(indexes_needing_params, parent=self)
@@ -1886,6 +1883,7 @@ class IntraGroupUIMixin:
param_values, param_values,
) )
class ParticipantUIMixin: class ParticipantUIMixin:
def setup_participant_ui( def setup_participant_ui(
self, self,
@@ -1913,7 +1911,6 @@ class ParticipantUIMixin:
self.event_dropdown = QComboBox() self.event_dropdown = QComboBox()
self.event_dropdown.addItem("<None Selected>") self.event_dropdown.addItem("<None Selected>")
self.index_texts = index_texts self.index_texts = index_texts
self.image_index_dropdown = self._create_multiselect_dropdown(self.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 # Inject full_text from index_texts
for idx, params_list in parameterized_indexes.items(): 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)" full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)"
+348 -81
View File
@@ -8,6 +8,10 @@ License: GPL-3.0
""" """
# Built-in imports # Built-in imports
import io
import shutil
import urllib.request
import zipfile
from pathlib import Path from pathlib import Path
from typing import Any, cast from typing import Any, cast
@@ -15,26 +19,95 @@ from typing import Any, cast
from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtGui import QColor from PySide6.QtGui import QColor
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMessageBox,
QMessageBox, QPushButton, QTabWidget, QTextBrowser, QVBoxLayout, QWidget 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 from src.shared.shareddata import APP_NAME, CURRENT_VERSION, PLUGINS_URL
class RemoteFetchWorker(QThread): class RemoteFetchWorker(QThread):
"""Background thread to fetch remote repository data without lagging the main UI.""" """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__() super().__init__()
self.manager = manager self.manager = manager
self.urls = urls self.urls = urls
self.fetch_type = fetch_type
def run(self) -> None: def run(self) -> None:
plugins, all_failed = self.manager.fetch_remote_repositories(self.urls) 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): class PluginsWindow(QWidget):
@@ -52,6 +125,8 @@ class PluginsWindow(QWidget):
self.remote_plugins_data: list[dict[str, Any]] = [] self.remote_plugins_data: list[dict[str, Any]] = []
self._has_fetched_remote: bool = False self._has_fetched_remote: bool = False
self._fetch_worker: RemoteFetchWorker | None = None 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 # Refresh UI automatically if manager updates state
self.manager.plugins_changed.connect(self.refresh_installed_plugins) self.manager.plugins_changed.connect(self.refresh_installed_plugins)
@@ -70,40 +145,75 @@ class PluginsWindow(QWidget):
self.setLayout(main_layout) self.setLayout(main_layout)
self.refresh_installed_plugins() 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: 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() self.installed_list.clear()
plugins_info = self.manager.get_installed_plugins_info() 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: 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')})" 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"]: if info["is_disabled"]:
display_text += " [Disabled]" display_text += " [Disabled]"
item = QListWidgetItem(display_text, self.installed_list) item = QListWidgetItem(display_text, self.installed_list)
item.setData(Qt.ItemDataRole.UserRole, info) 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")) item.setForeground(QColor("#757575"))
if self.installed_list.count() == 0: if self.installed_list.count() == 0:
self.installed_list.addItem("No plugins installed.") self.installed_list.addItem("No plugins installed.")
self._clear_details_panel() self._clear_details_panel()
# Re-populate browser list if remote data was already fetched to reflect newly installed/uninstalled plugins if not self._has_fetched_remote:
if self._has_fetched_remote and self.remote_plugins_data: self.check_installed_updates()
elif self.remote_plugins_data:
self._populate_browser_list() self._populate_browser_list()
def _on_installed_item_changed(self, current: QListWidgetItem | None, _: Any) -> None: def _on_installed_item_changed(self, current: QListWidgetItem | None, _: Any) -> None:
"""Updates the right-hand details panel when a plugin is selected.""" """Updates the right-hand details panel when a plugin is selected."""
if not current: if not current:
self._clear_details_panel() self._clear_details_panel()
self.btn_update_plugin.setEnabled(False)
return return
info = current.data(Qt.ItemDataRole.UserRole) info = current.data(Qt.ItemDataRole.UserRole)
if not isinstance(info, dict): if not isinstance(info, dict):
self._clear_details_panel() self._clear_details_panel()
self.btn_update_plugin.setEnabled(False)
return return
plugin_info = cast(dict[str, Any], info) plugin_info = cast(dict[str, Any], info)
@@ -114,83 +224,161 @@ class PluginsWindow(QWidget):
desc = str(plugin_info.get("description", "No description provided.")) desc = str(plugin_info.get("description", "No description provided."))
is_disabled = bool(plugin_info.get("is_disabled", False)) is_disabled = bool(plugin_info.get("is_disabled", False))
path = str(plugin_info.get("path", "")) path = str(plugin_info.get("path", ""))
update_available = bool(plugin_info.get("update_available", False))
self.lbl_plugin_title.setText(name) self.lbl_plugin_title.setText(name)
self.lbl_plugin_meta.setText(f"<b>Version:</b> {version} &nbsp;|&nbsp; <b>Author:</b> {author}") 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>") self.lbl_plugin_status.setText("<font color='#d32f2f'><b>Status: Disabled</b></font>")
else: else:
self.lbl_plugin_status.setText("<font color='#2e7d32'><b>Status: Active</b></font>") self.lbl_plugin_status.setText("<font color='#2e7d32'><b>Status: Active</b></font>")
self.txt_plugin_desc.setHtml(desc) self.txt_plugin_desc.setHtml(desc)
self.lbl_plugin_path.setText(f"<b>Path:</b> <code>{path}</code>") 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: def execute_plugin_installation(self, download_url: str, plugin_id: str, plugin_name: str, is_update: bool, plugin_path: Path | None) -> None:
"""Clears details display when nothing is selected.""" """Sets up the progress dialog and starts the background installation/update worker."""
self.lbl_plugin_title.setText("Select a plugin") if self._install_worker is not None and self._install_worker.isRunning():
self.lbl_plugin_meta.setText("") return
self.lbl_plugin_status.setText("")
self.txt_plugin_desc.clear()
self.lbl_plugin_path.setText("")
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() selected = self.installed_list.currentItem()
if not selected: if not selected:
return return
raw_info = selected.data(Qt.ItemDataRole.UserRole) 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): if not isinstance(raw_info, dict):
return return
info = cast(dict[str, Any], raw_info) 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_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 return
reply = QMessageBox.question( path_obj = Path(plugin_path) if isinstance(plugin_path, str) and plugin_path else (plugin_path if isinstance(plugin_path, Path) else None)
self, self.execute_plugin_installation(download_url, plugin_id, plugin_name, is_update=True, plugin_path=path_obj)
"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))
def fetch_remote_plugins(self) -> None: def install_selected_plugin(self) -> None:
"""Asynchronously fetches remote plugins on a background thread.""" """Triggers the background installation/update process from the browser tab."""
if self._fetch_worker is not None and self._fetch_worker.isRunning(): selected = self.browser_list.currentItem()
if not selected:
return
data = selected.data(Qt.ItemDataRole.UserRole)
if not isinstance(data, dict):
return return
self.browser_list.clear() plugin_data = cast(dict[str, Any], data)
self.browser_list.addItem("Fetching remote repositories...")
self.btn_install.setEnabled(False)
self.btn_fetch.setEnabled(False)
# Defer network call to background worker to avoid UI freeze update_available = bool(selected.data(Qt.ItemDataRole.UserRole + 3))
self._fetch_worker = RemoteFetchWorker(self.manager, self.repository_urls) plugin_id = str(plugin_data.get("id", "unnamed"))
self._fetch_worker.fetched.connect(self._on_remote_fetched) plugin_name = str(plugin_data.get("name", "Plugin"))
self._fetch_worker.start() 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.""" """Callback executed on the main UI thread when remote fetching finishes."""
self.remote_plugins_data = plugins self.remote_plugins_data = plugins
self._has_fetched_remote = True self._has_fetched_remote = True
self.btn_fetch.setEnabled(True) self.btn_fetch.setEnabled(True)
if fetch_type == "updates":
self.refresh_installed_plugins()
return
if plugins: if plugins:
self._populate_browser_list() self._populate_browser_list()
elif all_failed: elif all_failed:
@@ -200,13 +388,33 @@ class PluginsWindow(QWidget):
self.browser_list.clear() self.browser_list.clear()
self.browser_list.addItem("No plugins found across configured repositories.") 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: 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() self.browser_list.clear()
installed_info = self.manager.get_installed_plugins_info() installed_info = self.manager.get_installed_plugins_info()
installed_ids = {p.get("id") for p in installed_info if p.get("id")} installed_map: dict[str, dict[str, Any]] = {}
installed_names = {p.get("name") for p in installed_info if p.get("name")} 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: for plugin in self.remote_plugins_data:
name = plugin.get("name", "Unknown") 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_version_ok = self.manager.current_app_version >= parse_version(min_v_str)
is_compatible = is_platform_ok and is_version_ok is_compatible = is_platform_ok and is_version_ok
# Evaluate reasons for incompatibility
incompat_reasons: list[str] = [] incompat_reasons: list[str] = []
if not is_platform_ok: if not is_platform_ok:
plat_str = ", ".join(platforms) if isinstance(platforms, list) else str(platforms) plat_str = ", ".join(platforms) if isinstance(platforms, list) else str(platforms)
@@ -228,10 +435,20 @@ class PluginsWindow(QWidget):
if not is_version_ok: if not is_version_ok:
incompat_reasons.append(f"Requires App v{min_v_str}+") 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}" 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]" display_text += " [Installed]"
elif incompat_reasons: elif incompat_reasons:
reason_str = "; ".join(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, plugin)
item.setData(Qt.ItemDataRole.UserRole + 1, is_compatible) item.setData(Qt.ItemDataRole.UserRole + 1, is_compatible)
item.setData(Qt.ItemDataRole.UserRole + 2, is_installed) item.setData(Qt.ItemDataRole.UserRole + 2, is_installed)
item.setData(Qt.ItemDataRole.UserRole + 3, update_available)
if is_installed: if update_available:
item.setForeground(QColor("#2e7d32")) # Green for installed item.setForeground(QColor("#1976d2"))
elif is_installed:
item.setForeground(QColor("#2e7d32"))
elif not is_compatible: elif not is_compatible:
item.setForeground(QColor("#d32f2f")) # Red for incompatible item.setForeground(QColor("#d32f2f"))
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}")
def _on_tab_changed(self, index: int) -> None: def _on_tab_changed(self, index: int) -> None:
if index == 1 and not self._has_fetched_remote: if index == 1 and not self._has_fetched_remote:
@@ -272,10 +475,19 @@ class PluginsWindow(QWidget):
if current: if current:
is_compatible = bool(current.data(Qt.ItemDataRole.UserRole + 1)) is_compatible = bool(current.data(Qt.ItemDataRole.UserRole + 1))
is_installed = bool(current.data(Qt.ItemDataRole.UserRole + 2)) 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 if update_available:
self.btn_install.setEnabled(is_compatible and not is_installed) 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: 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) self.btn_install.setEnabled(False)
def _on_add_repo(self) -> None: def _on_add_repo(self) -> None:
@@ -334,14 +546,19 @@ class PluginsWindow(QWidget):
btn_layout = QHBoxLayout() btn_layout = QHBoxLayout()
btn_refresh = QPushButton("Refresh List", tab) 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_enable = QPushButton("Enable / Disable", tab)
btn_uninstall = QPushButton("Uninstall", tab) btn_uninstall = QPushButton("Uninstall", tab)
btn_refresh.clicked.connect(self.refresh_installed_plugins) 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_enable.clicked.connect(self.toggle_enable_disable)
btn_uninstall.clicked.connect(self.uninstall_plugin) btn_uninstall.clicked.connect(self.uninstall_plugin)
btn_layout.addWidget(btn_refresh) btn_layout.addWidget(btn_refresh)
btn_layout.addWidget(self.btn_update_plugin)
btn_layout.addStretch() btn_layout.addStretch()
btn_layout.addWidget(btn_enable) btn_layout.addWidget(btn_enable)
btn_layout.addWidget(btn_uninstall) btn_layout.addWidget(btn_uninstall)
@@ -389,3 +606,53 @@ class PluginsWindow(QWidget):
layout.addWidget(self.browser_list) layout.addWidget(self.browser_list)
layout.addLayout(btn_layout) layout.addLayout(btn_layout)
return tab 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), ("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), ("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), ("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) layout = QVBoxLayout(self)