Compare commits
4
Commits
4059a3ef93
...
v1.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2456daca7 | ||
|
|
d1c9346e57 | ||
|
|
63785d3a55 | ||
|
|
3a8ffbdf70 |
No files matched your search
@@ -185,3 +185,4 @@ flares-*
|
|||||||
*.cfg
|
*.cfg
|
||||||
tempCodeRunnerFile.py
|
tempCodeRunnerFile.py
|
||||||
*.pkl
|
*.pkl
|
||||||
|
*.joblib
|
||||||
@@ -1,3 +1,24 @@
|
|||||||
|
# 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
|
||||||
|
- A notification now appears when processing completes even if the application is not focused
|
||||||
|
- Fixed a regression where opening a folder would no longer load all the files in that folder
|
||||||
|
- Added inspirational quotes to the About window
|
||||||
|
- Licence is now clickable from the About window
|
||||||
|
- Improved code stability and functionality
|
||||||
|
|
||||||
|
|
||||||
# Version 1.7.2
|
# Version 1.7.2
|
||||||
|
|
||||||
- Fixed the Linux build not starting if shared library files were missing from the host machine
|
- Fixed the Linux build not starting if shared library files were missing from the host machine
|
||||||
|
|||||||
@@ -105,8 +105,7 @@ from mne_nirs.statistics._glm_level_first import RegressionResults # type: igno
|
|||||||
from mne_connectivity.viz import plot_connectivity_circle # type: ignore
|
from mne_connectivity.viz import plot_connectivity_circle # type: ignore
|
||||||
from mne_connectivity import envelope_correlation, spectral_connectivity_epochs, spectral_connectivity_time # type: ignore
|
from mne_connectivity import envelope_correlation, spectral_connectivity_epochs, spectral_connectivity_time # type: ignore
|
||||||
|
|
||||||
from src.shared.shareddata import PLATFORM_NAME, resource_path, get_app_dir
|
from src.shared.shareddata import PLATFORM_NAME, resource_path, get_app_dir, METRIC_REGISTRY
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
PRIMARY_COLORS = {
|
PRIMARY_COLORS = {
|
||||||
@@ -301,6 +300,8 @@ GROUP: str = "Default"
|
|||||||
|
|
||||||
FOLDING_BYP: bool = False
|
FOLDING_BYP: bool = False
|
||||||
|
|
||||||
|
SEPARATE_EVENTS: bool = False
|
||||||
|
|
||||||
FEATURE_1: bool = False
|
FEATURE_1: bool = False
|
||||||
FEATURE_2: bool = False
|
FEATURE_2: bool = False
|
||||||
|
|
||||||
@@ -326,7 +327,6 @@ logging.basicConfig(
|
|||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def set_config_me(config: dict[str, Any]) -> None:
|
def set_config_me(config: dict[str, Any]) -> None:
|
||||||
"""
|
"""
|
||||||
Validates and applies the given configuration dictionary.
|
Validates and applies the given configuration dictionary.
|
||||||
@@ -422,6 +422,27 @@ def gui_entry(config: dict[str, Any], gui_queue: mp.Queue, progress_queue: mp.Qu
|
|||||||
def process_participant_worker(file_path, file_params, file_metadata, result_queue, progress_queue):
|
def process_participant_worker(file_path, file_params, file_metadata, result_queue, progress_queue):
|
||||||
file_start = time.time()
|
file_start = time.time()
|
||||||
stage_tracker = {"value": 0.0}
|
stage_tracker = {"value": 0.0}
|
||||||
|
|
||||||
|
class QueueStreamAlt:
|
||||||
|
def __init__(self, fp, q):
|
||||||
|
self.fp = fp
|
||||||
|
self.q = q
|
||||||
|
|
||||||
|
def write(self, msg):
|
||||||
|
if msg and self.q:
|
||||||
|
try:
|
||||||
|
self.q.put(("log", self.fp, msg))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def flush(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
old_stdout = sys.stdout
|
||||||
|
old_stderr = sys.stderr
|
||||||
|
sys.stdout = QueueStreamAlt(file_path, progress_queue)
|
||||||
|
sys.stderr = QueueStreamAlt(file_path, progress_queue)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
set_config_me(file_params)
|
set_config_me(file_params)
|
||||||
set_metadata(file_path, file_metadata)
|
set_metadata(file_path, file_metadata)
|
||||||
@@ -446,6 +467,8 @@ def process_participant_worker(file_path, file_params, file_metadata, result_que
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
sys.stdout = old_stdout
|
||||||
|
sys.stderr = old_stderr
|
||||||
try:
|
try:
|
||||||
plt.close('all')
|
plt.close('all')
|
||||||
gc.collect()
|
gc.collect()
|
||||||
@@ -2101,7 +2124,6 @@ def plot_glm_results(file_path, raw_haemo, glm_est, design_matrix):
|
|||||||
axes[2].set_ylabel("Haemoglobin (ΔμMol)")
|
axes[2].set_ylabel("Haemoglobin (ΔμMol)")
|
||||||
axes[2].legend(["Oxyhaemoglobin", "Deoxyhaemoglobin"])
|
axes[2].legend(["Oxyhaemoglobin", "Deoxyhaemoglobin"])
|
||||||
|
|
||||||
|
|
||||||
print(f"Number of FIR bins: {len(vals_hbo)}")
|
print(f"Number of FIR bins: {len(vals_hbo)}")
|
||||||
print(f"Mean theta (HbO): {np.mean(vals_hbo):.4f}")
|
print(f"Mean theta (HbO): {np.mean(vals_hbo):.4f}")
|
||||||
print(f"Sum of theta (HbO): {np.sum(vals_hbo):.4f}")
|
print(f"Sum of theta (HbO): {np.sum(vals_hbo):.4f}")
|
||||||
@@ -2372,7 +2394,6 @@ def brain_3d_visualization(
|
|||||||
|
|
||||||
model_df = cast(DataFrame, statsmodels_to_results(ch_model, order=ch_summary["ch_name"].unique()))
|
model_df = cast(DataFrame, statsmodels_to_results(ch_model, order=ch_summary["ch_name"].unique()))
|
||||||
|
|
||||||
|
|
||||||
valid_channels = ch_summary["ch_name"].unique().tolist() # type: ignore
|
valid_channels = ch_summary["ch_name"].unique().tolist() # type: ignore
|
||||||
raw_for_plot = raw_haemo.copy().pick(picks=valid_channels) # type: ignore
|
raw_for_plot = raw_haemo.copy().pick(picks=valid_channels) # type: ignore
|
||||||
|
|
||||||
@@ -2476,7 +2497,6 @@ def brain_landmarks_3d(raw_haemo: BaseRaw, show_optodes: Literal['sensors', 'lab
|
|||||||
scale=0.002
|
scale=0.002
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
if show_brodmann:# Add Brodmann labels
|
if show_brodmann:# Add Brodmann labels
|
||||||
labels = cast(list[Label], read_labels_from_annot("fsaverage", "PALS_B12_Brodmann", "lh", verbose=False)) # type: ignore
|
labels = cast(list[Label], read_labels_from_annot("fsaverage", "PALS_B12_Brodmann", "lh", verbose=False)) # type: ignore
|
||||||
|
|
||||||
@@ -5990,6 +6010,24 @@ def process_participant(file_path, file_start, progress_callback=None):
|
|||||||
logger.info("Step 21 Completed.")
|
logger.info("Step 21 Completed.")
|
||||||
step_start = lap(step_start, timings, "Step 21")
|
step_start = lap(step_start, timings, "Step 21")
|
||||||
|
|
||||||
|
# Step 21.5: Make annotations trial-specific for individual trial GLM/FIR
|
||||||
|
if SEPARATE_EVENTS:
|
||||||
|
annots = raw_haemo.annotations
|
||||||
|
if len(annots) > 0:
|
||||||
|
new_descriptions = []
|
||||||
|
cond_counts = {}
|
||||||
|
for desc in annots.description:
|
||||||
|
# Optional: filter for specific task conditions if you have other markers like 'bad' or 'iseg'
|
||||||
|
cond_counts[desc] = cond_counts.get(desc, 0) + 1
|
||||||
|
new_descriptions.append(f"{desc}_trial_{cond_counts[desc]}")
|
||||||
|
|
||||||
|
# Update raw_haemo annotations with trial-specific names
|
||||||
|
raw_haemo.set_annotations(Annotations(
|
||||||
|
onset=annots.onset,
|
||||||
|
duration=annots.duration,
|
||||||
|
description=new_descriptions
|
||||||
|
))
|
||||||
|
|
||||||
# Step 22: Design Matrix
|
# Step 22: Design Matrix
|
||||||
raw_haemo, raw_haemo_dm, df_design_matrix, fig_design_matrix = make_design_matrix(
|
raw_haemo, raw_haemo_dm, df_design_matrix, fig_design_matrix = make_design_matrix(
|
||||||
raw_haemo=raw_haemo,
|
raw_haemo=raw_haemo,
|
||||||
@@ -6071,7 +6109,6 @@ def process_participant(file_path, file_start, progress_callback=None):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Step 27.5 Failed to extract FIR features: {e}")
|
logger.warning(f"Step 27.5 Failed to extract FIR features: {e}")
|
||||||
|
|
||||||
|
|
||||||
# Step 28: Finishing Up
|
# Step 28: Finishing Up
|
||||||
png_queue.put(None) # sentinel
|
png_queue.put(None) # sentinel
|
||||||
png_queue.join() # blocks only on remaining unfinished work
|
png_queue.join() # blocks only on remaining unfinished work
|
||||||
@@ -6404,11 +6441,6 @@ def _log_matrix_diagnostics(name: str, mat: np.ndarray) -> None:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Shared: channel alignment across participants
|
# Shared: channel alignment across participants
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -6797,13 +6829,6 @@ def run_group_functional_connectivity_epochs(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def sparks_csv_export(
|
def sparks_csv_export(
|
||||||
haemo_obj: BaseRaw,
|
haemo_obj: BaseRaw,
|
||||||
save_path: str,
|
save_path: str,
|
||||||
@@ -6828,7 +6853,6 @@ def sparks_csv_export(
|
|||||||
df.to_csv(save_path, index=False)
|
df.to_csv(save_path, index=False)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def peak_power_fast(
|
def peak_power_fast(
|
||||||
raw,
|
raw,
|
||||||
time_window=10,
|
time_window=10,
|
||||||
@@ -7128,20 +7152,6 @@ def write_qc_excel_summary(
|
|||||||
wb.save(output_path)
|
wb.save(output_path)
|
||||||
|
|
||||||
|
|
||||||
METRIC_REGISTRY = {
|
|
||||||
'Peak_Amp': 'Peak_Amp',
|
|
||||||
'TTP': 'Time_to_Peak',
|
|
||||||
'AUC': 'AUC',
|
|
||||||
'Rising_Slope': 'Rising_Slope',
|
|
||||||
'Recovery_Slope': 'Recovery_Slope',
|
|
||||||
'FWHM': 'FWHM',
|
|
||||||
'Onset_Latency': 'Onset_Latency',
|
|
||||||
'P2P_Amp': 'Peak_to_Peak_Amp',
|
|
||||||
'Signal_Std': 'Signal_Std',
|
|
||||||
'RMS': 'RMS'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def plot_and_enqueue_waveform_metrics(
|
def plot_and_enqueue_waveform_metrics(
|
||||||
roi_curves,
|
roi_curves,
|
||||||
fir_delays,
|
fir_delays,
|
||||||
@@ -7413,6 +7423,21 @@ def normalize_roi_map(roi_map):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def extract_epoch_features(epochs, file_path, window=(4.0, 12.0)):
|
||||||
|
"""One row per (epoch, channel): mean haemoglobin amplitude within `window` seconds."""
|
||||||
|
tmask = (epochs.times >= window[0]) & (epochs.times <= window[1])
|
||||||
|
feat = epochs.get_data()[:, :, tmask].mean(axis=2) * 1e6 # (n_epochs, n_channels), µM
|
||||||
|
id_to_name = {v: k for k, v in epochs.event_id.items()}
|
||||||
|
df = pd.DataFrame(feat, columns=epochs.ch_names)
|
||||||
|
df["Condition"] = [id_to_name[e] for e in epochs.events[:, 2]]
|
||||||
|
df["trial"] = epochs.selection # index of the epoch in the original event list (survives rejection)
|
||||||
|
long = df.melt(id_vars=["Condition", "trial"], var_name="ch_name", value_name="theta")
|
||||||
|
long["Chroma"] = long["ch_name"].map(dict(zip(epochs.ch_names, epochs.get_channel_types())))
|
||||||
|
long["ID"] = file_path
|
||||||
|
return long[["ch_name", "Chroma", "ID", "Condition", "trial", "theta"]]
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
print("This file has no functionality when not used in tandem with the FLARES application.")
|
print("This file has no functionality when not used in tandem with the FLARES application.")
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import traceback
|
|||||||
import subprocess
|
import subprocess
|
||||||
import configparser
|
import configparser
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
|
from typing import Any
|
||||||
from queue import Empty
|
from queue import Empty
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -30,15 +31,15 @@ if sys.platform.startswith("linux"):
|
|||||||
import psutil
|
import psutil
|
||||||
|
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QApplication, QWidget, QMessageBox, QVBoxLayout, QHBoxLayout, QTextEdit, QScrollArea, QComboBox, QGridLayout, QSplitter, QDialogButtonBox, QHeaderView,
|
QApplication, QStyle, QSystemTrayIcon, QWidget, QMessageBox, QVBoxLayout, QHBoxLayout, QTextEdit, QScrollArea, QComboBox, QGridLayout, QSplitter, QDialogButtonBox, QHeaderView,
|
||||||
QPushButton, QMainWindow, QLabel, QLineEdit, QGroupBox, QDialog, QMenu, QSpinBox, QTableWidget, QTableWidgetItem
|
QPushButton, QMainWindow, QLabel, QLineEdit, QGroupBox, QDialog, QMenu, QSpinBox, QTableWidget, QTableWidgetItem
|
||||||
)
|
)
|
||||||
from PySide6.QtCore import QEvent, QObject, Signal, Qt, QTimer
|
from PySide6.QtCore import QEvent, QObject, Signal, Qt, QTimer
|
||||||
from PySide6.QtGui import QAction, QActionGroup, QFontMetrics, QKeySequence, QIcon
|
from PySide6.QtGui import QAction, QActionGroup, QFontMetrics, QKeySequence, QIcon, QTextCursor
|
||||||
from PySide6.QtSvgWidgets import QSvgWidget # needed to show svgs when app is not frozen
|
from PySide6.QtSvgWidgets import QSvgWidget # needed to show svgs when app is not frozen
|
||||||
|
|
||||||
from file_ext_registration import register_file_association, ELEVATION_FLAG
|
from file_ext_registration import register_file_association, ELEVATION_FLAG
|
||||||
from plugin_manager import PluginManager
|
from plugin_manager import PluginManager, StartupUpdateWorker
|
||||||
from project_manager import ProjectManager
|
from project_manager import ProjectManager
|
||||||
from src.window.about import AboutWindow
|
from src.window.about import AboutWindow
|
||||||
from src.window.plugins import PluginsWindow
|
from src.window.plugins import PluginsWindow
|
||||||
@@ -49,7 +50,7 @@ from src.window.userguide import UserGuideWindow
|
|||||||
from src.window.viewerlauncher import ViewerLauncherWidget
|
from src.window.viewerlauncher import ViewerLauncherWidget
|
||||||
from src.window.welcome import WelcomeDialog
|
from src.window.welcome import WelcomeDialog
|
||||||
from src.shared.flaresbasewidget import FilePickerWidget, ParamSection, ProgressBubble
|
from src.shared.flaresbasewidget import FilePickerWidget, ParamSection, ProgressBubble
|
||||||
from src.shared.shareddata import API_URL, API_URL_SECONDARY, APP_NAME, CURRENT_VERSION, PLATFORM_NAME, DATA_SCHEMA, get_app_dir
|
from src.shared.shareddata import API_URL, API_URL_SECONDARY, APP_NAME, CURRENT_VERSION, PLATFORM_NAME, DATA_SCHEMA, PLUGINS_URL, get_app_dir
|
||||||
from startup_args import parse_startup_args
|
from startup_args import parse_startup_args
|
||||||
from updater import finish_update_if_needed, UpdateManager, LocalPendingUpdateCheckThread
|
from updater import finish_update_if_needed, UpdateManager, LocalPendingUpdateCheckThread
|
||||||
|
|
||||||
@@ -71,13 +72,19 @@ show_welcome_dialog = false
|
|||||||
first_startup = true
|
first_startup = true
|
||||||
|
|
||||||
[Preferences]
|
[Preferences]
|
||||||
theme = auto
|
theme_mode = auto
|
||||||
2d_data_bypass = false
|
|
||||||
incompatible_save_bypass = false
|
processing_2d_data_bypass = false
|
||||||
missing_events_bypass = false
|
processing_incompatible_save_bypass = false
|
||||||
analysis_clearing_bypass = false
|
processing_missing_events_bypass = false
|
||||||
folding_bypass = false
|
processing_analysis_clearing_bypass = false
|
||||||
advanced_parameters = false
|
processing_folding_bypass = false
|
||||||
|
processing_separate_events = false
|
||||||
|
|
||||||
|
ui_advanced_parameters = false
|
||||||
|
|
||||||
|
updates_check_plugins_automatic = true
|
||||||
|
updates_check_app_automatic = true
|
||||||
|
|
||||||
[Terminal]
|
[Terminal]
|
||||||
|
|
||||||
@@ -85,6 +92,7 @@ advanced_parameters = false
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
# Selectable parameters on the right side of the window
|
# Selectable parameters on the right side of the window
|
||||||
SECTIONS = [
|
SECTIONS = [
|
||||||
{
|
{
|
||||||
@@ -334,6 +342,33 @@ BIDS_FIELD_MAP = {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class LogWindow(QDialog):
|
||||||
|
|
||||||
|
def __init__(self, title, file_path, log_text, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.file_path = file_path
|
||||||
|
self.setWindowTitle(title)
|
||||||
|
self.resize(700, 500)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
self.text_edit = QTextEdit(self)
|
||||||
|
self.text_edit.setReadOnly(True)
|
||||||
|
self.text_edit.setPlainText(log_text)
|
||||||
|
|
||||||
|
font = self.text_edit.font()
|
||||||
|
font.setFamily("Courier")
|
||||||
|
self.text_edit.setFont(font)
|
||||||
|
|
||||||
|
layout.addWidget(self.text_edit)
|
||||||
|
|
||||||
|
def append_log(self, text):
|
||||||
|
# Auto-scroll to the bottom and insert incoming text chunk
|
||||||
|
cursor = self.text_edit.textCursor()
|
||||||
|
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||||||
|
self.text_edit.setTextCursor(cursor)
|
||||||
|
self.text_edit.insertPlainText(text)
|
||||||
|
|
||||||
|
|
||||||
class GroupAssignmentDialog(QDialog):
|
class GroupAssignmentDialog(QDialog):
|
||||||
"""Dialog allowing users to create groups and assign unique metadata values to them."""
|
"""Dialog allowing users to create groups and assign unique metadata values to them."""
|
||||||
|
|
||||||
@@ -366,7 +401,6 @@ class GroupAssignmentDialog(QDialog):
|
|||||||
|
|
||||||
def _init_ui(self):
|
def _init_ui(self):
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
# Field selector
|
# Field selector
|
||||||
field_box_layout = QHBoxLayout()
|
field_box_layout = QHBoxLayout()
|
||||||
field_box_layout.addWidget(QLabel("Group by:"))
|
field_box_layout.addWidget(QLabel("Group by:"))
|
||||||
@@ -607,25 +641,34 @@ class MainApplication(QMainWindow):
|
|||||||
self.events_blazes = None
|
self.events_blazes = None
|
||||||
self.plugins = None
|
self.plugins = None
|
||||||
self.terminal = None
|
self.terminal = None
|
||||||
self.bubble_widgets = {}
|
|
||||||
self.param_sections = []
|
|
||||||
self.folder_paths = []
|
|
||||||
self.section_widget = None
|
self.section_widget = None
|
||||||
self.first_run = True
|
self.first_run = True
|
||||||
self.show_status_bar = False
|
|
||||||
self.is_2d_bypass = False
|
|
||||||
self.incompatible_save_bypass = False
|
|
||||||
self.missing_events_bypass = False
|
|
||||||
self.analysis_clearing_bypass = False
|
|
||||||
self.folding_bypass = False
|
|
||||||
self.advanced_parameters = False
|
|
||||||
|
|
||||||
self.is_saved = True
|
self.is_saved = True
|
||||||
self.current_project_path = None
|
self.current_project_path = None
|
||||||
|
self.current_file = None
|
||||||
self.selected_paths = []
|
self.selected_paths = []
|
||||||
self.saved_selected_paths = []
|
self.saved_selected_paths = []
|
||||||
self.files_are_dirty = False
|
self.files_are_dirty = False
|
||||||
|
|
||||||
|
self.file_metadata = {} # AGE, SEX, HAND, GROUP
|
||||||
|
self.metadata_cache = {} # Internal file/path information metadata cache
|
||||||
|
self.bubble_widgets = {} # References to the UI "Bubble" objects
|
||||||
|
self.participant_logs = {}
|
||||||
|
|
||||||
|
self.files_total = 0 # total number of files to process
|
||||||
|
self.files_done = set() # set of file paths done (success or fail)
|
||||||
|
self.files_failed = set() # set of failed file paths
|
||||||
|
self.files_results = {} # dict for successful results (if needed)
|
||||||
|
|
||||||
|
self.pending_update_version = None
|
||||||
|
self.pending_update_path = None
|
||||||
|
|
||||||
|
# are these used?
|
||||||
|
self.param_sections = []
|
||||||
|
self.folder_paths = []
|
||||||
|
self.last_clicked_bubble = None
|
||||||
|
|
||||||
self.project_manager = ProjectManager(self, file_cfg=file_cfg, cfg_path=cfg_path)
|
self.project_manager = ProjectManager(self, file_cfg=file_cfg, cfg_path=cfg_path)
|
||||||
self.plugin_manager = PluginManager(self)
|
self.plugin_manager = PluginManager(self)
|
||||||
|
|
||||||
@@ -633,18 +676,35 @@ class MainApplication(QMainWindow):
|
|||||||
for item in DATA_SCHEMA:
|
for item in DATA_SCHEMA:
|
||||||
setattr(self, item["key"], {})
|
setattr(self, item["key"], {})
|
||||||
|
|
||||||
self.file_metadata = {} # AGE, SEX, HAND, GROUP
|
config_mappings = {
|
||||||
self.metadata_cache = {} # Internal file/path information metadata cache
|
"View": ["status_bar"],
|
||||||
self.bubble_widgets = {} # References to the UI "Bubble" objects
|
"Preferences": [
|
||||||
self.current_file = None # Tracks the currently selected absolute path
|
"theme_mode",
|
||||||
|
"processing_2d_data_bypass",
|
||||||
|
"processing_incompatible_save_bypass",
|
||||||
|
"processing_missing_events_bypass",
|
||||||
|
"processing_analysis_clearing_bypass",
|
||||||
|
"processing_folding_bypass",
|
||||||
|
"processing_separate_events",
|
||||||
|
"ui_advanced_parameters",
|
||||||
|
"updates_check_plugins_automatic",
|
||||||
|
"updates_check_app_automatic",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
for section, keys in config_mappings.items():
|
||||||
|
for key in keys:
|
||||||
|
if section == "Preferences" and key == "theme_mode":
|
||||||
|
val = file_cfg.get(section, key, fallback="auto")
|
||||||
|
elif key in ["status_bar", "updates_check_plugins_automatic", "updates_check_app_automatic"]:
|
||||||
|
val = file_cfg.getboolean(section, key, fallback=True)
|
||||||
|
else:
|
||||||
|
val = file_cfg.getboolean(section, key, fallback=False)
|
||||||
|
setattr(self, key, val)
|
||||||
|
|
||||||
self.metadata_processed.connect(self._safe_ui_update)
|
self.metadata_processed.connect(self._safe_ui_update)
|
||||||
self.metadata_ui_signal.connect(self._handle_metadata_ui_update)
|
self.metadata_ui_signal.connect(self._handle_metadata_ui_update)
|
||||||
|
|
||||||
self.files_total = 0 # total number of files to process
|
|
||||||
self.files_done = set() # set of file paths done (success or fail)
|
|
||||||
self.files_failed = set() # set of failed file paths
|
|
||||||
self.files_results = {} # dict for successful results (if needed)
|
|
||||||
|
|
||||||
self.updater = UpdateManager(
|
self.updater = UpdateManager(
|
||||||
main_window=self,
|
main_window=self,
|
||||||
@@ -658,20 +718,38 @@ class MainApplication(QMainWindow):
|
|||||||
|
|
||||||
self.init_ui()
|
self.init_ui()
|
||||||
self.create_menu_bar()
|
self.create_menu_bar()
|
||||||
|
|
||||||
self.pending_update_version = None
|
|
||||||
self.pending_update_path = None
|
|
||||||
self.last_clicked_bubble = None
|
|
||||||
self.installEventFilter(self)
|
self.installEventFilter(self)
|
||||||
|
|
||||||
# Start local pending update check thread
|
# Conditionally start background network threads using the loaded attributes
|
||||||
|
if self.updates_check_app_automatic:
|
||||||
self.local_check_thread = LocalPendingUpdateCheckThread(CURRENT_VERSION, "-" + PLATFORM_NAME, PLATFORM_NAME, APP_NAME)
|
self.local_check_thread = LocalPendingUpdateCheckThread(CURRENT_VERSION, "-" + PLATFORM_NAME, PLATFORM_NAME, APP_NAME)
|
||||||
self.local_check_thread.pending_update_found.connect(self.updater.on_pending_update_found)
|
self.local_check_thread.pending_update_found.connect(self.updater.on_pending_update_found)
|
||||||
self.local_check_thread.no_pending_update.connect(self.updater.on_no_pending_update)
|
self.local_check_thread.no_pending_update.connect(self.updater.on_no_pending_update)
|
||||||
self.local_check_thread.start()
|
self.local_check_thread.start()
|
||||||
|
else:
|
||||||
|
print("[MainApplication] App automatic update check is disabled in preferences.")
|
||||||
|
|
||||||
|
if self.updates_check_plugins_automatic:
|
||||||
|
self._startup_update_worker = StartupUpdateWorker(self.plugin_manager, [PLUGINS_URL])
|
||||||
|
self._startup_update_worker.updates_checked.connect(self._on_startup_updates_found)
|
||||||
|
self._startup_update_worker.start()
|
||||||
|
else:
|
||||||
|
print("[MainApplication] Plugin automatic update check is disabled in preferences.")
|
||||||
|
|
||||||
self.plugin_manager.reload_plugins()
|
self.plugin_manager.reload_plugins()
|
||||||
|
|
||||||
|
self.tray_icon = QSystemTrayIcon(self)
|
||||||
|
stock_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_ComputerIcon) # Or use your own QIcon("path_to_logo.png")
|
||||||
|
self.tray_icon.setIcon(stock_icon)
|
||||||
|
self.tray_icon.show()
|
||||||
|
|
||||||
|
def bring_to_front():
|
||||||
|
self.showNormal() # Restores if minimized
|
||||||
|
self.raise_() # Brings to top of window stack (macOS/Linux)
|
||||||
|
self.activateWindow() # Focuses the window (Windows)
|
||||||
|
|
||||||
|
self.tray_icon.messageClicked.connect(bring_to_front)
|
||||||
|
|
||||||
self.show()
|
self.show()
|
||||||
|
|
||||||
# Check if we should pop up the welcome screen
|
# Check if we should pop up the welcome screen
|
||||||
@@ -704,7 +782,6 @@ class MainApplication(QMainWindow):
|
|||||||
self.project_manager.load_project(file_to_open)
|
self.project_manager.load_project(file_to_open)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def init_ui(self):
|
def init_ui(self):
|
||||||
central = QWidget()
|
central = QWidget()
|
||||||
self.setCentralWidget(central)
|
self.setCentralWidget(central)
|
||||||
@@ -830,18 +907,40 @@ class MainApplication(QMainWindow):
|
|||||||
# for stat in top_stats[:10]:
|
# for stat in top_stats[:10]:
|
||||||
# print(stat)
|
# print(stat)
|
||||||
|
|
||||||
def create_menu_bar(self):
|
def _on_startup_updates_found(self, outdated_plugins: list[dict[str, Any]]) -> None:
|
||||||
'''Menu Bar at the top of the screen'''
|
"""Safely displays the update popup on the main thread once the background check finishes."""
|
||||||
|
if not outdated_plugins:
|
||||||
|
return
|
||||||
|
|
||||||
|
update_details = "\n".join(
|
||||||
|
f"• {p['name']}: Installed v{p['version']} ➔ New v{p['remote_version']}"
|
||||||
|
for p in outdated_plugins
|
||||||
|
)
|
||||||
|
summary_msg = f"The following plugin updates are available:\n\n{update_details}\n\nYou can update them in the Plugin Manager."
|
||||||
|
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
"Plugin Updates Available",
|
||||||
|
summary_msg
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_menu_bar(self):
|
||||||
|
"""Menu Bar at the top of the screen built dynamically from a unified schema."""
|
||||||
menu_bar = self.menuBar()
|
menu_bar = self.menuBar()
|
||||||
self.statusbar = self.statusBar()
|
self.statusbar = self.statusBar()
|
||||||
|
|
||||||
|
self.view_actions = {}
|
||||||
|
self.pref_actions = {}
|
||||||
|
|
||||||
def make_action(name, shortcut=None, slot=None, checkable=False, checked=False, icon=None):
|
def make_action(name, shortcut=None, slot=None, checkable=False, checked=False, icon=None):
|
||||||
action = QAction(name, self)
|
action = QAction(name, self)
|
||||||
|
|
||||||
if shortcut:
|
if shortcut:
|
||||||
action.setShortcut(QKeySequence(shortcut))
|
action.setShortcut(QKeySequence(shortcut))
|
||||||
if slot:
|
if slot:
|
||||||
|
if checkable:
|
||||||
|
# Checkable actions use 'toggled' to reliably pass the boolean state
|
||||||
|
action.toggled.connect(slot)
|
||||||
|
else:
|
||||||
action.triggered.connect(slot)
|
action.triggered.connect(slot)
|
||||||
if checkable:
|
if checkable:
|
||||||
action.setCheckable(True)
|
action.setCheckable(True)
|
||||||
@@ -850,140 +949,142 @@ class MainApplication(QMainWindow):
|
|||||||
action.setIcon(QIcon(icon))
|
action.setIcon(QIcon(icon))
|
||||||
return action
|
return action
|
||||||
|
|
||||||
# File menu and actions
|
# Unified Menu Bar Schema
|
||||||
file_menu = menu_bar.addMenu("File")
|
MENU_BAR_SCHEMA = [
|
||||||
file_actions = [
|
# === FILE MENU ===
|
||||||
("Open File...", "Ctrl+O", self.project_manager.open_file_dialog, resource_path("icons/file_open_24dp_1F1F1F.svg")),
|
{"menu": "File", "name": "Open File...", "shortcut": "Ctrl+O", "slot": self.project_manager.open_file_dialog, "icon": resource_path("icons/file_open_24dp_1F1F1F.svg")},
|
||||||
("Open Folder...", "Ctrl+Alt+O", self.project_manager.open_folder_dialog, resource_path("icons/folder_24dp_1F1F1F.svg")),
|
{"menu": "File", "name": "Open Folder...", "shortcut": "Ctrl+Alt+O", "slot": self.project_manager.open_folder_dialog, "icon": resource_path("icons/folder_24dp_1F1F1F.svg")},
|
||||||
("Load Project...", "Ctrl+L", self.project_manager.load_project_dialog, resource_path("icons/article_24dp_1F1F1F.svg")),
|
{"menu": "File", "type": "recent_files"},
|
||||||
("Save Project...", "Ctrl+S", lambda: self.project_manager.save_project(ask=False), resource_path("icons/save_24dp_1F1F1F.svg")),
|
{"menu": "File", "type": "separator"},
|
||||||
("Save Project As...", "Ctrl+Shift+S", lambda: self.project_manager.save_project(ask=True), resource_path("icons/save_as_24dp_1F1F1F.svg")),
|
{"menu": "File", "name": "Load Project...", "shortcut": "Ctrl+L", "slot": self.project_manager.load_project_dialog, "icon": resource_path("icons/article_24dp_1F1F1F.svg")},
|
||||||
|
{"menu": "File", "type": "recent_projects"},
|
||||||
|
{"menu": "File", "type": "separator"},
|
||||||
|
{"menu": "File", "name": "Save Project...", "shortcut": "Ctrl+S", "slot": lambda: self.project_manager.save_project(ask=False), "icon": resource_path("icons/save_24dp_1F1F1F.svg")},
|
||||||
|
{"menu": "File", "name": "Save Project As...", "shortcut": "Ctrl+Shift+S", "slot": lambda: self.project_manager.save_project(ask=True), "icon": resource_path("icons/save_as_24dp_1F1F1F.svg")},
|
||||||
|
{"menu": "File", "type": "separator"},
|
||||||
|
{"menu": "File", "name": "Exit", "shortcut": "Ctrl+Q", "slot": QApplication.instance().quit, "icon": resource_path("icons/exit_to_app_24dp_1F1F1F.svg")},
|
||||||
|
|
||||||
|
# === EDIT MENU ===
|
||||||
|
{"menu": "Edit", "name": "Cut", "shortcut": "Ctrl+X", "slot": self.cut_text, "icon": resource_path("icons/content_cut_24dp_1F1F1F.svg")},
|
||||||
|
{"menu": "Edit", "name": "Copy", "shortcut": "Ctrl+C", "slot": self.copy_text, "icon": resource_path("icons/content_copy_24dp_1F1F1F.svg")},
|
||||||
|
{"menu": "Edit", "name": "Paste", "shortcut": "Ctrl+V", "slot": self.paste_text, "icon": resource_path("icons/content_paste_24dp_1F1F1F.svg")},
|
||||||
|
|
||||||
|
# === VIEW MENU ===
|
||||||
|
{"menu": "View", "name": "Toggle Status Bar", "shortcut": "", "slot": self.toggle_statusbar, "checkable": True, "checked": True, "icon": resource_path("icons/short_text_24dp_1F1F1F.svg"), "key": "statusbar"},
|
||||||
|
{"menu": "View", "type": "separator"},
|
||||||
|
{"menu": "View", "name": "Reset Window Layout", "shortcut": "Ctrl+Shift+R", "slot": self.reset_window_layout, "icon": resource_path("icons/grid_layout_side_24dp_1F1F1F.svg")},
|
||||||
|
|
||||||
|
# === OPTIONS MENU ===
|
||||||
|
{"menu": "Options", "name": "User Guide", "shortcut": "F1", "slot": self.user_guide, "icon": resource_path("icons/help_24dp_1F1F1F.svg")},
|
||||||
|
{"menu": "Options", "name": "Regroup Files from Metadata", "shortcut": "F3", "slot": self.group_metadata, "icon": resource_path("icons/help_24dp_1F1F1F.svg")},
|
||||||
|
{"menu": "Options", "name": "Check for Updates", "shortcut": "F5", "slot": self.updater.manual_check_for_updates, "icon": resource_path("icons/update_24dp_1F1F1F.svg")},
|
||||||
|
{"menu": "Options", "name": "Show Update Changelog", "shortcut": "F6", "slot": self.show_update_changelog, "icon": resource_path("icons/article_shortcut_24dp_1F1F1F.svg")},
|
||||||
|
{"menu": "Options", "type": "separator"},
|
||||||
|
{"menu": "Options", "name": "Update events in snirf file (BORIS)...", "shortcut": "F7", "slot": self.update_event_markers, "icon": resource_path("icons/upgrade_24dp_1F1F1F.svg")},
|
||||||
|
{"menu": "Options", "name": "Update events in snirf file (BLAZES)...", "shortcut": "F8", "slot": self.update_event_markers_blazes, "icon": resource_path("icons/upgrade_24dp_1F1F1F.svg")},
|
||||||
|
{"menu": "Options", "name": "Update optodes in snirf file...", "shortcut": "F9", "slot": self.update_optode_positions, "icon": resource_path("icons/upgrade_24dp_1F1F1F.svg")},
|
||||||
|
{"menu": "Options", "type": "separator"},
|
||||||
|
{"menu": "Options", "name": "Reset to Default Configuration", "shortcut": "F10", "slot": self.reset_to_default_configuration, "icon": resource_path("icons/reset_settings_24dp_1F1F1F.svg")},
|
||||||
|
{"menu": "Options", "type": "separator"},
|
||||||
|
{"menu": "Options", "name": "About", "shortcut": "F12", "slot": self.about_window, "icon": resource_path("icons/info_24dp_1F1F1F.svg")},
|
||||||
|
|
||||||
|
# === PREFERENCES: THEME ===
|
||||||
|
{"menu": "Preferences", "submenu": "Theme", "name": "Auto", "mode": "auto", "slot": partial(self.theme_change_func, "auto"), "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg"), "key": "theme_auto", "group": "theme", "checkable": True},
|
||||||
|
{"menu": "Preferences", "submenu": "Theme", "name": "Light", "mode": "light", "slot": partial(self.theme_change_func, "light"), "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg"), "key": "theme_light", "group": "theme", "checkable": True},
|
||||||
|
{"menu": "Preferences", "submenu": "Theme", "name": "Dark", "mode": "dark", "slot": partial(self.theme_change_func, "dark"), "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg"), "key": "theme_dark", "group": "theme", "checkable": True},
|
||||||
|
|
||||||
|
# === PREFERENCES: PROCESSING ===
|
||||||
|
{"menu": "Preferences", "submenu": "Processing", "name": "2D Data Bypass", "key": "processing_2d_data_bypass", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")},
|
||||||
|
{"menu": "Preferences", "submenu": "Processing", "name": "Incompatible Save Bypass", "key": "processing_incompatible_save_bypass", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")},
|
||||||
|
{"menu": "Preferences", "submenu": "Processing", "name": "Missing Events Bypass", "key": "processing_missing_events_bypass", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")},
|
||||||
|
{"menu": "Preferences", "submenu": "Processing", "name": "Analysis Clearing Bypass", "key": "processing_analysis_clearing_bypass", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")},
|
||||||
|
{"menu": "Preferences", "submenu": "Processing", "name": "Folding Bypass", "key": "processing_folding_bypass", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")},
|
||||||
|
{"menu": "Preferences", "submenu": "Processing", "name": "Separate Events", "key": "processing_separate_events", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")},
|
||||||
|
|
||||||
|
# === PREFERENCES: UI ===
|
||||||
|
{"menu": "Preferences", "submenu": "UI", "name": "Show Advanced Parameters", "key": "ui_advanced_parameters", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")},
|
||||||
|
|
||||||
|
# === PREFERENCES: UPDATES ===
|
||||||
|
{"menu": "Preferences", "submenu": "Updates", "name": "Automatic App Updates", "key": "updates_check_app_automatic", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")},
|
||||||
|
{"menu": "Preferences", "submenu": "Updates", "name": "Automatic Plugin Updates", "key": "updates_check_plugins_automatic", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")},
|
||||||
|
|
||||||
|
# === PLUGINS MENU ===
|
||||||
|
{"menu": "Plugins", "name": "Plugin Manager", "shortcut": "Ctrl+Alt+P", "slot": self.plugins_gui, "icon": resource_path("icons/terminal_24dp_1F1F1F.svg")},
|
||||||
|
|
||||||
|
# === TERMINAL MENU ===
|
||||||
|
{"menu": "Terminal", "name": "New Terminal", "shortcut": "Ctrl+Alt+T", "slot": self.terminal_gui, "icon": resource_path("icons/terminal_24dp_1F1F1F.svg")},
|
||||||
]
|
]
|
||||||
|
|
||||||
for i, (name, shortcut, slot, icon) in enumerate(file_actions):
|
# Container references for submenus and groups
|
||||||
file_menu.addAction(make_action(name, shortcut, slot, icon=icon))
|
menus = {}
|
||||||
if i == 1:
|
submenus = {}
|
||||||
self.recent_files_menu = file_menu.addMenu("Recent Files")
|
|
||||||
self.recent_files_menu.setIcon(QIcon(resource_path("icons/history_24dp_1F1F1F.svg"))) # optional icon
|
|
||||||
file_menu.addSeparator()
|
|
||||||
elif i == 2:
|
|
||||||
self.recent_projects_menu = file_menu.addMenu("Recent Projects")
|
|
||||||
self.recent_projects_menu.setIcon(QIcon(resource_path("icons/history_2_24dp_1F1F1F.svg")))
|
|
||||||
file_menu.addSeparator()
|
|
||||||
|
|
||||||
file_menu.addSeparator()
|
|
||||||
file_menu.addAction(make_action("Exit", "Ctrl+Q", QApplication.instance().quit, icon=resource_path("icons/exit_to_app_24dp_1F1F1F.svg")))
|
|
||||||
|
|
||||||
# Edit menu
|
|
||||||
edit_menu = menu_bar.addMenu("Edit")
|
|
||||||
edit_actions = [
|
|
||||||
("Cut", "Ctrl+X", self.cut_text, resource_path("icons/content_cut_24dp_1F1F1F.svg")),
|
|
||||||
("Copy", "Ctrl+C", self.copy_text, resource_path("icons/content_copy_24dp_1F1F1F.svg")),
|
|
||||||
("Paste", "Ctrl+V", self.paste_text, resource_path("icons/content_paste_24dp_1F1F1F.svg"))
|
|
||||||
]
|
|
||||||
for name, shortcut, slot, icon in edit_actions:
|
|
||||||
edit_menu.addAction(make_action(name, shortcut, slot, icon=icon))
|
|
||||||
|
|
||||||
# View menu
|
|
||||||
|
|
||||||
self.view_actions = {}
|
|
||||||
|
|
||||||
view_menu = menu_bar.addMenu("View")
|
|
||||||
toggle_statusbar_action = make_action("Toggle Status Bar", "", self.toggle_statusbar, True, True, resource_path("icons/short_text_24dp_1F1F1F.svg"))
|
|
||||||
view_menu.addAction(toggle_statusbar_action)
|
|
||||||
self.view_actions["statusbar"] = toggle_statusbar_action
|
|
||||||
|
|
||||||
# Reset Layout Action
|
|
||||||
view_menu.addSeparator()
|
|
||||||
reset_layout_action = make_action(
|
|
||||||
"Reset Window Layout",
|
|
||||||
"Ctrl+Shift+R",
|
|
||||||
self.reset_window_layout,
|
|
||||||
icon=resource_path("icons/grid_layout_side_24dp_1F1F1F.svg")
|
|
||||||
)
|
|
||||||
view_menu.addAction(reset_layout_action)
|
|
||||||
|
|
||||||
# Options menu (Help & About)
|
|
||||||
options_menu = menu_bar.addMenu("Options")
|
|
||||||
|
|
||||||
options_actions = [
|
|
||||||
("User Guide", "F1", self.user_guide, resource_path("icons/help_24dp_1F1F1F.svg")),
|
|
||||||
("Regroup Files from Metadata", "F3", self.group_metadata, resource_path("icons/help_24dp_1F1F1F.svg")),
|
|
||||||
("Check for Updates", "F5", self.updater.manual_check_for_updates, resource_path("icons/update_24dp_1F1F1F.svg")),
|
|
||||||
("Show Update Changelog", "F6", self.show_update_changelog, resource_path("icons/article_shortcut_24dp_1F1F1.svg")),
|
|
||||||
("Update events in snirf file (BORIS)...", "F7", self.update_event_markers, resource_path("icons/upgrade_24dp_1F1F1F.svg")),
|
|
||||||
("Update events in snirf file (BLAZES)...", "F8", self.update_event_markers_blazes, resource_path("icons/upgrade_24dp_1F1F1F.svg")),
|
|
||||||
("Update optodes in snirf file...", "F9", self.update_optode_positions, resource_path("icons/upgrade_24dp_1F1F1F.svg")),
|
|
||||||
("Reset to Default Configuration", "F10", self.reset_to_default_configuration, resource_path("icons/reset_settings_24dp_1F1F1F.svg")),
|
|
||||||
("About", "F12", self.about_window, resource_path("icons/info_24dp_1F1F1F.svg"))
|
|
||||||
]
|
|
||||||
|
|
||||||
for i, (name, shortcut, slot, icon) in enumerate(options_actions):
|
|
||||||
options_menu.addAction(make_action(name, shortcut, slot, icon=icon))
|
|
||||||
if i == 3 or i == 6 or i == 7:
|
|
||||||
options_menu.addSeparator()
|
|
||||||
|
|
||||||
self.pref_actions = {}
|
|
||||||
|
|
||||||
preferences_menu = menu_bar.addMenu("Preferences")
|
|
||||||
theme_menu = preferences_menu.addMenu("Theme")
|
|
||||||
|
|
||||||
theme_group = QActionGroup(self)
|
theme_group = QActionGroup(self)
|
||||||
theme_group.setExclusive(True)
|
theme_group.setExclusive(True)
|
||||||
|
|
||||||
# 4. Define actions for the submenu
|
for item in MENU_BAR_SCHEMA:
|
||||||
theme_actions = [
|
menu_name = item["menu"]
|
||||||
("Auto", "", "auto", resource_path("icons/warning_off_24dp_1F1F1F.svg"), "theme_auto"),
|
if menu_name not in menus:
|
||||||
("Light", "", "light", resource_path("icons/warning_off_24dp_1F1F1F.svg"), "theme_light"),
|
menus[menu_name] = menu_bar.addMenu(menu_name)
|
||||||
("Dark", "", "dark", resource_path("icons/warning_off_24dp_1F1F1F.svg"), "theme_dark"),
|
parent_menu = menus[menu_name]
|
||||||
]
|
|
||||||
|
|
||||||
for name, shortcut, mode, icon, config_key in theme_actions:
|
# Handle Submenus (e.g. Preferences -> Theme/Processing/Updates)
|
||||||
# Use partial to pass 'mode' to self.theme_change_func on click
|
if "submenu" in item:
|
||||||
slot = partial(self.theme_change_func, mode)
|
sub_name = item["submenu"]
|
||||||
|
sub_key = f"{menu_name}_{sub_name}"
|
||||||
|
if sub_key not in submenus:
|
||||||
|
submenus[sub_key] = parent_menu.addMenu(sub_name)
|
||||||
|
parent_menu = submenus[sub_key]
|
||||||
|
|
||||||
action = make_action(name, shortcut, slot, icon=icon, checkable=True)
|
# Handle Special Types (Separators & Dynamic Submenus)
|
||||||
theme_menu.addAction(action)
|
item_type = item.get("type")
|
||||||
|
if item_type == "separator":
|
||||||
|
parent_menu.addSeparator()
|
||||||
|
continue
|
||||||
|
elif item_type == "recent_files":
|
||||||
|
self.recent_files_menu = parent_menu.addMenu("Recent Files")
|
||||||
|
self.recent_files_menu.setIcon(QIcon(resource_path("icons/history_24dp_1F1F1F.svg")))
|
||||||
|
parent_menu.addSeparator()
|
||||||
|
continue
|
||||||
|
elif item_type == "recent_projects":
|
||||||
|
self.recent_projects_menu = parent_menu.addMenu("Recent Projects")
|
||||||
|
self.recent_projects_menu.setIcon(QIcon(resource_path("icons/history_2_24dp_1F1F1F.svg")))
|
||||||
|
parent_menu.addSeparator()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Handle Normal/Preference/View Actions
|
||||||
|
name = item["name"]
|
||||||
|
shortcut = item.get("shortcut", "")
|
||||||
|
key = item.get("key")
|
||||||
|
|
||||||
|
# If it's a preference toggle item without an explicit slot, bind it to _set_preference
|
||||||
|
slot = item.get("slot")
|
||||||
|
checkable = item.get("checkable", False)
|
||||||
|
checked = item.get("checked", False)
|
||||||
|
|
||||||
|
if not slot and key and "theme_" not in key:
|
||||||
|
slot = partial(self._set_preference, key)
|
||||||
|
checkable = True
|
||||||
|
checked = getattr(self, key, False)
|
||||||
|
|
||||||
|
action = make_action(name, shortcut, slot, checkable=checkable, checked=checked, icon=item.get("icon"))
|
||||||
|
parent_menu.addAction(action)
|
||||||
|
|
||||||
|
# Register into appropriate tracking dictionaries
|
||||||
|
if key == "statusbar":
|
||||||
|
self.view_actions["statusbar"] = action
|
||||||
|
elif item.get("group") == "theme":
|
||||||
theme_group.addAction(action)
|
theme_group.addAction(action)
|
||||||
|
self.pref_actions[key] = action
|
||||||
|
elif key:
|
||||||
|
self.pref_actions[key] = action
|
||||||
|
|
||||||
self.pref_actions[config_key] = action
|
# Set default theme selection if available
|
||||||
|
if "theme_auto" in self.pref_actions:
|
||||||
# Set default selection (e.g., Auto)
|
|
||||||
self.pref_actions["theme_auto"].setChecked(True)
|
self.pref_actions["theme_auto"].setChecked(True)
|
||||||
|
|
||||||
preferences_actions = [
|
|
||||||
("2D Data Bypass", "", self.is_2d_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "2d_data_bypass"),
|
|
||||||
("Incompatible Save Bypass", "", self.incompatable_save_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "incompatible_save_bypass"),
|
|
||||||
("Missing Events Bypass", "", self.missing_events_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "missing_events_bypass"),
|
|
||||||
("Analysis Clearing Bypass", "", self.analysis_clearing_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "analysis_clearing_bypass"),
|
|
||||||
("Folding Bypass", "", self.folding_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "folding_bypass"),
|
|
||||||
("Show Advanced Parameters", "", self.advanced_parameters_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "advanced_parameters"),
|
|
||||||
]
|
|
||||||
|
|
||||||
for name, shortcut, slot, icon, config_key in preferences_actions:
|
|
||||||
action = make_action(name, shortcut, slot, icon=icon, checkable=True)
|
|
||||||
preferences_menu.addAction(action)
|
|
||||||
self.pref_actions[config_key] = action
|
|
||||||
|
|
||||||
plugins_menu = menu_bar.addMenu("Plugins")
|
|
||||||
plugins_actions = [
|
|
||||||
("Plugin Manager", "Ctrl+Alt+P", self.plugins_gui, resource_path("icons/terminal_24dp_1F1F1F.svg")),
|
|
||||||
]
|
|
||||||
for name, shortcut, slot, icon in plugins_actions:
|
|
||||||
plugins_menu.addAction(make_action(name, shortcut, slot, icon=icon))
|
|
||||||
|
|
||||||
terminal_menu = menu_bar.addMenu("Terminal")
|
|
||||||
terminal_actions = [
|
|
||||||
("New Terminal", "Ctrl+Alt+T", self.terminal_gui, resource_path("icons/terminal_24dp_1F1F1F.svg")),
|
|
||||||
]
|
|
||||||
for name, shortcut, slot, icon in terminal_actions:
|
|
||||||
terminal_menu.addAction(make_action(name, shortcut, slot, icon=icon))
|
|
||||||
|
|
||||||
self.sync_app_with_config()
|
self.sync_app_with_config()
|
||||||
|
|
||||||
self.statusbar.showMessage("Ready")
|
self.statusbar.showMessage("Ready")
|
||||||
|
|
||||||
|
|
||||||
def update_theme(self):
|
def update_theme(self):
|
||||||
text = self.label_desc.text()
|
text = self.label_desc.text()
|
||||||
self.label_desc.setText("")
|
self.label_desc.setText("")
|
||||||
@@ -1039,7 +1140,7 @@ class MainApplication(QMainWindow):
|
|||||||
# self.update_window_title()
|
# self.update_window_title()
|
||||||
|
|
||||||
# Defensive dictionary lookup for the preference action
|
# Defensive dictionary lookup for the preference action
|
||||||
pref_action = getattr(self, "pref_actions", {}).get("advanced_parameters")
|
pref_action = getattr(self, "pref_actions", {}).get("ui_advanced_parameters")
|
||||||
show_advanced = pref_action.isChecked() if pref_action is not None else False
|
show_advanced = pref_action.isChecked() if pref_action is not None else False
|
||||||
|
|
||||||
# Toggle visibility on all built sections
|
# Toggle visibility on all built sections
|
||||||
@@ -1280,7 +1381,7 @@ class MainApplication(QMainWindow):
|
|||||||
data_map["roi_channel_map_dict"],
|
data_map["roi_channel_map_dict"],
|
||||||
data_map["fir_feature_dict"],
|
data_map["fir_feature_dict"],
|
||||||
data_map["qc_dict"],
|
data_map["qc_dict"],
|
||||||
self.folding_bypass,
|
self.processing_folding_bypass,
|
||||||
]
|
]
|
||||||
|
|
||||||
self.launcher_window = ViewerLauncherWidget(*args)
|
self.launcher_window = ViewerLauncherWidget(*args)
|
||||||
@@ -1298,19 +1399,25 @@ class MainApplication(QMainWindow):
|
|||||||
self.top_left_widget.paste() # Trigger paste
|
self.top_left_widget.paste() # Trigger paste
|
||||||
self.statusbar.showMessage("Pasted from clipboard") # Show status message
|
self.statusbar.showMessage("Pasted from clipboard") # Show status message
|
||||||
|
|
||||||
def theme_change_func(self, mode):
|
def theme_change_func(self, mode: str, checked: bool = True) -> None:
|
||||||
|
"""Handles theme switching when toggled from the menu."""
|
||||||
|
if not checked:
|
||||||
|
return # Ignore the 'False' state when an option gets unchecked
|
||||||
|
|
||||||
app = QApplication.instance()
|
app = QApplication.instance()
|
||||||
style_hints = app.styleHints()
|
style_hints = app.styleHints()
|
||||||
|
|
||||||
if mode == "auto":
|
if mode == "auto":
|
||||||
style_hints.setColorScheme(Qt.ColorScheme.Unknown)
|
style_hints.setColorScheme(Qt.ColorScheme.Unknown)
|
||||||
|
|
||||||
elif mode == "light":
|
elif mode == "light":
|
||||||
style_hints.setColorScheme(Qt.ColorScheme.Light)
|
style_hints.setColorScheme(Qt.ColorScheme.Light)
|
||||||
|
|
||||||
elif mode == "dark":
|
elif mode == "dark":
|
||||||
style_hints.setColorScheme(Qt.ColorScheme.Dark)
|
style_hints.setColorScheme(Qt.ColorScheme.Dark)
|
||||||
|
|
||||||
|
# Save the change to your config file
|
||||||
|
self.theme_mode = mode
|
||||||
|
self._update_config_setting("Preferences", "theme_mode", mode)
|
||||||
|
|
||||||
def _update_config_setting(self, group, key, value):
|
def _update_config_setting(self, group, key, value):
|
||||||
"""Helper to update memory configuration and save to disk."""
|
"""Helper to update memory configuration and save to disk."""
|
||||||
# configparser expects string values
|
# configparser expects string values
|
||||||
@@ -1326,29 +1433,11 @@ class MainApplication(QMainWindow):
|
|||||||
self._update_config_setting("View", "status_bar", checked)
|
self._update_config_setting("View", "status_bar", checked)
|
||||||
self.statusbar.setVisible(checked)
|
self.statusbar.setVisible(checked)
|
||||||
|
|
||||||
def is_2d_bypass_func(self, checked):
|
def _set_preference(self, config_key: str, checked: bool) -> None:
|
||||||
self.is_2d_bypass = checked
|
"""Generic handler for updating boolean preferences and config settings."""
|
||||||
self._update_config_setting("Preferences", "2d_data_bypass", checked)
|
setattr(self, config_key, checked)
|
||||||
|
self._update_config_setting("Preferences", config_key, checked)
|
||||||
def incompatable_save_bypass_func(self, checked):
|
if config_key == "ui_advanced_parameters":
|
||||||
self.incompatible_save_bypass = checked
|
|
||||||
self._update_config_setting("Preferences", "incompatible_save_bypass", checked)
|
|
||||||
|
|
||||||
def missing_events_bypass_func(self, checked):
|
|
||||||
self.missing_events_bypass = checked
|
|
||||||
self._update_config_setting("Preferences", "missing_events_bypass", checked)
|
|
||||||
|
|
||||||
def analysis_clearing_bypass_func(self, checked):
|
|
||||||
self.analysis_clearing_bypass = checked
|
|
||||||
self._update_config_setting("Preferences", "analysis_clearing_bypass", checked)
|
|
||||||
|
|
||||||
def folding_bypass_func(self, checked):
|
|
||||||
self.folding_bypass = checked
|
|
||||||
self._update_config_setting("Preferences", "folding_bypass", checked)
|
|
||||||
|
|
||||||
def advanced_parameters_func(self, checked):
|
|
||||||
self.advanced_parameters = checked
|
|
||||||
self._update_config_setting("Preferences", "advanced_parameters", checked)
|
|
||||||
self.update_sections(0)
|
self.update_sections(0)
|
||||||
|
|
||||||
def about_window(self):
|
def about_window(self):
|
||||||
@@ -1454,32 +1543,65 @@ class MainApplication(QMainWindow):
|
|||||||
|
|
||||||
|
|
||||||
def sync_app_with_config(self):
|
def sync_app_with_config(self):
|
||||||
"""Reads values from file_cfg and updates both internal variables and UI checkmarks."""
|
"""Reads values from file_cfg and updates both internal variables and UI checkmarks,
|
||||||
# 1. Sync internal application state variables
|
using fallbacks parsed directly from DEFAULT_CONFIG."""
|
||||||
self.is_2d_bypass = file_cfg.getboolean("Preferences", "2d_data_bypass", fallback=False)
|
|
||||||
self.incompatible_save_bypass = file_cfg.getboolean("Preferences", "incompatible_save_bypass", fallback=False)
|
|
||||||
self.missing_events_bypass = file_cfg.getboolean("Preferences", "missing_events_bypass", fallback=False)
|
|
||||||
self.analysis_clearing_bypass = file_cfg.getboolean("Preferences", "analysis_clearing_bypass", fallback=False)
|
|
||||||
self.folding_bypass = file_cfg.getboolean("Preferences", "folding_bypass", fallback=False)
|
|
||||||
self.advanced_parameters = file_cfg.getboolean("Preferences", "advanced_parameters", fallback=False)
|
|
||||||
|
|
||||||
self.show_status_bar = file_cfg.getboolean("View", "status_bar", fallback=False)
|
# Parse DEFAULT_CONFIG to use as the single source of truth for fallbacks
|
||||||
self.main_h_ratio = file_cfg.getfloat("View", "main_h_ratio", fallback=26 / 45)
|
default_parser = configparser.ConfigParser()
|
||||||
self.left_v_ratio = file_cfg.getfloat("View", "left_v_ratio", fallback=0.30)
|
default_parser.read_string(DEFAULT_CONFIG)
|
||||||
|
|
||||||
# 2. Sync the UI Menu checkmarks visually
|
# Define mapping of sections, keys, and their expected type
|
||||||
if hasattr(self, 'pref_actions'):
|
config_mappings = {
|
||||||
self.pref_actions["2d_data_bypass"].setChecked(self.is_2d_bypass)
|
"View": [
|
||||||
self.pref_actions["incompatible_save_bypass"].setChecked(self.incompatible_save_bypass)
|
("status_bar", "boolean"),
|
||||||
self.pref_actions["missing_events_bypass"].setChecked(self.missing_events_bypass)
|
("main_h_ratio", "float"),
|
||||||
self.pref_actions["analysis_clearing_bypass"].setChecked(self.analysis_clearing_bypass)
|
("left_v_ratio", "float")
|
||||||
self.pref_actions["folding_bypass"].setChecked(self.folding_bypass)
|
],
|
||||||
self.pref_actions["advanced_parameters"].setChecked(self.advanced_parameters)
|
"Preferences": [
|
||||||
|
("theme_mode", "string"),
|
||||||
|
("processing_2d_data_bypass", "boolean"),
|
||||||
|
("processing_incompatible_save_bypass", "boolean"),
|
||||||
|
("processing_missing_events_bypass", "boolean"),
|
||||||
|
("processing_analysis_clearing_bypass", "boolean"),
|
||||||
|
("processing_folding_bypass", "boolean"),
|
||||||
|
("processing_separate_events", "boolean"),
|
||||||
|
("ui_advanced_parameters", "boolean"),
|
||||||
|
("updates_check_plugins_automatic", "boolean"),
|
||||||
|
("updates_check_app_automatic", "boolean"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
if hasattr(self, 'view_actions'):
|
# 1. Sync internal state variables and UI checkmarks dynamically
|
||||||
self.view_actions["statusbar"].setChecked(self.show_status_bar)
|
for section, items in config_mappings.items():
|
||||||
|
for key, val_type in items:
|
||||||
|
# Retrieve the exact fallback specified in DEFAULT_CONFIG
|
||||||
|
if val_type == "boolean":
|
||||||
|
fallback = default_parser.getboolean(section, key)
|
||||||
|
val = file_cfg.getboolean(section, key, fallback=fallback)
|
||||||
|
elif val_type == "float":
|
||||||
|
fallback = default_parser.getfloat(section, key)
|
||||||
|
val = file_cfg.getfloat(section, key, fallback=fallback)
|
||||||
|
else:
|
||||||
|
fallback = default_parser.get(section, key)
|
||||||
|
val = file_cfg.get(section, key, fallback=fallback)
|
||||||
|
|
||||||
if self.advanced_parameters:
|
# Set attribute on self
|
||||||
|
setattr(self, key, val)
|
||||||
|
|
||||||
|
# Sync UI Menu checkmarks visually
|
||||||
|
if key in self.pref_actions:
|
||||||
|
self.pref_actions[key].setChecked(val)
|
||||||
|
elif key == "status_bar" and "statusbar" in self.view_actions:
|
||||||
|
self.view_actions["statusbar"].setChecked(val)
|
||||||
|
|
||||||
|
# 2. Sync Theme Radio Action Group based on theme_mode string (e.g., "auto", "light", "dark")
|
||||||
|
theme_mode = getattr(self, "theme_mode", "auto")
|
||||||
|
theme_action_key = f"theme_{theme_mode}"
|
||||||
|
if theme_action_key in self.pref_actions:
|
||||||
|
self.pref_actions[theme_action_key].setChecked(True)
|
||||||
|
|
||||||
|
# 3. Trigger dependent UI updates and handlers
|
||||||
|
if getattr(self, "ui_advanced_parameters", False):
|
||||||
self.update_sections(0)
|
self.update_sections(0)
|
||||||
|
|
||||||
if hasattr(self, 'recent_files_menu'):
|
if hasattr(self, 'recent_files_menu'):
|
||||||
@@ -1489,6 +1611,7 @@ class MainApplication(QMainWindow):
|
|||||||
self.project_manager.update_recent_projects_menu()
|
self.project_manager.update_recent_projects_menu()
|
||||||
|
|
||||||
QTimer.singleShot(0, self.apply_splitter_ratios)
|
QTimer.singleShot(0, self.apply_splitter_ratios)
|
||||||
|
self.statusbar.showMessage("Ready")
|
||||||
|
|
||||||
|
|
||||||
def save_splitter_ratios(self):
|
def save_splitter_ratios(self):
|
||||||
@@ -1814,6 +1937,7 @@ class MainApplication(QMainWindow):
|
|||||||
menu = QMenu(self)
|
menu = QMenu(self)
|
||||||
action1 = menu.addAction(QIcon(resource_path("icons/folder_eye_24dp_1F1F1F.svg")), "Reveal")
|
action1 = menu.addAction(QIcon(resource_path("icons/folder_eye_24dp_1F1F1F.svg")), "Reveal")
|
||||||
action2 = menu.addAction(QIcon(resource_path("icons/remove_24dp_1F1F1F.svg")), "Remove")
|
action2 = menu.addAction(QIcon(resource_path("icons/remove_24dp_1F1F1F.svg")), "Remove")
|
||||||
|
action3 = menu.addAction(QIcon(resource_path("icons/remove_24dp_1F1F1F.svg")), "View Logs")
|
||||||
|
|
||||||
action = menu.exec(global_pos)
|
action = menu.exec(global_pos)
|
||||||
if action == action1:
|
if action == action1:
|
||||||
@@ -1899,6 +2023,36 @@ class MainApplication(QMainWindow):
|
|||||||
|
|
||||||
self.check_files_dirty_state()
|
self.check_files_dirty_state()
|
||||||
|
|
||||||
|
elif action == action3:
|
||||||
|
self.open_log_window(bubble)
|
||||||
|
|
||||||
|
|
||||||
|
def open_log_window(self, bubble):
|
||||||
|
target_path = bubble.file_path
|
||||||
|
log_content = getattr(self, "participant_logs", {}).get(
|
||||||
|
target_path, "No logs generated yet for this participant."
|
||||||
|
)
|
||||||
|
|
||||||
|
window_title = f"Logs — {os.path.basename(target_path)}"
|
||||||
|
|
||||||
|
if not hasattr(self, "active_log_windows"):
|
||||||
|
self.active_log_windows = []
|
||||||
|
self.active_log_windows = [
|
||||||
|
w for w in self.active_log_windows if w.isVisible()
|
||||||
|
]
|
||||||
|
|
||||||
|
# If a log window for this specific file is already open, focus it instead of opening a new one
|
||||||
|
for win in self.active_log_windows:
|
||||||
|
if getattr(win, "file_path", None) == target_path:
|
||||||
|
win.raise_()
|
||||||
|
win.activateWindow()
|
||||||
|
return
|
||||||
|
|
||||||
|
log_win = LogWindow(window_title, target_path, log_content, self)
|
||||||
|
log_win.show()
|
||||||
|
self.active_log_windows.append(log_win)
|
||||||
|
|
||||||
|
|
||||||
def check_files_dirty_state(self):
|
def check_files_dirty_state(self):
|
||||||
"""Compares currently loaded files against the saved baseline list."""
|
"""Compares currently loaded files against the saved baseline list."""
|
||||||
if not hasattr(self, "saved_selected_paths"):
|
if not hasattr(self, "saved_selected_paths"):
|
||||||
@@ -2010,7 +2164,7 @@ class MainApplication(QMainWindow):
|
|||||||
def on_run_task(self):
|
def on_run_task(self):
|
||||||
|
|
||||||
#do the check
|
#do the check
|
||||||
if not self.analysis_clearing_bypass:
|
if not self.processing_analysis_clearing_bypass:
|
||||||
if self.button3.isVisible():
|
if self.button3.isVisible():
|
||||||
msg = QMessageBox(self)
|
msg = QMessageBox(self)
|
||||||
msg.setWindowTitle("Confirm - FLARES")
|
msg.setWindowTitle("Confirm - FLARES")
|
||||||
@@ -2120,9 +2274,12 @@ class MainApplication(QMainWindow):
|
|||||||
for section_widget in self.param_sections:
|
for section_widget in self.param_sections:
|
||||||
section_params = section_widget.get_param_values()
|
section_params = section_widget.get_param_values()
|
||||||
all_params.update(section_params)
|
all_params.update(section_params)
|
||||||
if self.folding_bypass:
|
if self.processing_folding_bypass:
|
||||||
all_params['FOLDING_BYP'] = True
|
all_params['FOLDING_BYP'] = True
|
||||||
|
|
||||||
|
if self.processing_separate_events:
|
||||||
|
all_params['SEPARATE_EVENTS'] = True
|
||||||
|
|
||||||
collected_data = {
|
collected_data = {
|
||||||
"SNIRF_FILES": snirf_files,
|
"SNIRF_FILES": snirf_files,
|
||||||
"PARAMS": all_params, # add this line
|
"PARAMS": all_params, # add this line
|
||||||
@@ -2216,8 +2373,15 @@ class MainApplication(QMainWindow):
|
|||||||
elapsed_str = self._format_elapsed(msg.get("elapsed", getattr(self, "_last_elapsed", 0)))
|
elapsed_str = self._format_elapsed(msg.get("elapsed", getattr(self, "_last_elapsed", 0)))
|
||||||
speedup = msg.get("speedup")
|
speedup = msg.get("speedup")
|
||||||
speedup_str = f" | Speedup: {speedup:.1f}x" if speedup else ""
|
speedup_str = f" | Speedup: {speedup:.1f}x" if speedup else ""
|
||||||
self.statusbar.showMessage(
|
status_text = f"Complete: {success_count} succeeded, {fail_count} failed. | Total time: {elapsed_str}{speedup_str}"
|
||||||
f"Complete: {success_count} succeeded, {fail_count} failed. | Total time: {elapsed_str}{speedup_str}"
|
self.statusbar.showMessage(status_text)
|
||||||
|
|
||||||
|
if hasattr(self, "tray_icon") and self.tray_icon.isSystemTrayAvailable():
|
||||||
|
self.tray_icon.showMessage(
|
||||||
|
"Process Completed",
|
||||||
|
status_text,
|
||||||
|
QSystemTrayIcon.MessageIcon.Information,
|
||||||
|
4000 # Duration in milliseconds
|
||||||
)
|
)
|
||||||
|
|
||||||
if success_count > 0:
|
if success_count > 0:
|
||||||
@@ -2258,6 +2422,36 @@ class MainApplication(QMainWindow):
|
|||||||
_, file_path, step_index = msg
|
_, file_path, step_index = msg
|
||||||
self.progress_update_signal.emit(file_path, step_index)
|
self.progress_update_signal.emit(file_path, step_index)
|
||||||
|
|
||||||
|
elif isinstance(msg, tuple) and len(msg) == 3 and msg[0] == "log":
|
||||||
|
_, file_path, text = msg
|
||||||
|
if not hasattr(self, 'participant_logs'):
|
||||||
|
self.participant_logs = {}
|
||||||
|
if file_path not in self.participant_logs:
|
||||||
|
self.participant_logs[file_path] = ""
|
||||||
|
self.participant_logs[file_path] += text
|
||||||
|
|
||||||
|
# Push live update to any open log window for this file
|
||||||
|
if hasattr(self, 'active_log_windows'):
|
||||||
|
for win in self.active_log_windows:
|
||||||
|
if getattr(win, 'file_path', None) == file_path:
|
||||||
|
win.append_log(text)
|
||||||
|
|
||||||
|
elif isinstance(msg, dict) and msg.get("type") == "log":
|
||||||
|
file_path = msg.get("file")
|
||||||
|
text = msg.get("message")
|
||||||
|
if file_path:
|
||||||
|
if not hasattr(self, 'participant_logs'):
|
||||||
|
self.participant_logs = {}
|
||||||
|
if file_path not in self.participant_logs:
|
||||||
|
self.participant_logs[file_path] = ""
|
||||||
|
self.participant_logs[file_path] += text
|
||||||
|
|
||||||
|
# Push live update to any open log window for this file
|
||||||
|
if hasattr(self, 'active_log_windows'):
|
||||||
|
for win in self.active_log_windows:
|
||||||
|
if getattr(win, 'file_path', None) == file_path:
|
||||||
|
win.append_log(text)
|
||||||
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error in timer loop: {e}")
|
print(f"Error in timer loop: {e}")
|
||||||
@@ -2847,4 +3041,4 @@ if __name__ == "__main__":
|
|||||||
window.show()
|
window.show()
|
||||||
sys.exit(app.exec())
|
sys.exit(app.exec())
|
||||||
|
|
||||||
# Not 2800 lines yay!
|
# Not 3100 lines yay!
|
||||||
+96
-1
@@ -22,7 +22,7 @@ from typing import Any, cast
|
|||||||
|
|
||||||
# External library imports
|
# External library imports
|
||||||
import certifi
|
import certifi
|
||||||
from PySide6.QtCore import QObject, QUrl, Signal
|
from PySide6.QtCore import QObject, QThread, QUrl, Signal
|
||||||
from PySide6.QtGui import QDesktopServices
|
from PySide6.QtGui import QDesktopServices
|
||||||
from PySide6.QtWidgets import QMainWindow, QMenu, QMessageBox
|
from PySide6.QtWidgets import QMainWindow, QMenu, QMessageBox
|
||||||
|
|
||||||
@@ -329,3 +329,98 @@ class PluginManager(QObject):
|
|||||||
if self.plugins_dir.exists():
|
if self.plugins_dir.exists():
|
||||||
return QDesktopServices.openUrl(QUrl.fromLocalFile(str(self.plugins_dir)))
|
return QDesktopServices.openUrl(QUrl.fromLocalFile(str(self.plugins_dir)))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def check_for_updates(self, repo_urls: list[str]) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Fetches remote repositories and compares server plugin versions
|
||||||
|
against installed plugins, returning the installed plugins list
|
||||||
|
enriched with update metadata.
|
||||||
|
"""
|
||||||
|
installed_plugins = self.get_installed_plugins_info()
|
||||||
|
remote_data, _ = self.fetch_remote_repositories(repo_urls)
|
||||||
|
|
||||||
|
# Index remote plugins by their ID (fallback to name if id is missing)
|
||||||
|
remote_map: dict[str, dict[str, Any]] = {}
|
||||||
|
for remote_plugin in remote_data:
|
||||||
|
plugin_id = remote_plugin.get("id") or remote_plugin.get("name")
|
||||||
|
if plugin_id:
|
||||||
|
remote_map[str(plugin_id)] = remote_plugin
|
||||||
|
|
||||||
|
# Compare versions and annotate each installed plugin dictionary
|
||||||
|
for plugin in installed_plugins:
|
||||||
|
plugin_id = str(plugin.get("id", ""))
|
||||||
|
local_version_str = str(plugin.get("version", "0.0.0"))
|
||||||
|
local_tuple = parse_version(local_version_str)
|
||||||
|
|
||||||
|
remote_plugin = remote_map.get(plugin_id)
|
||||||
|
if remote_plugin:
|
||||||
|
remote_version_str = str(remote_plugin.get("version", "0.0.0"))
|
||||||
|
remote_tuple = parse_version(remote_version_str)
|
||||||
|
|
||||||
|
if remote_tuple > local_tuple:
|
||||||
|
plugin["update_available"] = True
|
||||||
|
plugin["remote_version"] = remote_version_str
|
||||||
|
plugin["download_url"] = str(remote_plugin.get("download_url", ""))
|
||||||
|
else:
|
||||||
|
plugin["update_available"] = False
|
||||||
|
plugin["remote_version"] = remote_version_str
|
||||||
|
plugin["download_url"] = ""
|
||||||
|
else:
|
||||||
|
plugin["update_available"] = False
|
||||||
|
plugin["remote_version"] = None
|
||||||
|
plugin["download_url"] = ""
|
||||||
|
|
||||||
|
return installed_plugins
|
||||||
|
|
||||||
|
|
||||||
|
class StartupUpdateWorker(QThread):
|
||||||
|
updates_checked = Signal(list)
|
||||||
|
|
||||||
|
def __init__(self, manager: Any, repo_urls: list[str]) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.manager = manager
|
||||||
|
self.repo_urls = repo_urls
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
try:
|
||||||
|
print("[StartupWorker] Starting background update check...")
|
||||||
|
installed_plugins = self.manager.get_installed_plugins_info()
|
||||||
|
print(f"[StartupWorker] Found {len(installed_plugins)} installed local plugins.")
|
||||||
|
|
||||||
|
remote_data, all_failed = self.manager.fetch_remote_repositories(self.repo_urls)
|
||||||
|
print(f"[StartupWorker] Fetch remote complete. Failed: {all_failed}, Items fetched: {len(remote_data)}")
|
||||||
|
|
||||||
|
if all_failed or not remote_data:
|
||||||
|
print("[StartupWorker] Aborting: Remote fetch failed or returned empty data.")
|
||||||
|
return
|
||||||
|
|
||||||
|
remote_map: dict[str, dict[str, Any]] = {}
|
||||||
|
for raw_plugin in remote_data:
|
||||||
|
if isinstance(raw_plugin, dict):
|
||||||
|
remote_plugin = cast(dict[str, Any], raw_plugin)
|
||||||
|
|
||||||
|
plugin_id = remote_plugin.get("id") or remote_plugin.get("name")
|
||||||
|
if isinstance(plugin_id, str):
|
||||||
|
remote_map[plugin_id] = remote_plugin
|
||||||
|
|
||||||
|
outdated_plugins: list[dict[str, Any]] = []
|
||||||
|
for plugin in installed_plugins:
|
||||||
|
plugin_id = str(plugin.get("id", ""))
|
||||||
|
local_version_str = str(plugin.get("version", "0.0.0"))
|
||||||
|
local_tuple = parse_version(local_version_str)
|
||||||
|
|
||||||
|
remote_plugin = remote_map.get(plugin_id)
|
||||||
|
if remote_plugin:
|
||||||
|
remote_version_str = str(remote_plugin.get("version", "0.0.0"))
|
||||||
|
remote_tuple = parse_version(remote_version_str)
|
||||||
|
print(f"[StartupWorker] Comparing '{plugin.get('name')}': Local v{local_version_str} vs Remote v{remote_version_str}")
|
||||||
|
|
||||||
|
if remote_tuple > local_tuple:
|
||||||
|
plugin["remote_version"] = remote_version_str
|
||||||
|
plugin["download_url"] = str(remote_plugin.get("download_url", ""))
|
||||||
|
outdated_plugins.append(plugin)
|
||||||
|
|
||||||
|
print(f"[StartupWorker] Outdated plugins found: {len(outdated_plugins)}")
|
||||||
|
self.updates_checked.emit(outdated_plugins)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[StartupWorker] Error during startup update check: {e}")
|
||||||
+69
-10
@@ -38,6 +38,23 @@ if TYPE_CHECKING:
|
|||||||
from main import MainApplication
|
from main import MainApplication
|
||||||
|
|
||||||
|
|
||||||
|
class LoadProjectThread(QThread):
|
||||||
|
finished_signal = Signal(object) # Emits the loaded project data dictionary
|
||||||
|
error_signal = Signal(str) # Emits the error message string
|
||||||
|
|
||||||
|
def __init__(self, filename: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.filename = filename
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
try:
|
||||||
|
with open(self.filename, "rb") as f:
|
||||||
|
project_data = pickle.load(f)
|
||||||
|
self.finished_signal.emit(project_data)
|
||||||
|
except Exception as e:
|
||||||
|
self.error_signal.emit(str(e))
|
||||||
|
|
||||||
|
|
||||||
class SaveProjectThread(QThread):
|
class SaveProjectThread(QThread):
|
||||||
finished_signal = Signal(str)
|
finished_signal = Signal(str)
|
||||||
error_signal = Signal(str)
|
error_signal = Signal(str)
|
||||||
@@ -56,6 +73,25 @@ class SaveProjectThread(QThread):
|
|||||||
self.error_signal.emit(str(e))
|
self.error_signal.emit(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
class LoadingOverlay(QDialog):
|
||||||
|
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
|
||||||
|
self.setModal(True)
|
||||||
|
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||||
|
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||||
|
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
|
||||||
|
label = QLabel("Loading Project…")
|
||||||
|
label.setStyleSheet(
|
||||||
|
"font-size: 18px; color: white; background-color: rgba(0,0,0,150);"
|
||||||
|
" padding: 20px; border-radius: 10px;"
|
||||||
|
)
|
||||||
|
layout.addWidget(label)
|
||||||
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
|
||||||
class SavingOverlay(QDialog):
|
class SavingOverlay(QDialog):
|
||||||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||||
@@ -171,6 +207,8 @@ class ProjectManager:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
self._load_files_into_pipeline(snirf_files)
|
||||||
|
|
||||||
def load_dropped_files(self, file_paths: List[str]) -> None:
|
def load_dropped_files(self, file_paths: List[str]) -> None:
|
||||||
"""Public entry point for handling files or folders dropped onto the UI."""
|
"""Public entry point for handling files or folders dropped onto the UI."""
|
||||||
if not file_paths:
|
if not file_paths:
|
||||||
@@ -339,12 +377,31 @@ class ProjectManager:
|
|||||||
self.load_project(filename)
|
self.load_project(filename)
|
||||||
|
|
||||||
def load_project(self, filename: str) -> None:
|
def load_project(self, filename: str) -> None:
|
||||||
"""Loads a .flare project file into the application."""
|
"""Triggers asynchronous loading of a .flare project file."""
|
||||||
|
app = self.app
|
||||||
|
|
||||||
|
# 1. Show the custom loading overlay matching your saver design
|
||||||
|
self.loading_overlay = LoadingOverlay(app)
|
||||||
|
self.loading_overlay.resize(app.size())
|
||||||
|
self.loading_overlay.move(app.pos())
|
||||||
|
self.loading_overlay.show()
|
||||||
|
|
||||||
|
# 2. Fire up the background thread for pickle loading
|
||||||
|
self.loader_thread = LoadProjectThread(filename)
|
||||||
|
self.loader_thread.finished_signal.connect(
|
||||||
|
lambda data: self._handle_loaded_data(filename, data)
|
||||||
|
)
|
||||||
|
self.loader_thread.error_signal.connect(self._handle_load_error)
|
||||||
|
self.loader_thread.start()
|
||||||
|
|
||||||
|
def _handle_loaded_data(self, filename: str, data: dict) -> None:
|
||||||
|
# Close overlay
|
||||||
|
if hasattr(self, "loading_overlay"):
|
||||||
|
self.loading_overlay.close()
|
||||||
|
self.loading_overlay.deleteLater()
|
||||||
|
|
||||||
app = self.app
|
app = self.app
|
||||||
try:
|
try:
|
||||||
with open(filename, "rb") as f:
|
|
||||||
data = pickle.load(f)
|
|
||||||
|
|
||||||
checks = [
|
checks = [
|
||||||
("version", "<=1.1.7"),
|
("version", "<=1.1.7"),
|
||||||
("file_metadata", "<=1.2.2"),
|
("file_metadata", "<=1.2.2"),
|
||||||
@@ -358,7 +415,7 @@ class ProjectManager:
|
|||||||
f"This project was saved in an earlier version of {APP_NAME.upper()} ({ver_str}) "
|
f"This project was saved in an earlier version of {APP_NAME.upper()} ({ver_str}) "
|
||||||
"and is potentially not compatible with this version. "
|
"and is potentially not compatible with this version. "
|
||||||
)
|
)
|
||||||
if getattr(app, "incompatible_save_bypass", False):
|
if getattr(app, "processing_incompatible_save_bypass", False):
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(
|
||||||
app, f"Warning - {APP_NAME.upper()}", msg + "Attempting load."
|
app, f"Warning - {APP_NAME.upper()}", msg + "Attempting load."
|
||||||
)
|
)
|
||||||
@@ -438,16 +495,19 @@ class ProjectManager:
|
|||||||
if hasattr(app, "button3"):
|
if hasattr(app, "button3"):
|
||||||
app.button3.setVisible(has_data)
|
app.button3.setVisible(has_data)
|
||||||
|
|
||||||
|
|
||||||
self.add_to_recent_projects(os.path.normpath(filename))
|
self.add_to_recent_projects(os.path.normpath(filename))
|
||||||
|
|
||||||
# Reset baselines cleanly
|
|
||||||
self.reset_all_dirty_states()
|
self.reset_all_dirty_states()
|
||||||
|
|
||||||
QMessageBox.information(app, "Loaded", f"Project loaded from:\n{filename}")
|
QMessageBox.information(app, "Loaded", f"Project loaded from:\n{filename}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(app, "Error", f"Failed to load project:\n{e}")
|
QMessageBox.critical(app, "Error", f"Failed to process loaded project data:\n{e}")
|
||||||
|
|
||||||
|
def _handle_load_error(self, error_msg: str) -> None:
|
||||||
|
if hasattr(self, "loading_overlay"):
|
||||||
|
self.loading_overlay.close()
|
||||||
|
self.loading_overlay.deleteLater()
|
||||||
|
QMessageBox.critical(self.app, "Error", f"Failed to read project file:\n{error_msg}")
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Project Saving (Save / Save As)
|
# Project Saving (Save / Save As)
|
||||||
@@ -863,7 +923,6 @@ def _get_bids_demographics(snirf_path: str) -> dict[str, str]:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def extract_metadata_worker(file_name: str) -> dict[str, Any]:
|
def extract_metadata_worker(file_name: str) -> dict[str, Any]:
|
||||||
"""Runs in the separate worker process. Returns a clean dict."""
|
"""Runs in the separate worker process. Returns a clean dict."""
|
||||||
|
|
||||||
|
|||||||
+58
-56
@@ -12,6 +12,7 @@ import os
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
# External library imports
|
# External library imports
|
||||||
|
from mne import Epochs
|
||||||
from pandas import DataFrame
|
from pandas import DataFrame
|
||||||
|
|
||||||
from mne.io.base import BaseRaw
|
from mne.io.base import BaseRaw
|
||||||
@@ -27,6 +28,7 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
haemo_dict: dict[str, BaseRaw],
|
haemo_dict: dict[str, BaseRaw],
|
||||||
|
epochs_dict: dict[str, Epochs],
|
||||||
cha_dict: dict[str, DataFrame],
|
cha_dict: dict[str, DataFrame],
|
||||||
df_ind_dict: dict[str, DataFrame],
|
df_ind_dict: dict[str, DataFrame],
|
||||||
design_matrix_dict: dict[str, DataFrame],
|
design_matrix_dict: dict[str, DataFrame],
|
||||||
@@ -40,6 +42,7 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
|
|||||||
super().__init__("ExportToCSV")
|
super().__init__("ExportToCSV")
|
||||||
self.setWindowTitle(f"Export To CSV Viewer - {APP_NAME.upper()}")
|
self.setWindowTitle(f"Export To CSV Viewer - {APP_NAME.upper()}")
|
||||||
self.haemo_dict = haemo_dict
|
self.haemo_dict = haemo_dict
|
||||||
|
self.epochs_dict = epochs_dict
|
||||||
self.cha_dict = cha_dict
|
self.cha_dict = cha_dict
|
||||||
self.df_ind_dict = df_ind_dict
|
self.df_ind_dict = df_ind_dict
|
||||||
self.design_matrix = design_matrix_dict
|
self.design_matrix = design_matrix_dict
|
||||||
@@ -50,8 +53,7 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
|
|||||||
self.qc_dict = qc_dict
|
self.qc_dict = qc_dict
|
||||||
|
|
||||||
self.setup_csv_ui(["0 (Export Data to CSV)", "1 (CSV for SPARKS)", "2 (Export Configuration to CSV)", "3 (Paragraph of Configuration)", "4 (Export FIR Waveform Features to CSV)",
|
self.setup_csv_ui(["0 (Export Data to CSV)", "1 (CSV for SPARKS)", "2 (Export Configuration to CSV)", "3 (Paragraph of Configuration)", "4 (Export FIR Waveform Features to CSV)",
|
||||||
"5 (Export Quality Control Metrics to CSV)",
|
"5 (Export Quality Control Metrics to CSV)", "6 (Export Master Consolidated Matrix [All Participants into 1 CSV])"
|
||||||
"6 (Export Master Consolidated Matrix [All Participants into 1 CSV])"
|
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
@@ -120,67 +122,67 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
|
|||||||
magic_string = self.gen_magic_str(first_params)
|
magic_string = self.gen_magic_str(first_params)
|
||||||
self.placeholder_label.setText(magic_string)
|
self.placeholder_label.setText(magic_string)
|
||||||
|
|
||||||
# elif idx == 4:
|
elif idx == 4:
|
||||||
# # FIR Waveform Features Export
|
# FIR Waveform Features Export
|
||||||
# fir_data = self.fir_feature_dict.get(file_path)
|
fir_data = self.fir_feature_dict.get(file_path)
|
||||||
# if fir_data and isinstance(fir_data, dict):
|
if fir_data:
|
||||||
# names = fir_data.get("feature_names", [])
|
names = fir_data.get("feature_names", [])
|
||||||
# vals = fir_data.get("features", [])
|
vals = fir_data.get("features", [])
|
||||||
# chans = fir_data.get("feature_channels", [])
|
chans = fir_data.get("feature_channels", [])
|
||||||
|
|
||||||
# fir_df = DataFrame({
|
fir_df = DataFrame({
|
||||||
# "Feature_Name": names,
|
"Feature_Name": names,
|
||||||
# "Channel": chans if len(chans) == len(names) else ["N/A"] * len(names),
|
"Channel": chans if len(chans) == len(names) else ["N/A"] * len(names),
|
||||||
# "Value": vals
|
"Value": vals
|
||||||
# })
|
})
|
||||||
# save_path = os.path.join(output_dir, f"{base_filename}_fir_features.csv")
|
save_path = os.path.join(output_dir, f"{base_filename}_fir_features.csv")
|
||||||
# fir_df.to_csv(save_path, index=False)
|
fir_df.to_csv(save_path, index=False)
|
||||||
# success_count += 1
|
success_count += 1
|
||||||
|
|
||||||
# elif idx == 5:
|
elif idx == 5:
|
||||||
# # Quality Control (QC) Metrics Export
|
# Quality Control (QC) Metrics Export
|
||||||
# qc_data = self.qc_dict.get(file_path)
|
qc_data = self.qc_dict.get(file_path)
|
||||||
# if qc_data and isinstance(qc_data, dict):
|
if qc_data and isinstance(qc_data, dict):
|
||||||
# qc_df = DataFrame(list(qc_data.items()), columns=["Metric", "Value"])
|
qc_df = DataFrame(list(qc_data.items()), columns=["Metric", "Value"])
|
||||||
# save_path = os.path.join(output_dir, f"{base_filename}_qc_metrics.csv")
|
save_path = os.path.join(output_dir, f"{base_filename}_qc_metrics.csv")
|
||||||
# qc_df.to_csv(save_path, index=False)
|
qc_df.to_csv(save_path, index=False)
|
||||||
# success_count += 1
|
success_count += 1
|
||||||
|
|
||||||
# elif idx == 6:
|
elif idx == 6:
|
||||||
# # Master Consolidated Matrix (1 single CSV combining all selected participants)
|
# Master Consolidated Matrix (1 single CSV combining all selected participants)
|
||||||
# save_path = os.path.join(output_dir, f"{APP_NAME}_master_consolidated.csv")
|
save_path = os.path.join(output_dir, f"{APP_NAME}_master_consolidated.csv")
|
||||||
# if not os.path.exists(save_path):
|
if not os.path.exists(save_path):
|
||||||
# master_rows: list[dict[str, Any]] = []
|
master_rows: list[dict[str, Any]] = []
|
||||||
# for fp in selected_file_paths:
|
for fp in selected_file_paths:
|
||||||
# abs_path = os.path.abspath(fp)
|
abs_path = os.path.abspath(fp)
|
||||||
# grp = self.group_dict.get(fp, "Unknown")
|
grp = self.group_dict.get(fp, "Unknown")
|
||||||
# row: dict[str, Any] = {"Participant": abs_path, "Group": grp}
|
row: dict[str, Any] = {"Participant": abs_path, "Group": grp}
|
||||||
|
|
||||||
# # QC Metrics
|
# QC Metrics
|
||||||
# qc_info = self.qc_dict.get(fp, {})
|
qc_info = self.qc_dict.get(fp, {})
|
||||||
# if isinstance(qc_info, dict):
|
if isinstance(qc_info, dict):
|
||||||
# for mk, mv in qc_info.items():
|
for mk, mv in qc_info.items():
|
||||||
# row[f"QC_{mk}"] = mv
|
row[f"QC_{mk}"] = mv
|
||||||
|
|
||||||
# # FIR Features
|
# FIR Features
|
||||||
# fir_info = self.fir_feature_dict.get(fp, {})
|
fir_info = self.fir_feature_dict.get(fp, {})
|
||||||
# print("1")
|
print("1")
|
||||||
# if isinstance(fir_info, dict):
|
if isinstance(fir_info, dict):
|
||||||
# print("2")
|
print("2")
|
||||||
# f_names = fir_info.get("feature_names", [])
|
f_names = fir_info.get("feature_names", [])
|
||||||
# f_vals = fir_info.get("features", [])
|
f_vals = fir_info.get("features", [])
|
||||||
# print("3")
|
print("3")
|
||||||
# if len(f_names) == len(f_vals):
|
if len(f_names) == len(f_vals):
|
||||||
# print("4")
|
print("4")
|
||||||
# for fn, fv in zip(f_names, f_vals):
|
for fn, fv in zip(f_names, f_vals):
|
||||||
# row[f"FIR_{fn}"] = fv
|
row[f"FIR_{fn}"] = fv
|
||||||
|
|
||||||
# master_rows.append(row)
|
master_rows.append(row)
|
||||||
|
|
||||||
# if master_rows:
|
if master_rows:
|
||||||
# df_master = DataFrame(master_rows)
|
df_master = DataFrame(master_rows)
|
||||||
# df_master.to_csv(save_path, index=False)
|
df_master.to_csv(save_path, index=False)
|
||||||
# success_count += 1
|
success_count += 1
|
||||||
|
|
||||||
else:
|
else:
|
||||||
print(f"No method defined for index {idx}")
|
print(f"No method defined for index {idx}")
|
||||||
|
|||||||
@@ -211,7 +211,6 @@ class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
cha_df = self.cha_dict.get(file_path)
|
cha_df = self.cha_dict.get(file_path)
|
||||||
if cha_df is not None:
|
if cha_df is not None:
|
||||||
all_cha = pd.concat([all_cha, cha_df], ignore_index=True)
|
all_cha = pd.concat([all_cha, cha_df], ignore_index=True)
|
||||||
|
|||||||
@@ -283,7 +283,6 @@ class StaticChannelCanvas(FigureCanvas):
|
|||||||
self.draw_idle()
|
self.draw_idle()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class StandaloneLegendDialog(QWidget):
|
class StandaloneLegendDialog(QWidget):
|
||||||
def __init__(self, canvas_engine: Any, title_prefix: str, parent: QWidget | None = None) -> None:
|
def __init__(self, canvas_engine: Any, title_prefix: str, parent: QWidget | None = None) -> None:
|
||||||
super().__init__(None)
|
super().__init__(None)
|
||||||
@@ -317,7 +316,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
|||||||
|
|
||||||
num_channels = len(channels_data)
|
num_channels = len(channels_data)
|
||||||
|
|
||||||
# --- FIX: DYNAMICALLY CALCULATE OPTIMAL 16:9 COLUMNS ---
|
# Dynamically calculate best ratio for 16:9
|
||||||
target_ratio = 16 / 9
|
target_ratio = 16 / 9
|
||||||
best_cols = 4
|
best_cols = 4
|
||||||
min_ratio_error = float('inf')
|
min_ratio_error = float('inf')
|
||||||
@@ -678,7 +677,6 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
|||||||
scroll_layout.setSpacing(6)
|
scroll_layout.setSpacing(6)
|
||||||
scroll_layout.setContentsMargins(0, 0, 0, 0)
|
scroll_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
true_color_map = get_landmark_color_map()
|
true_color_map = get_landmark_color_map()
|
||||||
|
|
||||||
# Iterate over the sorted keys directly from your method
|
# Iterate over the sorted keys directly from your method
|
||||||
|
|||||||
@@ -14,11 +14,12 @@ import pandas as pd
|
|||||||
from pandas import DataFrame
|
from pandas import DataFrame
|
||||||
|
|
||||||
from PySide6.QtWidgets import QApplication, QComboBox, QDialog, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QListView, QMessageBox, QPushButton, QScrollArea, QSizePolicy, QVBoxLayout, QWidget, QFrame, QSpinBox, QFileDialog
|
from PySide6.QtWidgets import QApplication, QComboBox, QDialog, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QListView, QMessageBox, QPushButton, QScrollArea, QSizePolicy, QVBoxLayout, QWidget, QFrame, QSpinBox, QFileDialog
|
||||||
from PySide6.QtGui import QPalette, QStandardItemModel, QStandardItem, QPixmap, QIntValidator, QDoubleValidator
|
from PySide6.QtGui import QStandardItemModel, QStandardItem, QPixmap, QIntValidator, QDoubleValidator
|
||||||
from PySide6.QtCore import QEvent, QPoint, QSize, QTimer, Qt, Signal
|
from PySide6.QtCore import QEvent, QPoint, QSize, QTimer, Qt, Signal
|
||||||
|
|
||||||
from src.shared.shareddata import APP_NAME, PIPELINE_STAGES
|
from src.shared.shareddata import APP_NAME, PIPELINE_STAGES
|
||||||
|
|
||||||
|
|
||||||
class FullClickComboBox(QComboBox):
|
class FullClickComboBox(QComboBox):
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -1290,7 +1291,6 @@ class FlaresBaseWidget(QWidget):
|
|||||||
model.itemChanged.connect(on_item_changed)
|
model.itemChanged.connect(on_item_changed)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def update_participant_list_for_group(self, group_name=None, combo=None):
|
def update_participant_list_for_group(self, group_name=None, combo=None):
|
||||||
|
|
||||||
target_combo = combo if combo is not None else getattr(self, "participant_dropdown", None)
|
target_combo = combo if combo is not None else getattr(self, "participant_dropdown", None)
|
||||||
@@ -1492,8 +1492,6 @@ class InterGroupUIMixin:
|
|||||||
dropdown.blockSignals(False)
|
dropdown.blockSignals(False)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _get_file_paths_from_labels(self, labels, group_name):
|
def _get_file_paths_from_labels(self, labels, group_name):
|
||||||
file_paths = []
|
file_paths = []
|
||||||
|
|
||||||
@@ -1868,7 +1866,6 @@ class IntraGroupUIMixin:
|
|||||||
param_info["options"] = dynamic_contrasts
|
param_info["options"] = dynamic_contrasts
|
||||||
param_info["default"] = dynamic_contrasts[0] if dynamic_contrasts else ""
|
param_info["default"] = dynamic_contrasts[0] if dynamic_contrasts else ""
|
||||||
|
|
||||||
|
|
||||||
param_values = {}
|
param_values = {}
|
||||||
if indexes_needing_params:
|
if indexes_needing_params:
|
||||||
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
|
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
|
||||||
@@ -1886,6 +1883,7 @@ class IntraGroupUIMixin:
|
|||||||
param_values,
|
param_values,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ParticipantUIMixin:
|
class ParticipantUIMixin:
|
||||||
def setup_participant_ui(
|
def setup_participant_ui(
|
||||||
self,
|
self,
|
||||||
@@ -1913,7 +1911,6 @@ class ParticipantUIMixin:
|
|||||||
self.event_dropdown = QComboBox()
|
self.event_dropdown = QComboBox()
|
||||||
self.event_dropdown.addItem("<None Selected>")
|
self.event_dropdown.addItem("<None Selected>")
|
||||||
|
|
||||||
|
|
||||||
self.index_texts = index_texts
|
self.index_texts = index_texts
|
||||||
|
|
||||||
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
|
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
|
||||||
@@ -1968,8 +1965,6 @@ class ParticipantUIMixin:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Inject full_text from index_texts
|
# Inject full_text from index_texts
|
||||||
for idx, params_list in parameterized_indexes.items():
|
for idx, params_list in parameterized_indexes.items():
|
||||||
full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)"
|
full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)"
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""
|
||||||
|
Filename: inspiration.py
|
||||||
|
Description: Contains inspiration quotes to be shown at random
|
||||||
|
Note: Compliant with pylance strict type checking
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Built-in imports
|
||||||
|
import random
|
||||||
|
|
||||||
|
FNIRS_QUOTES = [
|
||||||
|
"Understanding the brain through light and data.",
|
||||||
|
"Every signal offers another perspective on brain function.",
|
||||||
|
"Exploring the relationship between brain activity and behavior.",
|
||||||
|
"Using light to investigate the complexity of the human brain.",
|
||||||
|
"Turning neural measurements into meaningful insights.",
|
||||||
|
"A deeper understanding of the brain begins with better questions.",
|
||||||
|
"Studying the brain means looking beyond what we can see.",
|
||||||
|
"Connecting brain activity to the way we think, feel, and act.",
|
||||||
|
"Even small changes in brain signals can reveal meaningful patterns.",
|
||||||
|
"Advancing neuroscience through new ways of measuring brain activity.",
|
||||||
|
"The brain is complex, and every measurement adds another piece.",
|
||||||
|
"Using fNIRS to explore the connection between the brain and the world around us.",
|
||||||
|
"Good science begins with curiosity and grows through careful observation.",
|
||||||
|
"There is still so much to learn about how the brain works.",
|
||||||
|
"Measuring brain activity is only the beginning; understanding it is the goal.",
|
||||||
|
"Exploring what brain activity can tell us about the human experience.",
|
||||||
|
"Every experiment brings a new opportunity to understand the brain.",
|
||||||
|
"The more we measure, the more questions we learn to ask.",
|
||||||
|
"Neuroscience advances through careful measurements and thoughtful interpretation.",
|
||||||
|
"Understanding brain function requires looking at both signals and context.",
|
||||||
|
"Each dataset offers a different view of the brain in action.",
|
||||||
|
"Exploring the neural processes behind everyday human experiences.",
|
||||||
|
"Better measurements can lead to better questions about the brain.",
|
||||||
|
"The challenge is not just collecting data, but understanding what it means.",
|
||||||
|
"Brain activity reflects a complex interaction of processes and experiences.",
|
||||||
|
"Scientific progress begins with observing what we do not yet understand.",
|
||||||
|
"Each discovery builds on the questions that came before it.",
|
||||||
|
"There is more to brain activity than a single signal can reveal.",
|
||||||
|
"New tools create new opportunities to investigate old questions.",
|
||||||
|
"The goal is to connect what we measure with what we want to understand.",
|
||||||
|
"Neural signals provide clues to the processes underlying human behavior.",
|
||||||
|
"A better understanding of brain function starts with better evidence.",
|
||||||
|
"The value of a measurement lies in what it helps us understand.",
|
||||||
|
"Every experiment adds another perspective to a complex picture.",
|
||||||
|
"Scientific discovery often begins with noticing something unexpected.",
|
||||||
|
"Understanding the brain is an ongoing process of questioning and refinement.",
|
||||||
|
"The best insights emerge when data meets thoughtful interpretation.",
|
||||||
|
"Research moves forward when curiosity is matched with rigorous methods.",
|
||||||
|
"There is always another question worth exploring.",
|
||||||
|
"Investigating the brain means embracing complexity rather than avoiding it.",
|
||||||
|
"Progress comes from asking precise questions about complicated systems.",
|
||||||
|
"The brain does not work in isolation from the person or environment.",
|
||||||
|
"Understanding behavior requires more than observing behavior alone.",
|
||||||
|
"The smallest details can sometimes change how we interpret the bigger picture.",
|
||||||
|
"Every method offers a different window into brain function.",
|
||||||
|
"Different perspectives help build a more complete understanding of the brain.",
|
||||||
|
"Good research turns uncertainty into questions that can be investigated.",
|
||||||
|
"The process of discovery is as important as the result.",
|
||||||
|
"Meaningful findings begin with reliable data and careful reasoning.",
|
||||||
|
"The brain is not simple, and our methods should reflect that complexity.",
|
||||||
|
"Scientific understanding grows when evidence challenges our assumptions.",
|
||||||
|
"Research is a process of learning what the data can—and cannot—tell us.",
|
||||||
|
"The most useful findings are those that move understanding forward.",
|
||||||
|
"Each new method expands the questions we can ask about the brain.",
|
||||||
|
"The path to understanding is built from observations, questions, and evidence.",
|
||||||
|
"Neuroscience is about understanding the processes behind the patterns.",
|
||||||
|
"The aim is not simply to measure brain activity, but to interpret it meaningfully.",
|
||||||
|
"Better tools help us investigate the questions that matter.",
|
||||||
|
"Progress in neuroscience depends on both technical precision and human curiosity.",
|
||||||
|
"The brain is complex, but every careful observation helps clarify a part of it.",
|
||||||
|
"Understanding brain function means connecting measurements to meaningful outcomes.",
|
||||||
|
"Research turns curiosity into evidence and evidence into understanding.",
|
||||||
|
"There is a story behind every pattern, but evidence determines what we can conclude.",
|
||||||
|
"New insights emerge when we approach familiar questions from different angles.",
|
||||||
|
"The strongest conclusions begin with careful measurements.",
|
||||||
|
"Understanding the brain takes more than data; it takes context.",
|
||||||
|
"The questions we ask shape what we learn from the brain.",
|
||||||
|
"Good research is not about finding answers quickly, but finding them carefully.",
|
||||||
|
"Every result contributes to a larger conversation about how the brain works.",
|
||||||
|
"The human brain remains an evolving question for science.",
|
||||||
|
"Measuring brain activity gives us another way to investigate human cognition.",
|
||||||
|
"The connection between brain function and behavior is rarely straightforward.",
|
||||||
|
"Scientific progress comes from combining new methods with careful interpretation.",
|
||||||
|
"The more we learn about the brain, the better our questions become.",
|
||||||
|
"Research is the practice of making uncertainty more understandable.",
|
||||||
|
"Understanding the brain means being willing to revise what we think we know.",
|
||||||
|
"Each experiment helps define what we know and what remains uncertain.",
|
||||||
|
"The most interesting findings often begin with questions that have no easy answers.",
|
||||||
|
"The purpose of research is to move beyond assumptions toward evidence.",
|
||||||
|
"A measurement becomes useful when it helps answer a meaningful question.",
|
||||||
|
"Neuroscience brings together technology, observation, and the study of human experience.",
|
||||||
|
"fNIRS offers another way to investigate changes in brain hemodynamics.",
|
||||||
|
"Optical measurements provide a noninvasive approach to studying brain function.",
|
||||||
|
"Studying brain function outside the lab can reveal aspects of everyday cognition.",
|
||||||
|
"The flexibility of fNIRS opens new possibilities for human neuroscience research.",
|
||||||
|
"Brain measurements become more meaningful when considered alongside behavior.",
|
||||||
|
"fNIRS provides a useful perspective on the relationship between physiology and cognition.",
|
||||||
|
"Understanding the strengths and limitations of a method is part of good science.",
|
||||||
|
"The future of neuroscience depends on asking better questions, not just collecting more data.",
|
||||||
|
"Technology expands what we can measure, while critical thinking guides what we conclude.",
|
||||||
|
"The goal of neuroscience is not just to observe the brain, but to understand its function.",
|
||||||
|
"Every research method brings its own perspective, possibilities, and limitations.",
|
||||||
|
"The most meaningful discoveries connect technical advances to real-world questions.",
|
||||||
|
"Better ways to study the brain can lead to a better understanding of human experience.",
|
||||||
|
"Science progresses when we remain curious about what the evidence cannot yet explain.",
|
||||||
|
"Understanding the brain is a long-term effort built on many small discoveries.",
|
||||||
|
"Behind every useful finding is a question worth investigating.",
|
||||||
|
"The value of research lies in what we learn, not just what we measure.",
|
||||||
|
"Good science makes room for complexity, uncertainty, and new perspectives.",
|
||||||
|
"The next insight may come from looking at a familiar problem in a different way.",
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_quote() -> None:
|
||||||
|
return f"'{random.choice(FNIRS_QUOTES)}'"
|
||||||
@@ -8,12 +8,18 @@ License: GPL-3.0
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Built-in imports
|
# Built-in imports
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import platform
|
import platform
|
||||||
|
|
||||||
|
try:
|
||||||
|
import winreg
|
||||||
|
except ImportError:
|
||||||
|
winreg = None
|
||||||
|
|
||||||
CURRENT_VERSION = "1.7.2"
|
CURRENT_VERSION = "1.8.0"
|
||||||
APP_NAME = "flares"
|
APP_NAME = "flares"
|
||||||
APP_NAME_EXPANDED = "fNIRS Lightweight Analysis, Research, & Evaluation Suite"
|
APP_NAME_EXPANDED = "fNIRS Lightweight Analysis, Research, & Evaluation Suite"
|
||||||
API_URL = f"https://git.research.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases"
|
API_URL = f"https://git.research.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases"
|
||||||
@@ -72,6 +78,63 @@ DATA_SCHEMA = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
METRIC_REGISTRY = {
|
||||||
|
"Peak_Amp": "Peak_Amp",
|
||||||
|
"TTP": "Time_to_Peak",
|
||||||
|
"AUC": "AUC",
|
||||||
|
"Rising_Slope": "Rising_Slope",
|
||||||
|
"Recovery_Slope": "Recovery_Slope",
|
||||||
|
"FWHM": "FWHM",
|
||||||
|
"Onset_Latency": "Onset_Latency",
|
||||||
|
"P2P_Amp": "Peak_to_Peak_Amp",
|
||||||
|
"Signal_Std": "Signal_Std",
|
||||||
|
"RMS": "RMS",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_cpu_model_name():
|
||||||
|
"""Detect detailed processor info, collapsing Windows registry whitespace padding."""
|
||||||
|
try:
|
||||||
|
system = platform.system()
|
||||||
|
|
||||||
|
if system == "Windows" and winreg is not None:
|
||||||
|
with winreg.OpenKey(
|
||||||
|
winreg.HKEY_LOCAL_MACHINE,
|
||||||
|
r"HARDWARE\DESCRIPTION\System\CentralProcessor\0",
|
||||||
|
) as key:
|
||||||
|
raw_cpu_name, _ = winreg.QueryValueEx(key, "ProcessorNameString")
|
||||||
|
cpu_name = " ".join(raw_cpu_name.split())
|
||||||
|
|
||||||
|
command = (
|
||||||
|
"Get-CimInstance Win32_Processor | "
|
||||||
|
"Select-Object NumberOfCores, NumberOfLogicalProcessors, MaxClockSpeed | "
|
||||||
|
"ConvertTo-Json -Compress"
|
||||||
|
)
|
||||||
|
cpu = json.loads(subprocess.check_output(
|
||||||
|
["powershell", "-NoProfile", "-Command", command],
|
||||||
|
text=True,
|
||||||
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||||
|
))
|
||||||
|
base_ghz = cpu["MaxClockSpeed"] / 1000
|
||||||
|
return (f"{cpu_name} @ {base_ghz:.1f}GHz "
|
||||||
|
f"{cpu['NumberOfCores']}c/{cpu['NumberOfLogicalProcessors']}t")
|
||||||
|
|
||||||
|
if system == "Darwin":
|
||||||
|
return subprocess.check_output(
|
||||||
|
["sysctl", "-n", "machdep.cpu.brand_string"], text=True
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
if system == "Linux":
|
||||||
|
with open("/proc/cpuinfo") as f:
|
||||||
|
for line in f:
|
||||||
|
if "model name" in line:
|
||||||
|
return line.split(":")[1].strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return platform.processor() or "Multi-Core CPU"
|
||||||
|
|
||||||
|
|
||||||
def resource_path(relative_path: str) -> str:
|
def resource_path(relative_path: str) -> str:
|
||||||
"""Get absolute path to resource regardless of running directly or packaged using PyInstaller"""
|
"""Get absolute path to resource regardless of running directly or packaged using PyInstaller"""
|
||||||
base_path = getattr(sys, "_MEIPASS", os.path.abspath("."))
|
base_path = getattr(sys, "_MEIPASS", os.path.abspath("."))
|
||||||
|
|||||||
+11
-1
@@ -12,6 +12,7 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel
|
|||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
from src.shared.shareddata import APP_NAME, APP_NAME_EXPANDED, CURRENT_VERSION
|
from src.shared.shareddata import APP_NAME, APP_NAME_EXPANDED, CURRENT_VERSION
|
||||||
|
from src.shared.inspiration import get_quote
|
||||||
|
|
||||||
|
|
||||||
class AboutWindow(QWidget):
|
class AboutWindow(QWidget):
|
||||||
@@ -30,12 +31,21 @@ class AboutWindow(QWidget):
|
|||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
label = QLabel(f"About {APP_NAME.upper()}", self)
|
label = QLabel(f"About {APP_NAME.upper()}", self)
|
||||||
label2 = QLabel(f"{APP_NAME_EXPANDED}", self)
|
label2 = QLabel(f"{APP_NAME_EXPANDED}", self)
|
||||||
label3 = QLabel(f"{APP_NAME.upper()} is licensed under the GPL-3.0 licence. For more information, visit https://www.gnu.org/licenses/gpl-3.0.en.html", self)
|
label3 = QLabel(
|
||||||
|
f'{APP_NAME.upper()} is licensed under the GPL-3.0 licence. '
|
||||||
|
'For more information, visit '
|
||||||
|
'<a href="https://www.gnu.org/licenses/gpl-3.0.en.html">'
|
||||||
|
'GNU GPL-3.0</a>',
|
||||||
|
self,
|
||||||
|
)
|
||||||
|
label3.setOpenExternalLinks(True)
|
||||||
label4 = QLabel(f"Version v{CURRENT_VERSION}")
|
label4 = QLabel(f"Version v{CURRENT_VERSION}")
|
||||||
|
label5 = QLabel(f"{get_quote()}", self)
|
||||||
|
|
||||||
layout.addWidget(label)
|
layout.addWidget(label)
|
||||||
layout.addWidget(label2)
|
layout.addWidget(label2)
|
||||||
layout.addWidget(label3)
|
layout.addWidget(label3)
|
||||||
layout.addWidget(label4)
|
layout.addWidget(label4)
|
||||||
|
layout.addWidget(label5)
|
||||||
|
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
+348
-81
@@ -8,6 +8,10 @@ License: GPL-3.0
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Built-in imports
|
# Built-in imports
|
||||||
|
import io
|
||||||
|
import shutil
|
||||||
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
@@ -15,26 +19,95 @@ from typing import Any, cast
|
|||||||
from PySide6.QtCore import Qt, QThread, Signal
|
from PySide6.QtCore import Qt, QThread, Signal
|
||||||
from PySide6.QtGui import QColor
|
from PySide6.QtGui import QColor
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
|
QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMessageBox,
|
||||||
QMessageBox, QPushButton, QTabWidget, QTextBrowser, QVBoxLayout, QWidget
|
QPushButton, QTabWidget, QTextBrowser, QVBoxLayout, QWidget, QProgressDialog
|
||||||
)
|
)
|
||||||
|
|
||||||
from plugin_manager import PluginManager, parse_version
|
from plugin_manager import PluginManager, get_ssl_context, parse_version
|
||||||
from src.shared.shareddata import APP_NAME, CURRENT_VERSION, PLUGINS_URL
|
from src.shared.shareddata import APP_NAME, CURRENT_VERSION, PLUGINS_URL
|
||||||
|
|
||||||
|
|
||||||
class RemoteFetchWorker(QThread):
|
class RemoteFetchWorker(QThread):
|
||||||
"""Background thread to fetch remote repository data without lagging the main UI."""
|
"""Background thread to fetch remote repository data without lagging the main UI."""
|
||||||
fetched = Signal(list, bool) # (plugins_data, all_failed)
|
fetched = Signal(list, bool, str) # (plugins_data, all_failed, fetch_type)
|
||||||
|
|
||||||
def __init__(self, manager: PluginManager, urls: list[str]) -> None:
|
def __init__(self, manager: PluginManager, urls: list[str], fetch_type: str = "browser") -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.manager = manager
|
self.manager = manager
|
||||||
self.urls = urls
|
self.urls = urls
|
||||||
|
self.fetch_type = fetch_type
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
plugins, all_failed = self.manager.fetch_remote_repositories(self.urls)
|
plugins, all_failed = self.manager.fetch_remote_repositories(self.urls)
|
||||||
self.fetched.emit(plugins, all_failed)
|
self.fetched.emit(plugins, all_failed, self.fetch_type)
|
||||||
|
|
||||||
|
|
||||||
|
class PluginInstallWorker(QThread):
|
||||||
|
"""Background worker to download, unpack, and install/update plugins with progress reporting."""
|
||||||
|
progress = Signal(int, int) # (downloaded_bytes, total_bytes)
|
||||||
|
status_message = Signal(str)
|
||||||
|
finished = Signal(bool, str) # (success, message_or_path)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
manager: PluginManager,
|
||||||
|
download_url: str,
|
||||||
|
plugin_id: str,
|
||||||
|
is_update: bool = False,
|
||||||
|
plugin_path_to_remove: Path | None = None
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.manager = manager
|
||||||
|
self.download_url = download_url
|
||||||
|
self.plugin_id = plugin_id
|
||||||
|
self.is_update = is_update
|
||||||
|
self.plugin_path_to_remove = plugin_path_to_remove
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
try:
|
||||||
|
self.status_message.emit("Connecting to server...")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
self.download_url,
|
||||||
|
headers={"User-Agent": f"{APP_NAME}-PluginManager"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with urllib.request.urlopen(req, timeout=30, context=get_ssl_context()) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
self.finished.emit(False, f"Download failed with HTTP status code {response.status}")
|
||||||
|
return
|
||||||
|
|
||||||
|
total_size_str = response.headers.get("Content-Length")
|
||||||
|
total_size = int(total_size_str) if total_size_str and total_size_str.isdigit() else 0
|
||||||
|
|
||||||
|
self.status_message.emit("Downloading plugin archive...")
|
||||||
|
chunk_size = 8192
|
||||||
|
downloaded = 0
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
chunk = response.read(chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
buffer.write(chunk)
|
||||||
|
downloaded += len(chunk)
|
||||||
|
self.progress.emit(downloaded, total_size)
|
||||||
|
|
||||||
|
zip_bytes = buffer.getvalue()
|
||||||
|
|
||||||
|
if self.is_update and self.plugin_path_to_remove and self.plugin_path_to_remove.exists():
|
||||||
|
self.status_message.emit("Removing old version...")
|
||||||
|
if self.plugin_path_to_remove.is_dir():
|
||||||
|
shutil.rmtree(self.plugin_path_to_remove)
|
||||||
|
else:
|
||||||
|
self.plugin_path_to_remove.unlink()
|
||||||
|
|
||||||
|
self.status_message.emit("Unpacking and extracting files...")
|
||||||
|
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zip_ref:
|
||||||
|
zip_ref.extractall(self.manager.plugins_dir)
|
||||||
|
|
||||||
|
self.finished.emit(True, str(self.manager.plugins_dir))
|
||||||
|
except Exception as e:
|
||||||
|
self.finished.emit(False, str(e))
|
||||||
|
|
||||||
|
|
||||||
class PluginsWindow(QWidget):
|
class PluginsWindow(QWidget):
|
||||||
@@ -52,6 +125,8 @@ class PluginsWindow(QWidget):
|
|||||||
self.remote_plugins_data: list[dict[str, Any]] = []
|
self.remote_plugins_data: list[dict[str, Any]] = []
|
||||||
self._has_fetched_remote: bool = False
|
self._has_fetched_remote: bool = False
|
||||||
self._fetch_worker: RemoteFetchWorker | None = None
|
self._fetch_worker: RemoteFetchWorker | None = None
|
||||||
|
self._install_worker: PluginInstallWorker | None = None
|
||||||
|
self._progress_dialog: QProgressDialog | None = None
|
||||||
|
|
||||||
# Refresh UI automatically if manager updates state
|
# Refresh UI automatically if manager updates state
|
||||||
self.manager.plugins_changed.connect(self.refresh_installed_plugins)
|
self.manager.plugins_changed.connect(self.refresh_installed_plugins)
|
||||||
@@ -70,40 +145,75 @@ class PluginsWindow(QWidget):
|
|||||||
self.setLayout(main_layout)
|
self.setLayout(main_layout)
|
||||||
|
|
||||||
self.refresh_installed_plugins()
|
self.refresh_installed_plugins()
|
||||||
|
self.check_installed_updates()
|
||||||
|
|
||||||
|
def check_installed_updates(self) -> None:
|
||||||
|
"""Asynchronously checks remote repositories for installed plugin updates."""
|
||||||
|
if self._fetch_worker is not None and self._fetch_worker.isRunning():
|
||||||
|
return
|
||||||
|
|
||||||
|
self._fetch_worker = RemoteFetchWorker(self.manager, self.repository_urls, fetch_type="updates")
|
||||||
|
self._fetch_worker.fetched.connect(self._on_remote_fetched)
|
||||||
|
self._fetch_worker.start()
|
||||||
|
|
||||||
def refresh_installed_plugins(self) -> None:
|
def refresh_installed_plugins(self) -> None:
|
||||||
"""Refreshes installed list using metadata from PluginManager."""
|
"""Refreshes installed list using metadata from PluginManager and attaches update info."""
|
||||||
self.installed_list.clear()
|
self.installed_list.clear()
|
||||||
plugins_info = self.manager.get_installed_plugins_info()
|
plugins_info = self.manager.get_installed_plugins_info()
|
||||||
|
|
||||||
|
remote_map: dict[str, dict[str, Any]] = {}
|
||||||
|
if self._has_fetched_remote and self.remote_plugins_data:
|
||||||
|
for p in self.remote_plugins_data:
|
||||||
|
p_id = p.get("id") or p.get("name")
|
||||||
|
if p_id:
|
||||||
|
remote_map[str(p_id)] = p
|
||||||
|
|
||||||
for info in plugins_info:
|
for info in plugins_info:
|
||||||
|
p_id = str(info.get("id", ""))
|
||||||
|
local_tuple = parse_version(str(info.get("version", "0.0.0")))
|
||||||
|
|
||||||
|
remote_plugin = remote_map.get(p_id)
|
||||||
|
if remote_plugin:
|
||||||
|
remote_ver = str(remote_plugin.get("version", "0.0.0"))
|
||||||
|
if parse_version(remote_ver) > local_tuple:
|
||||||
|
info["update_available"] = True
|
||||||
|
info["remote_version"] = remote_ver
|
||||||
|
info["download_url"] = str(remote_plugin.get("download_url", ""))
|
||||||
|
|
||||||
display_text = f"{info['name']} (v{info.get('version', '1.0.0')})"
|
display_text = f"{info['name']} (v{info.get('version', '1.0.0')})"
|
||||||
|
if info.get("update_available"):
|
||||||
|
display_text += f" [Update Available: v{info.get('remote_version')}]"
|
||||||
if info["is_disabled"]:
|
if info["is_disabled"]:
|
||||||
display_text += " [Disabled]"
|
display_text += " [Disabled]"
|
||||||
|
|
||||||
item = QListWidgetItem(display_text, self.installed_list)
|
item = QListWidgetItem(display_text, self.installed_list)
|
||||||
item.setData(Qt.ItemDataRole.UserRole, info)
|
item.setData(Qt.ItemDataRole.UserRole, info)
|
||||||
|
|
||||||
if info["is_disabled"]:
|
if info.get("update_available"):
|
||||||
|
item.setForeground(QColor("#1976d2"))
|
||||||
|
elif info["is_disabled"]:
|
||||||
item.setForeground(QColor("#757575"))
|
item.setForeground(QColor("#757575"))
|
||||||
|
|
||||||
if self.installed_list.count() == 0:
|
if self.installed_list.count() == 0:
|
||||||
self.installed_list.addItem("No plugins installed.")
|
self.installed_list.addItem("No plugins installed.")
|
||||||
self._clear_details_panel()
|
self._clear_details_panel()
|
||||||
|
|
||||||
# Re-populate browser list if remote data was already fetched to reflect newly installed/uninstalled plugins
|
if not self._has_fetched_remote:
|
||||||
if self._has_fetched_remote and self.remote_plugins_data:
|
self.check_installed_updates()
|
||||||
|
elif self.remote_plugins_data:
|
||||||
self._populate_browser_list()
|
self._populate_browser_list()
|
||||||
|
|
||||||
def _on_installed_item_changed(self, current: QListWidgetItem | None, _: Any) -> None:
|
def _on_installed_item_changed(self, current: QListWidgetItem | None, _: Any) -> None:
|
||||||
"""Updates the right-hand details panel when a plugin is selected."""
|
"""Updates the right-hand details panel when a plugin is selected."""
|
||||||
if not current:
|
if not current:
|
||||||
self._clear_details_panel()
|
self._clear_details_panel()
|
||||||
|
self.btn_update_plugin.setEnabled(False)
|
||||||
return
|
return
|
||||||
|
|
||||||
info = current.data(Qt.ItemDataRole.UserRole)
|
info = current.data(Qt.ItemDataRole.UserRole)
|
||||||
if not isinstance(info, dict):
|
if not isinstance(info, dict):
|
||||||
self._clear_details_panel()
|
self._clear_details_panel()
|
||||||
|
self.btn_update_plugin.setEnabled(False)
|
||||||
return
|
return
|
||||||
|
|
||||||
plugin_info = cast(dict[str, Any], info)
|
plugin_info = cast(dict[str, Any], info)
|
||||||
@@ -114,83 +224,161 @@ class PluginsWindow(QWidget):
|
|||||||
desc = str(plugin_info.get("description", "No description provided."))
|
desc = str(plugin_info.get("description", "No description provided."))
|
||||||
is_disabled = bool(plugin_info.get("is_disabled", False))
|
is_disabled = bool(plugin_info.get("is_disabled", False))
|
||||||
path = str(plugin_info.get("path", ""))
|
path = str(plugin_info.get("path", ""))
|
||||||
|
update_available = bool(plugin_info.get("update_available", False))
|
||||||
|
|
||||||
self.lbl_plugin_title.setText(name)
|
self.lbl_plugin_title.setText(name)
|
||||||
self.lbl_plugin_meta.setText(f"<b>Version:</b> {version} | <b>Author:</b> {author}")
|
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>")
|
self.lbl_plugin_status.setText("<font color='#d32f2f'><b>Status: Disabled</b></font>")
|
||||||
else:
|
else:
|
||||||
self.lbl_plugin_status.setText("<font color='#2e7d32'><b>Status: Active</b></font>")
|
self.lbl_plugin_status.setText("<font color='#2e7d32'><b>Status: Active</b></font>")
|
||||||
|
|
||||||
self.txt_plugin_desc.setHtml(desc)
|
self.txt_plugin_desc.setHtml(desc)
|
||||||
self.lbl_plugin_path.setText(f"<b>Path:</b> <code>{path}</code>")
|
self.lbl_plugin_path.setText(f"<b>Path:</b> <code>{path}</code>")
|
||||||
|
self.btn_update_plugin.setEnabled(update_available)
|
||||||
|
|
||||||
def _clear_details_panel(self) -> None:
|
def execute_plugin_installation(self, download_url: str, plugin_id: str, plugin_name: str, is_update: bool, plugin_path: Path | None) -> None:
|
||||||
"""Clears details display when nothing is selected."""
|
"""Sets up the progress dialog and starts the background installation/update worker."""
|
||||||
self.lbl_plugin_title.setText("Select a plugin")
|
if self._install_worker is not None and self._install_worker.isRunning():
|
||||||
self.lbl_plugin_meta.setText("")
|
return
|
||||||
self.lbl_plugin_status.setText("")
|
|
||||||
self.txt_plugin_desc.clear()
|
|
||||||
self.lbl_plugin_path.setText("")
|
|
||||||
|
|
||||||
def toggle_enable_disable(self) -> None:
|
self._installation_finished_flag = False
|
||||||
|
action_label = "Updating" if is_update else "Installing"
|
||||||
|
|
||||||
|
self._progress_dialog = QProgressDialog(f"{action_label} '{plugin_name}'...", "Cancel", 0, 100, self)
|
||||||
|
self._progress_dialog.setMinimumDuration(0) # Show the progress dialog instantly
|
||||||
|
self._progress_dialog.setWindowTitle(f"{action_label} Plugin")
|
||||||
|
self._progress_dialog.setWindowModality(Qt.WindowModality.WindowModal)
|
||||||
|
self._progress_dialog.setAutoClose(False)
|
||||||
|
self._progress_dialog.setAutoReset(False)
|
||||||
|
self._progress_dialog.canceled.connect(self._cancel_installation)
|
||||||
|
self._progress_dialog.show()
|
||||||
|
|
||||||
|
self._install_worker = PluginInstallWorker(self.manager, download_url, plugin_id, is_update, plugin_path)
|
||||||
|
self._install_worker.progress.connect(self._on_install_progress)
|
||||||
|
self._install_worker.status_message.connect(self._on_install_status)
|
||||||
|
self._install_worker.finished.connect(lambda success, msg: self._on_install_finished(success, msg, plugin_name, is_update))
|
||||||
|
self._install_worker.start()
|
||||||
|
|
||||||
|
|
||||||
|
def _cancel_installation(self) -> None:
|
||||||
|
"""Handles manual user cancellation of the download/install progress."""
|
||||||
|
if getattr(self, "_installation_finished_flag", False):
|
||||||
|
return # Ignore if triggered by closing the dialog programmatically on success
|
||||||
|
|
||||||
|
if self._install_worker and self._install_worker.isRunning():
|
||||||
|
self._install_worker.terminate()
|
||||||
|
self._install_worker.wait()
|
||||||
|
QMessageBox.warning(self, "Cancelled", "Plugin installation was cancelled.")
|
||||||
|
|
||||||
|
def _on_install_finished(self, success: bool, message: str, plugin_name: str, is_update: bool) -> None:
|
||||||
|
self._installation_finished_flag = True # Prevent false cancellation triggers
|
||||||
|
|
||||||
|
if self._progress_dialog:
|
||||||
|
self._progress_dialog.close()
|
||||||
|
self._progress_dialog.deleteLater()
|
||||||
|
self._progress_dialog = None
|
||||||
|
|
||||||
|
action_word = "updated" if is_update else "installed"
|
||||||
|
action_verb = "update" if is_update else "install"
|
||||||
|
|
||||||
|
if success:
|
||||||
|
self.manager.reload_plugins()
|
||||||
|
QMessageBox.information(self, "Success", f"Successfully {action_word} '{plugin_name}'!")
|
||||||
|
self.refresh_installed_plugins()
|
||||||
|
if self._has_fetched_remote:
|
||||||
|
self._populate_browser_list()
|
||||||
|
else:
|
||||||
|
QMessageBox.critical(self, "Installation Failed", f"Could not {action_verb} plugin:\n{message}")
|
||||||
|
|
||||||
|
def _on_install_progress(self, downloaded: int, total: int) -> None:
|
||||||
|
if self._progress_dialog:
|
||||||
|
if total > 0:
|
||||||
|
self._progress_dialog.setMaximum(total)
|
||||||
|
self._progress_dialog.setValue(downloaded)
|
||||||
|
mb_down = downloaded / (1024 * 1024)
|
||||||
|
mb_total = total / (1024 * 1024)
|
||||||
|
self._progress_dialog.setLabelText(f"Downloading... ({mb_down:.2f} MB / {mb_total:.2f} MB)")
|
||||||
|
else:
|
||||||
|
self._progress_dialog.setMaximum(0) # Infinite marquee progress if size is unknown
|
||||||
|
mb_down = downloaded / (1024 * 1024)
|
||||||
|
self._progress_dialog.setLabelText(f"Downloading... ({mb_down:.2f} MB downloaded)")
|
||||||
|
|
||||||
|
def _on_install_status(self, message: str) -> None:
|
||||||
|
if self._progress_dialog:
|
||||||
|
self._progress_dialog.setLabelText(message)
|
||||||
|
|
||||||
|
|
||||||
|
def update_selected_plugin(self) -> None:
|
||||||
|
"""Triggers the background update process for the selected installed plugin."""
|
||||||
selected = self.installed_list.currentItem()
|
selected = self.installed_list.currentItem()
|
||||||
if not selected:
|
if not selected:
|
||||||
return
|
return
|
||||||
raw_info = selected.data(Qt.ItemDataRole.UserRole)
|
raw_info = selected.data(Qt.ItemDataRole.UserRole)
|
||||||
if isinstance(raw_info, dict):
|
|
||||||
info = cast(dict[str, Any], raw_info)
|
|
||||||
plugin_path = info.get("path")
|
|
||||||
if isinstance(plugin_path, str) and plugin_path:
|
|
||||||
self.manager.toggle_plugin_state(Path(plugin_path))
|
|
||||||
|
|
||||||
def uninstall_plugin(self) -> None:
|
|
||||||
selected = self.installed_list.currentItem()
|
|
||||||
if not selected:
|
|
||||||
return
|
|
||||||
|
|
||||||
raw_info = selected.data(Qt.ItemDataRole.UserRole)
|
|
||||||
if not isinstance(raw_info, dict):
|
if not isinstance(raw_info, dict):
|
||||||
return
|
return
|
||||||
|
|
||||||
info = cast(dict[str, Any], raw_info)
|
info = cast(dict[str, Any], raw_info)
|
||||||
|
download_url = info.get("download_url", "")
|
||||||
|
plugin_id = info.get("id", "unnamed")
|
||||||
|
plugin_name = info.get("name", "Plugin")
|
||||||
plugin_path = info.get("path")
|
plugin_path = info.get("path")
|
||||||
plugin_name = info.get("name", "this plugin")
|
|
||||||
|
|
||||||
if not isinstance(plugin_path, str) or not plugin_path:
|
if not download_url:
|
||||||
|
QMessageBox.warning(self, "Update Failed", "No download URL provided for this update.")
|
||||||
return
|
return
|
||||||
|
|
||||||
reply = QMessageBox.question(
|
path_obj = Path(plugin_path) if isinstance(plugin_path, str) and plugin_path else (plugin_path if isinstance(plugin_path, Path) else None)
|
||||||
self,
|
self.execute_plugin_installation(download_url, plugin_id, plugin_name, is_update=True, plugin_path=path_obj)
|
||||||
"Confirm Uninstall",
|
|
||||||
f"Are you sure you want to delete '{plugin_name}'?",
|
|
||||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
||||||
)
|
|
||||||
if reply == QMessageBox.StandardButton.Yes:
|
|
||||||
self.manager.uninstall_plugin(Path(plugin_path))
|
|
||||||
|
|
||||||
def fetch_remote_plugins(self) -> None:
|
def install_selected_plugin(self) -> None:
|
||||||
"""Asynchronously fetches remote plugins on a background thread."""
|
"""Triggers the background installation/update process from the browser tab."""
|
||||||
if self._fetch_worker is not None and self._fetch_worker.isRunning():
|
selected = self.browser_list.currentItem()
|
||||||
|
if not selected:
|
||||||
|
return
|
||||||
|
data = selected.data(Qt.ItemDataRole.UserRole)
|
||||||
|
if not isinstance(data, dict):
|
||||||
return
|
return
|
||||||
|
|
||||||
self.browser_list.clear()
|
plugin_data = cast(dict[str, Any], data)
|
||||||
self.browser_list.addItem("Fetching remote repositories...")
|
|
||||||
self.btn_install.setEnabled(False)
|
|
||||||
self.btn_fetch.setEnabled(False)
|
|
||||||
|
|
||||||
# Defer network call to background worker to avoid UI freeze
|
update_available = bool(selected.data(Qt.ItemDataRole.UserRole + 3))
|
||||||
self._fetch_worker = RemoteFetchWorker(self.manager, self.repository_urls)
|
plugin_id = str(plugin_data.get("id", "unnamed"))
|
||||||
self._fetch_worker.fetched.connect(self._on_remote_fetched)
|
plugin_name = str(plugin_data.get("name", "Plugin"))
|
||||||
self._fetch_worker.start()
|
download_url = str(plugin_data.get("download_url", ""))
|
||||||
|
|
||||||
def _on_remote_fetched(self, plugins: list[dict[str, Any]], all_failed: bool) -> None:
|
if not download_url:
|
||||||
|
QMessageBox.warning(self, "Error", "No download URL provided for this plugin.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find existing local path if updating an installed plugin
|
||||||
|
existing_path: Path | None = None
|
||||||
|
installed_info = self.manager.get_installed_plugins_info()
|
||||||
|
for info in installed_info:
|
||||||
|
if info.get("id") == plugin_id or info.get("name") == plugin_name:
|
||||||
|
p = info.get("path")
|
||||||
|
if isinstance(p, Path):
|
||||||
|
existing_path = p
|
||||||
|
elif isinstance(p, str):
|
||||||
|
existing_path = Path(p)
|
||||||
|
break
|
||||||
|
|
||||||
|
self.execute_plugin_installation(download_url, plugin_id, plugin_name, is_update=update_available, plugin_path=existing_path)
|
||||||
|
|
||||||
|
def _on_remote_fetched(self, plugins: list[dict[str, Any]], all_failed: bool, fetch_type: str) -> None:
|
||||||
"""Callback executed on the main UI thread when remote fetching finishes."""
|
"""Callback executed on the main UI thread when remote fetching finishes."""
|
||||||
self.remote_plugins_data = plugins
|
self.remote_plugins_data = plugins
|
||||||
self._has_fetched_remote = True
|
self._has_fetched_remote = True
|
||||||
self.btn_fetch.setEnabled(True)
|
self.btn_fetch.setEnabled(True)
|
||||||
|
|
||||||
|
if fetch_type == "updates":
|
||||||
|
self.refresh_installed_plugins()
|
||||||
|
return
|
||||||
|
|
||||||
if plugins:
|
if plugins:
|
||||||
self._populate_browser_list()
|
self._populate_browser_list()
|
||||||
elif all_failed:
|
elif all_failed:
|
||||||
@@ -200,13 +388,33 @@ class PluginsWindow(QWidget):
|
|||||||
self.browser_list.clear()
|
self.browser_list.clear()
|
||||||
self.browser_list.addItem("No plugins found across configured repositories.")
|
self.browser_list.addItem("No plugins found across configured repositories.")
|
||||||
|
|
||||||
|
def fetch_remote_plugins(self) -> None:
|
||||||
|
"""Asynchronously fetches remote plugins on a background thread for the browser."""
|
||||||
|
if self._fetch_worker is not None and self._fetch_worker.isRunning():
|
||||||
|
return
|
||||||
|
|
||||||
|
self.browser_list.clear()
|
||||||
|
self.browser_list.addItem("Fetching remote repositories...")
|
||||||
|
self.btn_install.setEnabled(False)
|
||||||
|
self.btn_fetch.setEnabled(False)
|
||||||
|
|
||||||
|
self._fetch_worker = RemoteFetchWorker(self.manager, self.repository_urls, fetch_type="browser")
|
||||||
|
self._fetch_worker.fetched.connect(self._on_remote_fetched)
|
||||||
|
self._fetch_worker.start()
|
||||||
|
|
||||||
def _populate_browser_list(self) -> None:
|
def _populate_browser_list(self) -> None:
|
||||||
"""Populates the browser tab list and evaluates compatibility and installation state."""
|
"""Populates the browser tab list and evaluates compatibility, installation, and updates."""
|
||||||
self.browser_list.clear()
|
self.browser_list.clear()
|
||||||
|
|
||||||
installed_info = self.manager.get_installed_plugins_info()
|
installed_info = self.manager.get_installed_plugins_info()
|
||||||
installed_ids = {p.get("id") for p in installed_info if p.get("id")}
|
installed_map: dict[str, dict[str, Any]] = {}
|
||||||
installed_names = {p.get("name") for p in installed_info if p.get("name")}
|
for p in installed_info:
|
||||||
|
p_id = p.get("id")
|
||||||
|
p_name = p.get("name")
|
||||||
|
if p_id:
|
||||||
|
installed_map[str(p_id)] = p
|
||||||
|
if p_name:
|
||||||
|
installed_map[str(p_name)] = p
|
||||||
|
|
||||||
for plugin in self.remote_plugins_data:
|
for plugin in self.remote_plugins_data:
|
||||||
name = plugin.get("name", "Unknown")
|
name = plugin.get("name", "Unknown")
|
||||||
@@ -220,7 +428,6 @@ class PluginsWindow(QWidget):
|
|||||||
is_version_ok = self.manager.current_app_version >= parse_version(min_v_str)
|
is_version_ok = self.manager.current_app_version >= parse_version(min_v_str)
|
||||||
is_compatible = is_platform_ok and is_version_ok
|
is_compatible = is_platform_ok and is_version_ok
|
||||||
|
|
||||||
# Evaluate reasons for incompatibility
|
|
||||||
incompat_reasons: list[str] = []
|
incompat_reasons: list[str] = []
|
||||||
if not is_platform_ok:
|
if not is_platform_ok:
|
||||||
plat_str = ", ".join(platforms) if isinstance(platforms, list) else str(platforms)
|
plat_str = ", ".join(platforms) if isinstance(platforms, list) else str(platforms)
|
||||||
@@ -228,10 +435,20 @@ class PluginsWindow(QWidget):
|
|||||||
if not is_version_ok:
|
if not is_version_ok:
|
||||||
incompat_reasons.append(f"Requires App v{min_v_str}+")
|
incompat_reasons.append(f"Requires App v{min_v_str}+")
|
||||||
|
|
||||||
is_installed = (bool(p_id) and p_id in installed_ids) or (bool(name) and name in installed_names)
|
installed_match = installed_map.get(str(p_id)) or installed_map.get(str(name))
|
||||||
|
is_installed = installed_match is not None
|
||||||
|
|
||||||
|
update_available = False
|
||||||
|
remote_ver_str = str(plugin.get("version", "0.0.0"))
|
||||||
|
if is_installed:
|
||||||
|
local_ver_str = str(installed_match.get("version", "0.0.0"))
|
||||||
|
if parse_version(remote_ver_str) > parse_version(local_ver_str):
|
||||||
|
update_available = True
|
||||||
|
|
||||||
display_text = f"{name} (v{version}) - {desc}"
|
display_text = f"{name} (v{version}) - {desc}"
|
||||||
if is_installed:
|
if update_available:
|
||||||
|
display_text += f" [Update Available: v{remote_ver_str}]"
|
||||||
|
elif is_installed:
|
||||||
display_text += " [Installed]"
|
display_text += " [Installed]"
|
||||||
elif incompat_reasons:
|
elif incompat_reasons:
|
||||||
reason_str = "; ".join(incompat_reasons)
|
reason_str = "; ".join(incompat_reasons)
|
||||||
@@ -241,28 +458,14 @@ class PluginsWindow(QWidget):
|
|||||||
item.setData(Qt.ItemDataRole.UserRole, plugin)
|
item.setData(Qt.ItemDataRole.UserRole, plugin)
|
||||||
item.setData(Qt.ItemDataRole.UserRole + 1, is_compatible)
|
item.setData(Qt.ItemDataRole.UserRole + 1, is_compatible)
|
||||||
item.setData(Qt.ItemDataRole.UserRole + 2, is_installed)
|
item.setData(Qt.ItemDataRole.UserRole + 2, is_installed)
|
||||||
|
item.setData(Qt.ItemDataRole.UserRole + 3, update_available)
|
||||||
|
|
||||||
if is_installed:
|
if update_available:
|
||||||
item.setForeground(QColor("#2e7d32")) # Green for installed
|
item.setForeground(QColor("#1976d2"))
|
||||||
|
elif is_installed:
|
||||||
|
item.setForeground(QColor("#2e7d32"))
|
||||||
elif not is_compatible:
|
elif not is_compatible:
|
||||||
item.setForeground(QColor("#d32f2f")) # Red for incompatible
|
item.setForeground(QColor("#d32f2f"))
|
||||||
|
|
||||||
def install_selected_plugin(self) -> None:
|
|
||||||
selected = self.browser_list.currentItem()
|
|
||||||
if not selected:
|
|
||||||
return
|
|
||||||
data = selected.data(Qt.ItemDataRole.UserRole)
|
|
||||||
if not data:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
target_path = self.manager.install_plugin_from_url(
|
|
||||||
data.get("download_url", ""),
|
|
||||||
data.get("id", "unnamed")
|
|
||||||
)
|
|
||||||
QMessageBox.information(self, "Success", f"Installed plugin to:\n{target_path}")
|
|
||||||
except Exception as e:
|
|
||||||
QMessageBox.critical(self, "Installation Failed", f"Could not install plugin:\n{e}")
|
|
||||||
|
|
||||||
def _on_tab_changed(self, index: int) -> None:
|
def _on_tab_changed(self, index: int) -> None:
|
||||||
if index == 1 and not self._has_fetched_remote:
|
if index == 1 and not self._has_fetched_remote:
|
||||||
@@ -272,10 +475,19 @@ class PluginsWindow(QWidget):
|
|||||||
if current:
|
if current:
|
||||||
is_compatible = bool(current.data(Qt.ItemDataRole.UserRole + 1))
|
is_compatible = bool(current.data(Qt.ItemDataRole.UserRole + 1))
|
||||||
is_installed = bool(current.data(Qt.ItemDataRole.UserRole + 2))
|
is_installed = bool(current.data(Qt.ItemDataRole.UserRole + 2))
|
||||||
|
update_available = bool(current.data(Qt.ItemDataRole.UserRole + 3))
|
||||||
|
|
||||||
# Button lights up ONLY if plugin is compatible and NOT yet installed
|
if update_available:
|
||||||
self.btn_install.setEnabled(is_compatible and not is_installed)
|
self.btn_install.setText("Update Plugin")
|
||||||
|
self.btn_install.setEnabled(is_compatible)
|
||||||
|
elif is_installed:
|
||||||
|
self.btn_install.setText("Install Plugin")
|
||||||
|
self.btn_install.setEnabled(False)
|
||||||
else:
|
else:
|
||||||
|
self.btn_install.setText("Install Plugin")
|
||||||
|
self.btn_install.setEnabled(is_compatible)
|
||||||
|
else:
|
||||||
|
self.btn_install.setText("Install Plugin")
|
||||||
self.btn_install.setEnabled(False)
|
self.btn_install.setEnabled(False)
|
||||||
|
|
||||||
def _on_add_repo(self) -> None:
|
def _on_add_repo(self) -> None:
|
||||||
@@ -334,14 +546,19 @@ class PluginsWindow(QWidget):
|
|||||||
|
|
||||||
btn_layout = QHBoxLayout()
|
btn_layout = QHBoxLayout()
|
||||||
btn_refresh = QPushButton("Refresh List", tab)
|
btn_refresh = QPushButton("Refresh List", tab)
|
||||||
|
self.btn_update_plugin = QPushButton("Update Plugin", tab)
|
||||||
|
self.btn_update_plugin.setEnabled(False)
|
||||||
|
self.btn_update_plugin.setStyleSheet("font-weight: bold; color: #1976d2;")
|
||||||
btn_enable = QPushButton("Enable / Disable", tab)
|
btn_enable = QPushButton("Enable / Disable", tab)
|
||||||
btn_uninstall = QPushButton("Uninstall", tab)
|
btn_uninstall = QPushButton("Uninstall", tab)
|
||||||
|
|
||||||
btn_refresh.clicked.connect(self.refresh_installed_plugins)
|
btn_refresh.clicked.connect(self.refresh_installed_plugins)
|
||||||
|
self.btn_update_plugin.clicked.connect(self.update_selected_plugin)
|
||||||
btn_enable.clicked.connect(self.toggle_enable_disable)
|
btn_enable.clicked.connect(self.toggle_enable_disable)
|
||||||
btn_uninstall.clicked.connect(self.uninstall_plugin)
|
btn_uninstall.clicked.connect(self.uninstall_plugin)
|
||||||
|
|
||||||
btn_layout.addWidget(btn_refresh)
|
btn_layout.addWidget(btn_refresh)
|
||||||
|
btn_layout.addWidget(self.btn_update_plugin)
|
||||||
btn_layout.addStretch()
|
btn_layout.addStretch()
|
||||||
btn_layout.addWidget(btn_enable)
|
btn_layout.addWidget(btn_enable)
|
||||||
btn_layout.addWidget(btn_uninstall)
|
btn_layout.addWidget(btn_uninstall)
|
||||||
@@ -389,3 +606,53 @@ class PluginsWindow(QWidget):
|
|||||||
layout.addWidget(self.browser_list)
|
layout.addWidget(self.browser_list)
|
||||||
layout.addLayout(btn_layout)
|
layout.addLayout(btn_layout)
|
||||||
return tab
|
return tab
|
||||||
|
|
||||||
|
def toggle_enable_disable(self) -> None:
|
||||||
|
selected = self.installed_list.currentItem()
|
||||||
|
if not selected:
|
||||||
|
return
|
||||||
|
raw_info = selected.data(Qt.ItemDataRole.UserRole)
|
||||||
|
if isinstance(raw_info, dict):
|
||||||
|
info = cast(dict[str, Any], raw_info)
|
||||||
|
plugin_path = info.get("path")
|
||||||
|
if isinstance(plugin_path, str) and plugin_path:
|
||||||
|
self.manager.toggle_plugin_state(Path(plugin_path))
|
||||||
|
elif isinstance(plugin_path, Path):
|
||||||
|
self.manager.toggle_plugin_state(plugin_path)
|
||||||
|
|
||||||
|
def uninstall_plugin(self) -> None:
|
||||||
|
selected = self.installed_list.currentItem()
|
||||||
|
if not selected:
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_info = selected.data(Qt.ItemDataRole.UserRole)
|
||||||
|
if not isinstance(raw_info, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
info = cast(dict[str, Any], raw_info)
|
||||||
|
plugin_path = info.get("path")
|
||||||
|
plugin_name = info.get("name", "this plugin")
|
||||||
|
|
||||||
|
if not plugin_path:
|
||||||
|
return
|
||||||
|
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Confirm Uninstall",
|
||||||
|
f"Are you sure you want to delete '{plugin_name}'?",
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
|
)
|
||||||
|
if reply == QMessageBox.StandardButton.Yes:
|
||||||
|
if isinstance(plugin_path, str):
|
||||||
|
self.manager.uninstall_plugin(Path(plugin_path))
|
||||||
|
elif isinstance(plugin_path, Path):
|
||||||
|
self.manager.uninstall_plugin(plugin_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_details_panel(self) -> None:
|
||||||
|
"""Clears details display when nothing is selected."""
|
||||||
|
self.lbl_plugin_title.setText("Select a plugin")
|
||||||
|
self.lbl_plugin_meta.setText("")
|
||||||
|
self.lbl_plugin_status.setText("")
|
||||||
|
self.txt_plugin_desc.clear()
|
||||||
|
self.lbl_plugin_path.setText("")
|
||||||
@@ -64,7 +64,7 @@ class ViewerLauncherWidget(QWidget):
|
|||||||
("Inter-Group Stats Viewer", InterGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, roi_channel_map_dict, group_dict], True),
|
("Inter-Group Stats Viewer", InterGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, roi_channel_map_dict, group_dict], True),
|
||||||
("Intra-Group Brain and Image Viewer", IntraGroupBrainImageWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True),
|
("Intra-Group Brain and Image Viewer", IntraGroupBrainImageWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True),
|
||||||
("Inter-Group Brain and Image Viewer", InterGroupBrainImageWidget, [haemo_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True),
|
("Inter-Group Brain and Image Viewer", InterGroupBrainImageWidget, [haemo_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True),
|
||||||
("Export To CSV Viewer", ExportToCSVWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict, config_dict, fir_feature_dict, qc_dict], True)
|
("Export To CSV Viewer", ExportToCSVWidget, [haemo_dict, epochs_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict, config_dict, fir_feature_dict, qc_dict], True)
|
||||||
]
|
]
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
|
|||||||
Reference in new issue
Block a user