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