drag+drop support
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user