drag+drop support
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
# Version 1.7.1
|
||||
|
||||
- Added support for Plugins! Plugins can be downloaded from the official repository or from custom ones. Support for creating a plugin can be found on the wiki
|
||||
- Files and folders can now be dragged and dropped into the loaded files area to be loaded instead of relying on the file menu
|
||||
- Fixed an issue where shadow snirf files would attempt to be loaded by the application. Fixes [Issue 92](https://git.research.dezeeuw.ca/tyler/flares/issues/92)
|
||||
- Fixed an issue where the application could not automatically update between versions 1.5.1 to 1.7.1. Sorry! 1.7.1 upgrading onward should be fixed and more robust
|
||||
- Fixed an issue where some log files would not generate, or would generate in an incorrect location
|
||||
- Fixed an issue where updating events in a snirf file would not release memory properly
|
||||
- Fixed an issue where loading multiple files in quick succession could cause some of the files to never finish loading
|
||||
- Fixed an issue where loading a saved project, pushing clear, and re-opening snirf files would cause them to appear incorrectly in the loaded files area
|
||||
- Fixed a crucial bug where short channels were still being sent to the donor pool to assist in interpolating long channels from. Fixes [Issue 80](https://git.research.dezeeuw.ca/tyler/flares/issues/80)
|
||||
|
||||
|
||||
# Version 1.7.0
|
||||
|
||||
@@ -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_short = pair_distances[i] < short_channels_threshold
|
||||
|
||||
if is_bad:
|
||||
bad_pairs.append(i)
|
||||
elif is_short:
|
||||
if is_short:
|
||||
n_short_excluded += 1
|
||||
elif is_bad:
|
||||
bad_pairs.append(i)
|
||||
else:
|
||||
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)
|
||||
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)):
|
||||
if j >= n_bad:
|
||||
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'])
|
||||
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)
|
||||
|
||||
return raw, fig_raw_after, fig_compare
|
||||
|
||||
@@ -22,6 +22,10 @@ from datetime import datetime
|
||||
from functools import partial
|
||||
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
|
||||
import psutil
|
||||
|
||||
@@ -535,6 +539,49 @@ class ThemeChangeWatcher(QObject):
|
||||
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):
|
||||
"""
|
||||
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.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.setWidget(self.bubble_container)
|
||||
self.scroll_area.setMinimumHeight(200)
|
||||
|
||||
+105
-7
@@ -108,20 +108,115 @@ class ProjectManager:
|
||||
# =========================================================================
|
||||
# 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:
|
||||
"""Opens dialog to pick a single .snirf file."""
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self.app, "Open File", "", "SNIRF Files (*.snirf);;All Files (*)"
|
||||
)
|
||||
if file_path:
|
||||
self._load_files_into_pipeline([os.path.normpath(file_path)])
|
||||
if not 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:
|
||||
"""Recursively finds all .snirf files in a selected directory."""
|
||||
folder_path = QFileDialog.getExistingDirectory(self.app, "Select Folder", "")
|
||||
if folder_path:
|
||||
snirf_files = [os.path.normpath(str(f)) for f in Path(folder_path).rglob("*.snirf")]
|
||||
self._load_files_into_pipeline(snirf_files)
|
||||
if not folder_path:
|
||||
return
|
||||
|
||||
# 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:
|
||||
"""Loads .snirf files into UI using chunked batches and background workers."""
|
||||
@@ -146,8 +241,11 @@ class ProjectManager:
|
||||
if not hasattr(app, "metadata_cache"):
|
||||
app.metadata_cache = {}
|
||||
|
||||
# Filter out files already in the UI to avoid duplicates
|
||||
new_files = [p for p in file_paths if p not in app.selected_paths]
|
||||
# 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 and self.is_valid_snirf(p)
|
||||
]
|
||||
if not new_files:
|
||||
return
|
||||
|
||||
|
||||
Reference in New Issue
Block a user