drag+drop support

This commit is contained in:
2026-09-10 11:18:00 -07:00
parent cd0b55df34
commit 613d2b9103
4 changed files with 162 additions and 15 deletions
+3
View File
@@ -1,11 +1,14 @@
# Version 1.7.1 # Version 1.7.1
- Added support for Plugins! Plugins can be downloaded from the official repository or from custom ones. Support for creating a plugin can be found on the wiki - Added support for Plugins! Plugins can be downloaded from the official repository or from custom ones. Support for creating a plugin can be found on the wiki
- Files and folders can now be dragged and dropped into the loaded files area to be loaded instead of relying on the file menu
- Fixed an issue where shadow snirf files would attempt to be loaded by the application. Fixes [Issue 92](https://git.research.dezeeuw.ca/tyler/flares/issues/92)
- Fixed an issue where the application could not automatically update between versions 1.5.1 to 1.7.1. Sorry! 1.7.1 upgrading onward should be fixed and more robust - Fixed an issue where the application could not automatically update between versions 1.5.1 to 1.7.1. Sorry! 1.7.1 upgrading onward should be fixed and more robust
- Fixed an issue where some log files would not generate, or would generate in an incorrect location - Fixed an issue where some log files would not generate, or would generate in an incorrect location
- Fixed an issue where updating events in a snirf file would not release memory properly - Fixed an issue where updating events in a snirf file would not release memory properly
- Fixed an issue where loading multiple files in quick succession could cause some of the files to never finish loading - Fixed an issue where loading multiple files in quick succession could cause some of the files to never finish loading
- Fixed an issue where loading a saved project, pushing clear, and re-opening snirf files would cause them to appear incorrectly in the loaded files area - Fixed an issue where loading a saved project, pushing clear, and re-opening snirf files would cause them to appear incorrectly in the loaded files area
- Fixed a crucial bug where short channels were still being sent to the donor pool to assist in interpolating long channels from. Fixes [Issue 80](https://git.research.dezeeuw.ca/tyler/flares/issues/80)
# Version 1.7.0 # Version 1.7.0
+4 -7
View File
@@ -1115,10 +1115,10 @@ def interpolate_fNIRS_bads_weighted_average(raw, max_dist=0.03, min_neighbors=2,
is_bad = (hbo_ch in raw.info['bads']) or (hbr_ch in raw.info['bads']) is_bad = (hbo_ch in raw.info['bads']) or (hbr_ch in raw.info['bads'])
is_short = pair_distances[i] < short_channels_threshold is_short = pair_distances[i] < short_channels_threshold
if is_bad: if is_short:
bad_pairs.append(i)
elif is_short:
n_short_excluded += 1 n_short_excluded += 1
elif is_bad:
bad_pairs.append(i)
else: else:
good_pairs.append(i) good_pairs.append(i)
@@ -1182,7 +1182,7 @@ def interpolate_fNIRS_bads_weighted_average(raw, max_dist=0.03, min_neighbors=2,
constrained_layout=True) constrained_layout=True)
if n_bad == 1: axes = [axes] # Handle single subplot case if n_bad == 1: axes = [axes] # Handle single subplot case
axes_flat = np.asarray(axes).get_data().flatten() if hasattr(axes, 'get_data') else np.asarray(axes).ravel() axes_flat = np.asarray(axes).ravel()
for j in range(n_bad, len(axes_flat)): for j in range(n_bad, len(axes_flat)):
if j >= n_bad: if j >= n_bad:
axes_flat[j].axis('off') axes_flat[j].axis('off')
@@ -1222,9 +1222,6 @@ def interpolate_fNIRS_bads_weighted_average(raw, max_dist=0.03, min_neighbors=2,
print("Bads cleared:", raw.info['bads']) print("Bads cleared:", raw.info['bads'])
raw.info['bads'] = [] raw.info['bads'] = []
for ch in raw.info['bads']:
print(f"Channel {ch} still marked as bad.")
fig_raw_after = raw.plot(duration=raw.times[-1], n_channels=raw.info['nchan'], title="After interpolation", show=False) fig_raw_after = raw.plot(duration=raw.times[-1], n_channels=raw.info['nchan'], title="After interpolation", show=False)
return raw, fig_raw_after, fig_compare return raw, fig_raw_after, fig_compare
+50 -1
View File
@@ -22,6 +22,10 @@ from datetime import datetime
from functools import partial from functools import partial
from multiprocessing import Process, current_process, freeze_support, Queue, set_start_method from multiprocessing import Process, current_process, freeze_support, Queue, set_start_method
# Fix for plotting on linux
if sys.platform.startswith("linux"):
os.environ["QT_QPA_PLATFORM"] = "xcb"
# External library imports # External library imports
import psutil import psutil
@@ -535,6 +539,49 @@ class ThemeChangeWatcher(QObject):
self.main_window.update_theme() self.main_window.update_theme()
class DropScrollArea(QScrollArea):
"""QScrollArea that accepts .snirf file and folder drops."""
def __init__(self, on_files_dropped_callback, parent=None):
super().__init__(parent)
self.on_files_dropped_callback = on_files_dropped_callback
self.setAcceptDrops(True)
# ScrollAreas require setting acceptDrops on their viewport as well
self.viewport().setAcceptDrops(True)
def dragEnterEvent(self, event) -> None:
if event.mimeData().hasUrls():
event.acceptProposedAction()
else:
event.ignore()
def dropEvent(self, event) -> None:
if not event.mimeData().hasUrls():
return
dropped_paths = [
url.toLocalFile() for url in event.mimeData().urls() if url.isLocalFile()
]
files_to_load = []
for path_str in dropped_paths:
p = Path(path_str)
if p.is_file() and p.suffix.lower() == ".snirf":
files_to_load.append(os.path.normpath(str(p)))
elif p.is_dir():
files_to_load.extend(
os.path.normpath(str(f)) for f in p.rglob("*.snirf")
)
if files_to_load:
self.on_files_dropped_callback(files_to_load)
else:
QMessageBox.information(
self,
"No Valid Files",
"None of the dropped items were .snirf files or directories containing .snirf files.",
)
class MainApplication(QMainWindow): class MainApplication(QMainWindow):
""" """
Main application window that creates and sets up the UI. Main application window that creates and sets up the UI.
@@ -715,7 +762,9 @@ class MainApplication(QMainWindow):
self.bubble_layout = QGridLayout(self.bubble_container) self.bubble_layout = QGridLayout(self.bubble_container)
self.bubble_layout.setAlignment(Qt.AlignmentFlag.AlignTop) self.bubble_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_area = QScrollArea() self.scroll_area = DropScrollArea(
on_files_dropped_callback=self.project_manager.load_dropped_files
)
self.scroll_area.setWidgetResizable(True) self.scroll_area.setWidgetResizable(True)
self.scroll_area.setWidget(self.bubble_container) self.scroll_area.setWidget(self.bubble_container)
self.scroll_area.setMinimumHeight(200) self.scroll_area.setMinimumHeight(200)
+105 -7
View File
@@ -108,21 +108,116 @@ class ProjectManager:
# ========================================================================= # =========================================================================
# File & Folder Opening Dialogs # File & Folder Opening Dialogs
# ========================================================================= # =========================================================================
def is_valid_snirf(self, path: str) -> bool:
"""Fast header check to verify HDF5/SNIRF signature and ignore corrupt/shadow files."""
try:
if os.path.getsize(path) < 8:
return False
with open(path, "rb") as f:
return f.read(8) == b"\x89HDF\r\n\x1a\n"
except Exception:
return False
def open_file_dialog(self) -> None: def open_file_dialog(self) -> None:
"""Opens dialog to pick a single .snirf file.""" """Opens dialog to pick a single .snirf file."""
file_path, _ = QFileDialog.getOpenFileName( file_path, _ = QFileDialog.getOpenFileName(
self.app, "Open File", "", "SNIRF Files (*.snirf);;All Files (*)" self.app, "Open File", "", "SNIRF Files (*.snirf);;All Files (*)"
) )
if file_path: if not file_path:
self._load_files_into_pipeline([os.path.normpath(file_path)]) return
norm_path = os.path.normpath(file_path)
filename = os.path.basename(norm_path)
# 1. Check specifically for macOS shadow files
if filename.startswith("._"):
QMessageBox.warning(
self.app,
"Invalid SNIRF File",
f"'{filename}' is a macOS system shadow file (Apple Double resource fork), not a actual data file."
)
return
# 2. Check for general header validity / corruption
if not self.is_valid_snirf(norm_path):
QMessageBox.warning(
self.app,
"Invalid SNIRF File",
f"'{filename}' is not a valid SNIRF file or has a corrupted header."
)
return
self._load_files_into_pipeline([norm_path])
def open_folder_dialog(self)-> None: def open_folder_dialog(self)-> None:
"""Recursively finds all .snirf files in a selected directory.""" """Recursively finds all .snirf files in a selected directory."""
folder_path = QFileDialog.getExistingDirectory(self.app, "Select Folder", "") folder_path = QFileDialog.getExistingDirectory(self.app, "Select Folder", "")
if folder_path: if not folder_path:
snirf_files = [os.path.normpath(str(f)) for f in Path(folder_path).rglob("*.snirf")] return
self._load_files_into_pipeline(snirf_files)
# Automatically filter out shadow files and invalid headers in batch mode
snirf_files = [
os.path.normpath(str(f))
for f in Path(folder_path).rglob("*.snirf")
if self.is_valid_snirf(str(f))
]
if not snirf_files:
QMessageBox.information(
self.app,
"No Valid Files",
"No valid .snirf files were found in the selected directory."
)
return
def load_dropped_files(self, file_paths: List[str]) -> None:
"""Public entry point for handling files or folders dropped onto the UI."""
if not file_paths:
return
# 1. Single file drop: Give explicit feedback like open_file_dialog
if len(file_paths) == 1:
norm_path = os.path.normpath(file_paths[0])
filename = os.path.basename(norm_path)
if filename.startswith("._"):
QMessageBox.warning(
self.app,
"Invalid SNIRF File",
f"'{filename}' is a macOS system shadow file (Apple Double resource fork), not an actual data file."
)
return
if not self.is_valid_snirf(norm_path):
QMessageBox.warning(
self.app,
"Invalid SNIRF File",
f"'{filename}' is not a valid SNIRF file or has a corrupted header."
)
return
valid_files = [norm_path]
# 2. Batch drop: Silently filter out shadow/corrupt files like open_folder_dialog
else:
valid_files = [
os.path.normpath(p)
for p in file_paths
if self.is_valid_snirf(os.path.normpath(p))
]
if not valid_files:
QMessageBox.information(
self.app,
"No Valid Files",
"None of the dropped items were valid .snirf files."
)
return
# Delegate to internal pipeline
self._load_files_into_pipeline(valid_files)
def _load_files_into_pipeline(self, file_paths: List[str]) -> None: def _load_files_into_pipeline(self, file_paths: List[str]) -> None:
"""Loads .snirf files into UI using chunked batches and background workers.""" """Loads .snirf files into UI using chunked batches and background workers."""
app = self.app app = self.app
@@ -146,8 +241,11 @@ class ProjectManager:
if not hasattr(app, "metadata_cache"): if not hasattr(app, "metadata_cache"):
app.metadata_cache = {} app.metadata_cache = {}
# Filter out files already in the UI to avoid duplicates # Filter out duplicates AND non-SNIRF files (including macOS ._ shadow files)
new_files = [p for p in file_paths if p not in app.selected_paths] new_files = [
p for p in file_paths
if p not in app.selected_paths and self.is_valid_snirf(p)
]
if not new_files: if not new_files:
return return