ui.workspace_tree_view
1import os 2import re 3 4from PyQt6 import QtWidgets, QtCore 5from PyQt6.QtCore import QUrl, pyqtSignal 6from PyQt6.QtWidgets import QTreeView, QMessageBox, QMenu, QFileDialog 7from PyQt6.QtGui import QFileSystemModel, QDesktopServices, QAction 8 9from components.file_role_dialog import FileRoleDialog 10from logger import get_logger 11from threads.utils_threads import CopyDeleteThread 12 13log = get_logger() 14 15 16class WorkspaceTreeView(QTreeView): 17 """ 18 Custom QTreeView to manage files and folders within a workspace. 19 20 This widget: 21 - Displays the workspace directory structure using QFileSystemModel. 22 - Handles context menus for file/folder operations (add, remove, export, open). 23 - Supports multiple file selection and drag/drop-like functionality via threads. 24 - Integrates with the main application context for shared signals and callbacks. 25 """ 26 27 # Signal emitted when a background thread (Copy/Delete) is created 28 new_thread = pyqtSignal(object) 29 """**Signal(object):** Emitted when a new background thread (e.g., for copy or delete operations) is created. 30 Parameter: 31 - `object`: Reference to the created thread instance. 32 """ 33 34 def __init__(self, context): 35 super().__init__() 36 37 # Context dictionary (shared among pages/windows) 38 self.context = context 39 self.workspace_path = self.context["workspace_path"] 40 41 self.selected_files = [] 42 43 # --- File System Model setup --- 44 self.tree_model = QFileSystemModel() 45 self.tree_model.setRootPath(self.workspace_path) 46 47 # Apply model to the tree view 48 self.setModel(self.tree_model) 49 self.setRootIndex(self.tree_model.index(self.workspace_path)) 50 self.setMinimumSize(QtCore.QSize(200, 0)) 51 52 # Enable extended selection (multi-selection) 53 self.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection) 54 55 # Enable custom context menu 56 self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) 57 58 # --- Signal connections --- 59 self.clicked.connect(self.handle_workspace_click) 60 self.doubleClicked.connect(self.handle_double_click) 61 self.customContextMenuRequested.connect(self.open_tree_context_menu) 62 63 # --------------------------------------------------------------- 64 # Event Handlers 65 # --------------------------------------------------------------- 66 67 def handle_workspace_click(self): 68 """Handle single-click event: update selected files and emit them via context signal.""" 69 selected_indexes = self.selectionModel().selectedRows() 70 selected_files = [self.tree_model.filePath(idx) for idx in selected_indexes] 71 72 self.selected_files = selected_files 73 self.context["selected_files_signal"].emit(selected_files) 74 75 def handle_double_click(self, index): 76 """ 77 Handle double-click event: 78 - If the selected item is a NIfTI file (.nii / .nii.gz), open it in the viewer. 79 - Otherwise, open it with the system's default application. 80 """ 81 file_path = self.tree_model.filePath(index) 82 if not os.path.isfile(file_path): 83 return 84 85 if file_path.endswith((".nii", ".nii.gz")): 86 try: 87 self.context["open_nifti_viewer"](file_path) 88 except Exception as e: 89 QMessageBox.critical( 90 self, 91 QtCore.QCoreApplication.translate("TreeView", "Error"), 92 QtCore.QCoreApplication.translate("TreeView", "Error when opening NIfTI file:\n{0}").format(str(e)) 93 ) 94 log.error(f"Error opening NIfTI file:\n{str(e)}") 95 else: 96 self._open_in_explorer(file_path) 97 98 def adjust_tree_columns(self): 99 """Automatically hide or show extra columns based on the widget width.""" 100 width = self.width() 101 for i in range(1, self.tree_model.columnCount()): 102 if width > 400: 103 self.showColumn(i) 104 self.setColumnWidth(i, 100) 105 else: 106 self.hideColumn(i) 107 108 # --------------------------------------------------------------- 109 # Context Menu 110 # --------------------------------------------------------------- 111 112 def open_tree_context_menu(self, position): 113 """ 114 Open the right-click context menu dynamically, depending on the selected item: 115 - Workspace root 116 - Folder 117 - File 118 - Multiple files 119 """ 120 index = self.indexAt(position) 121 file_path = None 122 is_dir = False 123 is_nifty = False 124 125 # Determine selected file/folder 126 if self.selected_files and len(self.selected_files) == 1 and not index.isValid(): 127 file_path = self.selected_files[0] 128 is_dir = self.tree_model.isDir(index) if index.isValid() else False 129 is_nifty = file_path.endswith((".nii", ".nii.gz")) 130 elif index.isValid(): 131 file_path = self.tree_model.filePath(index) 132 is_dir = self.tree_model.isDir(index) 133 134 menu = QMenu(self) 135 actions = {} 136 137 # Choose menu layout based on selection 138 if not index.isValid() and not self.selected_files: 139 actions.update(self._add_workspace_actions(menu)) 140 elif index.isValid() and file_path and len(self.selected_files) <= 1: 141 if is_dir: 142 actions.update(self._add_folder_actions(menu, file_path)) 143 else: 144 actions.update(self._add_file_actions(menu, file_path, is_nifty)) 145 elif index.isValid() and len(self.selected_files) > 1: 146 actions.update(self._add_multi_file_actions(menu)) 147 148 # Execute chosen action 149 chosen_action = menu.exec(self.viewport().mapToGlobal(position)) 150 if chosen_action and chosen_action in actions: 151 actions[chosen_action](file_path, is_dir, is_nifty) 152 153 # --- Context Menu Definitions --- 154 155 def _add_workspace_actions(self, menu): 156 """Add actions available at the root workspace level.""" 157 open_in_explorer = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Open workspace in explorer")) 158 menu.addSeparator() 159 export_workspace = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Export workspace")) 160 clear_workspace = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Clear workspace")) 161 menu.addSeparator() 162 add_single_file = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Add single file to workspace")) 163 164 return { 165 open_in_explorer: lambda *_: self._open_in_explorer(self.workspace_path), 166 export_workspace: lambda *_: self.export_files([self.workspace_path], True), 167 clear_workspace: lambda *_: self.context["main_window"].clear_folder( 168 folder_path=self.workspace_path, folder_name="workspace", return_to_import=True 169 ), 170 add_single_file: lambda *_: self.add_file_to_workspace(None, False), 171 } 172 173 def _add_folder_actions(self, menu, file_path): 174 """Add actions for a folder.""" 175 open_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Open folder in explorer")) 176 menu.addSeparator() 177 add_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Add single file to folder")) 178 remove_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Remove folder from workspace")) 179 export_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Export folder")) 180 181 return { 182 open_action: lambda *_: self._open_in_explorer(file_path), 183 add_action: lambda *_: self.add_file_to_workspace(file_path, True), 184 remove_action: lambda *_: self.remove_from_workspace([file_path]), 185 export_action: lambda *_: self.export_files([file_path], True), 186 } 187 188 def _add_file_actions(self, menu, file_path, is_nifty): 189 """Add actions for a single file.""" 190 open_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Open with system predefined")) 191 actions = {open_action: lambda *_: self._open_in_explorer(file_path)} 192 193 if is_nifty: 194 nifti_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Open NIfTI file")) 195 actions[nifti_action] = lambda *_: self._open_nifti(file_path) 196 197 menu.addSeparator() 198 add_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Add single file")) 199 remove_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Remove file from workspace")) 200 export_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Export file")) 201 202 actions.update({ 203 add_action: lambda *_: self.add_file_to_workspace(file_path, False), 204 remove_action: lambda *_: self.remove_from_workspace([file_path]), 205 export_action: lambda *_: self.export_files([file_path], False), 206 }) 207 return actions 208 209 def _add_multi_file_actions(self, menu): 210 """Add actions for multiple selected files.""" 211 export_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Export files")) 212 remove_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Remove Files from workspace")) 213 return { 214 export_action: lambda *_: self.export_files(self.selected_files, False), 215 remove_action: lambda *_: self.remove_from_workspace(self.selected_files), 216 } 217 218 # --------------------------------------------------------------- 219 # Core Operations 220 # --------------------------------------------------------------- 221 222 def add_file_to_workspace(self, folder_path, is_dir): 223 """ 224 Add a single file into the workspace. 225 Opens a file dialog, optionally adds related JSON, and triggers background copy thread. 226 """ 227 dialog = QFileDialog(self.context['main_window'], "Select File") 228 dialog.setFileMode(QFileDialog.FileMode.ExistingFile) 229 dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True) 230 dialog.setOption(QFileDialog.Option.ReadOnly, True) 231 dialog.setDirectory(os.path.expanduser("~")) 232 233 if not dialog.exec(): 234 return 235 236 file = dialog.selectedFiles()[0] 237 file_name = os.path.basename(file) 238 dir_name = os.path.dirname(file) 239 name, ext = os.path.splitext(file_name) 240 json_file = None 241 242 # Handle potential .nii.gz format 243 if ext == ".gz": 244 name2, ext2 = os.path.splitext(name) 245 if ext2 == ".nii": 246 json_file = self._including_json(name2, dir_name) 247 elif ext == ".nii": 248 json_file = self._including_json(name, dir_name) 249 250 # Determine destination folder 251 if folder_path: 252 if is_dir: 253 folder = os.path.basename(folder_path) 254 else: 255 folder = os.path.dirname(folder_path) 256 else: 257 folder = "workspace" 258 259 # Specialized behavior based on folder type 260 if folder in ("anat", "pet"): 261 self.new_thread.emit(CopyDeleteThread(src=file, dst=folder_path, is_folder=is_dir, copy=True)) 262 elif re.match(r"^ses-\d+$", folder): 263 self.new_thread.emit(CopyDeleteThread(src=file, dst=os.path.join(folder_path, "pet"), is_folder=is_dir, copy=True)) 264 elif re.match(r"^sub-\d+$", folder): 265 return self.open_role_dialog(files=[file, json_file], folder_path=folder_path, subj=folder) 266 elif folder == "derivatives": 267 return self.open_role_dialog(files=[file, json_file], folder_path=folder_path, main=folder) 268 else: 269 return self.open_role_dialog(files=[file, json_file], folder_path=self.workspace_path) 270 271 def open_role_dialog(self, files, folder_path=None, subj=None, role=None, main=None): 272 """Open a FileRoleDialog to determine where to place files in the workspace hierarchy.""" 273 dialog = FileRoleDialog(workspace_path=self.workspace_path, subj=subj, role=role, main=main, parent=self) 274 if dialog.exec(): 275 relative_path = dialog.get_relative_path() 276 path = os.path.join(folder_path, relative_path) 277 os.makedirs(path, exist_ok=True) 278 for file in files: 279 if file: 280 self.new_thread.emit(CopyDeleteThread(src=file, dst=path, is_folder=False, copy=True)) 281 282 def _including_json(self, file_name, file_dir): 283 """ 284 Ask the user whether to include an associated JSON file when adding a NIfTI file. 285 Returns JSON path if available and confirmed, otherwise empty string. 286 """ 287 answer = QMessageBox.information( 288 self, 289 QtCore.QCoreApplication.translate("TreeView", "Adding Json?"), 290 QtCore.QCoreApplication.translate("TreeView", "Do you want to include the JSON file if present?"), 291 QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel 292 ) 293 if answer == QMessageBox.StandardButton.Ok: 294 json_file_path = os.path.join(file_dir, file_name + ".json") 295 if os.path.isfile(json_file_path): 296 return json_file_path 297 return "" 298 299 # --------------------------------------------------------------- 300 # Export / Remove / Open 301 # --------------------------------------------------------------- 302 303 304 def export_files(self, paths, is_dir): 305 """Export selected files or folders to a chosen destination.""" 306 if not paths: 307 return 308 309 def _get_file_filter(ext): 310 """Return the QFileDialog filter based on file extension.""" 311 if ext == ".nii.gz": 312 return "NIfTI compressed (*.nii.gz);;NIfTI (*.nii);;All files (*.*)" 313 elif ext == ".nii": 314 return "NIfTI (*.nii);;All files (*.*)" 315 elif ext == ".json": 316 return "JSON (*.json);;All files (*.*)" 317 return "All files (*.*)" 318 319 def _get_json_file(path): 320 """Return associated JSON file if it exists.""" 321 base, ext = os.path.splitext(path) 322 if ext == ".gz" and base.endswith(".nii"): 323 base2 = base[:-4] # remove .nii 324 json_file = os.path.join(os.path.dirname(path), base2 + ".json") 325 return json_file if os.path.exists(json_file) else None 326 elif ext == ".nii": 327 json_file = os.path.join(os.path.dirname(path), base + ".json") 328 return json_file if os.path.exists(json_file) else None 329 return None 330 331 if len(paths) == 1: 332 path = paths[0] 333 if is_dir: 334 # Choose destination folder for a single directory 335 dst_path = QFileDialog.getExistingDirectory(self, "Select destination folder") 336 if dst_path: 337 dst_path = os.path.join(dst_path, os.path.basename(path)) 338 self.new_thread.emit(CopyDeleteThread(src=path, dst=dst_path, is_folder=True, copy=True)) 339 else: 340 # Single file export 341 file_filter = _get_file_filter(os.path.splitext(path)[1]) 342 dst_path, _ = QFileDialog.getSaveFileName(self, "Save file as...", os.path.basename(path), file_filter) 343 if not dst_path: 344 return 345 346 self.new_thread.emit(CopyDeleteThread(src=path, dst=dst_path, is_folder=False, copy=True)) 347 348 # Copy associated JSON if exists 349 json_file = _get_json_file(path) 350 if json_file: 351 dst_json = os.path.splitext(dst_path)[0] + ".json" 352 self.new_thread.emit(CopyDeleteThread(src=json_file, dst=dst_json, is_folder=False, copy=True)) 353 else: 354 # Multiple files/folders 355 dst_dir = QFileDialog.getExistingDirectory(self, "Select destination folder") 356 if not dst_dir: 357 return 358 359 for path in paths: 360 if not os.path.exists(path): 361 continue 362 363 is_folder = os.path.isdir(path) 364 dst_path = os.path.join(dst_dir, os.path.basename(path)) if is_folder else os.path.join(dst_dir, 365 os.path.basename( 366 path)) 367 self.new_thread.emit(CopyDeleteThread(src=path, dst=dst_path, is_folder=is_folder, copy=True)) 368 369 def remove_from_workspace(self, paths): 370 """Ask confirmation before deleting selected files/folders from workspace.""" 371 for path in paths: 372 if not os.path.exists(path): 373 return 374 if len(paths)<2: 375 is_dir = os.path.isdir(paths[0]) 376 item_type = QtCore.QCoreApplication.translate("TreeView", "this folder: ") if is_dir else QtCore.QCoreApplication.translate("TreeView", "this file:") 377 else: item_type = QtCore.QCoreApplication.translate("TreeView", "these files") 378 379 item_name = os.path.basename(paths[0]) if len(paths) == 1 else "" 380 message = QtCore.QCoreApplication.translate("TreeView", "Are you sure you want to remove {0} {1}?").format(item_type, item_name) 381 382 reply = QMessageBox.question( 383 self, 384 QtCore.QCoreApplication.translate("TreeView", "Confirm"), 385 message, 386 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No 387 ) 388 389 if reply == QMessageBox.StandardButton.Yes: 390 for path in paths: 391 is_dir = os.path.isdir(path) 392 self.new_thread.emit(CopyDeleteThread(src=path, is_folder=is_dir,delete=True)) 393 394 def _open_in_explorer(self, path): 395 """Open a file or folder using the system's file explorer or default app.""" 396 try: 397 url = QUrl.fromLocalFile(path) 398 success = QDesktopServices.openUrl(url) 399 400 if not success: 401 # Extract file extension 402 ext = os.path.splitext(path)[1] or QtCore.QCoreApplication.translate("TreeView", "(unknown)") 403 QMessageBox.warning( 404 self, 405 QtCore.QCoreApplication.translate("TreeView", "No default application"), 406 QtCore.QCoreApplication.translate("TreeView", "No default application is registered for files with extension {0}.").format(ext) 407 ) 408 log.debug(f"No default application is registered for files with extension {ext}.") 409 except Exception as e: 410 QMessageBox.critical( 411 self, 412 QtCore.QCoreApplication.translate("TreeView", "Error"), 413 QtCore.QCoreApplication.translate("TreeView", "An unexpected error occurred while opening the file:\n{0}").format(e) 414 ) 415 log.error(f"An unexpected error occurred while opening the file:\n{e}") 416 417 def _open_nifti(self, file_path): 418 """Open a NIfTI file in the integrated viewer.""" 419 try: 420 self.context["open_nifti_viewer"](file_path) 421 except Exception as e: 422 QMessageBox.critical( 423 self, 424 QtCore.QCoreApplication.translate("TreeView", "Error"), 425 QtCore.QCoreApplication.translate("TreeView", "Error opening file NIfTI:\n{0}").format(str(e))) 426 log.error(f"Error opening file NIfTI:\n{str(e)}")
log =
<Logger GliAAns-UI (WARNING)>
class
WorkspaceTreeView(PyQt6.QtWidgets.QTreeView):
17class WorkspaceTreeView(QTreeView): 18 """ 19 Custom QTreeView to manage files and folders within a workspace. 20 21 This widget: 22 - Displays the workspace directory structure using QFileSystemModel. 23 - Handles context menus for file/folder operations (add, remove, export, open). 24 - Supports multiple file selection and drag/drop-like functionality via threads. 25 - Integrates with the main application context for shared signals and callbacks. 26 """ 27 28 # Signal emitted when a background thread (Copy/Delete) is created 29 new_thread = pyqtSignal(object) 30 """**Signal(object):** Emitted when a new background thread (e.g., for copy or delete operations) is created. 31 Parameter: 32 - `object`: Reference to the created thread instance. 33 """ 34 35 def __init__(self, context): 36 super().__init__() 37 38 # Context dictionary (shared among pages/windows) 39 self.context = context 40 self.workspace_path = self.context["workspace_path"] 41 42 self.selected_files = [] 43 44 # --- File System Model setup --- 45 self.tree_model = QFileSystemModel() 46 self.tree_model.setRootPath(self.workspace_path) 47 48 # Apply model to the tree view 49 self.setModel(self.tree_model) 50 self.setRootIndex(self.tree_model.index(self.workspace_path)) 51 self.setMinimumSize(QtCore.QSize(200, 0)) 52 53 # Enable extended selection (multi-selection) 54 self.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection) 55 56 # Enable custom context menu 57 self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) 58 59 # --- Signal connections --- 60 self.clicked.connect(self.handle_workspace_click) 61 self.doubleClicked.connect(self.handle_double_click) 62 self.customContextMenuRequested.connect(self.open_tree_context_menu) 63 64 # --------------------------------------------------------------- 65 # Event Handlers 66 # --------------------------------------------------------------- 67 68 def handle_workspace_click(self): 69 """Handle single-click event: update selected files and emit them via context signal.""" 70 selected_indexes = self.selectionModel().selectedRows() 71 selected_files = [self.tree_model.filePath(idx) for idx in selected_indexes] 72 73 self.selected_files = selected_files 74 self.context["selected_files_signal"].emit(selected_files) 75 76 def handle_double_click(self, index): 77 """ 78 Handle double-click event: 79 - If the selected item is a NIfTI file (.nii / .nii.gz), open it in the viewer. 80 - Otherwise, open it with the system's default application. 81 """ 82 file_path = self.tree_model.filePath(index) 83 if not os.path.isfile(file_path): 84 return 85 86 if file_path.endswith((".nii", ".nii.gz")): 87 try: 88 self.context["open_nifti_viewer"](file_path) 89 except Exception as e: 90 QMessageBox.critical( 91 self, 92 QtCore.QCoreApplication.translate("TreeView", "Error"), 93 QtCore.QCoreApplication.translate("TreeView", "Error when opening NIfTI file:\n{0}").format(str(e)) 94 ) 95 log.error(f"Error opening NIfTI file:\n{str(e)}") 96 else: 97 self._open_in_explorer(file_path) 98 99 def adjust_tree_columns(self): 100 """Automatically hide or show extra columns based on the widget width.""" 101 width = self.width() 102 for i in range(1, self.tree_model.columnCount()): 103 if width > 400: 104 self.showColumn(i) 105 self.setColumnWidth(i, 100) 106 else: 107 self.hideColumn(i) 108 109 # --------------------------------------------------------------- 110 # Context Menu 111 # --------------------------------------------------------------- 112 113 def open_tree_context_menu(self, position): 114 """ 115 Open the right-click context menu dynamically, depending on the selected item: 116 - Workspace root 117 - Folder 118 - File 119 - Multiple files 120 """ 121 index = self.indexAt(position) 122 file_path = None 123 is_dir = False 124 is_nifty = False 125 126 # Determine selected file/folder 127 if self.selected_files and len(self.selected_files) == 1 and not index.isValid(): 128 file_path = self.selected_files[0] 129 is_dir = self.tree_model.isDir(index) if index.isValid() else False 130 is_nifty = file_path.endswith((".nii", ".nii.gz")) 131 elif index.isValid(): 132 file_path = self.tree_model.filePath(index) 133 is_dir = self.tree_model.isDir(index) 134 135 menu = QMenu(self) 136 actions = {} 137 138 # Choose menu layout based on selection 139 if not index.isValid() and not self.selected_files: 140 actions.update(self._add_workspace_actions(menu)) 141 elif index.isValid() and file_path and len(self.selected_files) <= 1: 142 if is_dir: 143 actions.update(self._add_folder_actions(menu, file_path)) 144 else: 145 actions.update(self._add_file_actions(menu, file_path, is_nifty)) 146 elif index.isValid() and len(self.selected_files) > 1: 147 actions.update(self._add_multi_file_actions(menu)) 148 149 # Execute chosen action 150 chosen_action = menu.exec(self.viewport().mapToGlobal(position)) 151 if chosen_action and chosen_action in actions: 152 actions[chosen_action](file_path, is_dir, is_nifty) 153 154 # --- Context Menu Definitions --- 155 156 def _add_workspace_actions(self, menu): 157 """Add actions available at the root workspace level.""" 158 open_in_explorer = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Open workspace in explorer")) 159 menu.addSeparator() 160 export_workspace = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Export workspace")) 161 clear_workspace = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Clear workspace")) 162 menu.addSeparator() 163 add_single_file = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Add single file to workspace")) 164 165 return { 166 open_in_explorer: lambda *_: self._open_in_explorer(self.workspace_path), 167 export_workspace: lambda *_: self.export_files([self.workspace_path], True), 168 clear_workspace: lambda *_: self.context["main_window"].clear_folder( 169 folder_path=self.workspace_path, folder_name="workspace", return_to_import=True 170 ), 171 add_single_file: lambda *_: self.add_file_to_workspace(None, False), 172 } 173 174 def _add_folder_actions(self, menu, file_path): 175 """Add actions for a folder.""" 176 open_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Open folder in explorer")) 177 menu.addSeparator() 178 add_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Add single file to folder")) 179 remove_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Remove folder from workspace")) 180 export_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Export folder")) 181 182 return { 183 open_action: lambda *_: self._open_in_explorer(file_path), 184 add_action: lambda *_: self.add_file_to_workspace(file_path, True), 185 remove_action: lambda *_: self.remove_from_workspace([file_path]), 186 export_action: lambda *_: self.export_files([file_path], True), 187 } 188 189 def _add_file_actions(self, menu, file_path, is_nifty): 190 """Add actions for a single file.""" 191 open_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Open with system predefined")) 192 actions = {open_action: lambda *_: self._open_in_explorer(file_path)} 193 194 if is_nifty: 195 nifti_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Open NIfTI file")) 196 actions[nifti_action] = lambda *_: self._open_nifti(file_path) 197 198 menu.addSeparator() 199 add_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Add single file")) 200 remove_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Remove file from workspace")) 201 export_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Export file")) 202 203 actions.update({ 204 add_action: lambda *_: self.add_file_to_workspace(file_path, False), 205 remove_action: lambda *_: self.remove_from_workspace([file_path]), 206 export_action: lambda *_: self.export_files([file_path], False), 207 }) 208 return actions 209 210 def _add_multi_file_actions(self, menu): 211 """Add actions for multiple selected files.""" 212 export_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Export files")) 213 remove_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Remove Files from workspace")) 214 return { 215 export_action: lambda *_: self.export_files(self.selected_files, False), 216 remove_action: lambda *_: self.remove_from_workspace(self.selected_files), 217 } 218 219 # --------------------------------------------------------------- 220 # Core Operations 221 # --------------------------------------------------------------- 222 223 def add_file_to_workspace(self, folder_path, is_dir): 224 """ 225 Add a single file into the workspace. 226 Opens a file dialog, optionally adds related JSON, and triggers background copy thread. 227 """ 228 dialog = QFileDialog(self.context['main_window'], "Select File") 229 dialog.setFileMode(QFileDialog.FileMode.ExistingFile) 230 dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True) 231 dialog.setOption(QFileDialog.Option.ReadOnly, True) 232 dialog.setDirectory(os.path.expanduser("~")) 233 234 if not dialog.exec(): 235 return 236 237 file = dialog.selectedFiles()[0] 238 file_name = os.path.basename(file) 239 dir_name = os.path.dirname(file) 240 name, ext = os.path.splitext(file_name) 241 json_file = None 242 243 # Handle potential .nii.gz format 244 if ext == ".gz": 245 name2, ext2 = os.path.splitext(name) 246 if ext2 == ".nii": 247 json_file = self._including_json(name2, dir_name) 248 elif ext == ".nii": 249 json_file = self._including_json(name, dir_name) 250 251 # Determine destination folder 252 if folder_path: 253 if is_dir: 254 folder = os.path.basename(folder_path) 255 else: 256 folder = os.path.dirname(folder_path) 257 else: 258 folder = "workspace" 259 260 # Specialized behavior based on folder type 261 if folder in ("anat", "pet"): 262 self.new_thread.emit(CopyDeleteThread(src=file, dst=folder_path, is_folder=is_dir, copy=True)) 263 elif re.match(r"^ses-\d+$", folder): 264 self.new_thread.emit(CopyDeleteThread(src=file, dst=os.path.join(folder_path, "pet"), is_folder=is_dir, copy=True)) 265 elif re.match(r"^sub-\d+$", folder): 266 return self.open_role_dialog(files=[file, json_file], folder_path=folder_path, subj=folder) 267 elif folder == "derivatives": 268 return self.open_role_dialog(files=[file, json_file], folder_path=folder_path, main=folder) 269 else: 270 return self.open_role_dialog(files=[file, json_file], folder_path=self.workspace_path) 271 272 def open_role_dialog(self, files, folder_path=None, subj=None, role=None, main=None): 273 """Open a FileRoleDialog to determine where to place files in the workspace hierarchy.""" 274 dialog = FileRoleDialog(workspace_path=self.workspace_path, subj=subj, role=role, main=main, parent=self) 275 if dialog.exec(): 276 relative_path = dialog.get_relative_path() 277 path = os.path.join(folder_path, relative_path) 278 os.makedirs(path, exist_ok=True) 279 for file in files: 280 if file: 281 self.new_thread.emit(CopyDeleteThread(src=file, dst=path, is_folder=False, copy=True)) 282 283 def _including_json(self, file_name, file_dir): 284 """ 285 Ask the user whether to include an associated JSON file when adding a NIfTI file. 286 Returns JSON path if available and confirmed, otherwise empty string. 287 """ 288 answer = QMessageBox.information( 289 self, 290 QtCore.QCoreApplication.translate("TreeView", "Adding Json?"), 291 QtCore.QCoreApplication.translate("TreeView", "Do you want to include the JSON file if present?"), 292 QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel 293 ) 294 if answer == QMessageBox.StandardButton.Ok: 295 json_file_path = os.path.join(file_dir, file_name + ".json") 296 if os.path.isfile(json_file_path): 297 return json_file_path 298 return "" 299 300 # --------------------------------------------------------------- 301 # Export / Remove / Open 302 # --------------------------------------------------------------- 303 304 305 def export_files(self, paths, is_dir): 306 """Export selected files or folders to a chosen destination.""" 307 if not paths: 308 return 309 310 def _get_file_filter(ext): 311 """Return the QFileDialog filter based on file extension.""" 312 if ext == ".nii.gz": 313 return "NIfTI compressed (*.nii.gz);;NIfTI (*.nii);;All files (*.*)" 314 elif ext == ".nii": 315 return "NIfTI (*.nii);;All files (*.*)" 316 elif ext == ".json": 317 return "JSON (*.json);;All files (*.*)" 318 return "All files (*.*)" 319 320 def _get_json_file(path): 321 """Return associated JSON file if it exists.""" 322 base, ext = os.path.splitext(path) 323 if ext == ".gz" and base.endswith(".nii"): 324 base2 = base[:-4] # remove .nii 325 json_file = os.path.join(os.path.dirname(path), base2 + ".json") 326 return json_file if os.path.exists(json_file) else None 327 elif ext == ".nii": 328 json_file = os.path.join(os.path.dirname(path), base + ".json") 329 return json_file if os.path.exists(json_file) else None 330 return None 331 332 if len(paths) == 1: 333 path = paths[0] 334 if is_dir: 335 # Choose destination folder for a single directory 336 dst_path = QFileDialog.getExistingDirectory(self, "Select destination folder") 337 if dst_path: 338 dst_path = os.path.join(dst_path, os.path.basename(path)) 339 self.new_thread.emit(CopyDeleteThread(src=path, dst=dst_path, is_folder=True, copy=True)) 340 else: 341 # Single file export 342 file_filter = _get_file_filter(os.path.splitext(path)[1]) 343 dst_path, _ = QFileDialog.getSaveFileName(self, "Save file as...", os.path.basename(path), file_filter) 344 if not dst_path: 345 return 346 347 self.new_thread.emit(CopyDeleteThread(src=path, dst=dst_path, is_folder=False, copy=True)) 348 349 # Copy associated JSON if exists 350 json_file = _get_json_file(path) 351 if json_file: 352 dst_json = os.path.splitext(dst_path)[0] + ".json" 353 self.new_thread.emit(CopyDeleteThread(src=json_file, dst=dst_json, is_folder=False, copy=True)) 354 else: 355 # Multiple files/folders 356 dst_dir = QFileDialog.getExistingDirectory(self, "Select destination folder") 357 if not dst_dir: 358 return 359 360 for path in paths: 361 if not os.path.exists(path): 362 continue 363 364 is_folder = os.path.isdir(path) 365 dst_path = os.path.join(dst_dir, os.path.basename(path)) if is_folder else os.path.join(dst_dir, 366 os.path.basename( 367 path)) 368 self.new_thread.emit(CopyDeleteThread(src=path, dst=dst_path, is_folder=is_folder, copy=True)) 369 370 def remove_from_workspace(self, paths): 371 """Ask confirmation before deleting selected files/folders from workspace.""" 372 for path in paths: 373 if not os.path.exists(path): 374 return 375 if len(paths)<2: 376 is_dir = os.path.isdir(paths[0]) 377 item_type = QtCore.QCoreApplication.translate("TreeView", "this folder: ") if is_dir else QtCore.QCoreApplication.translate("TreeView", "this file:") 378 else: item_type = QtCore.QCoreApplication.translate("TreeView", "these files") 379 380 item_name = os.path.basename(paths[0]) if len(paths) == 1 else "" 381 message = QtCore.QCoreApplication.translate("TreeView", "Are you sure you want to remove {0} {1}?").format(item_type, item_name) 382 383 reply = QMessageBox.question( 384 self, 385 QtCore.QCoreApplication.translate("TreeView", "Confirm"), 386 message, 387 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No 388 ) 389 390 if reply == QMessageBox.StandardButton.Yes: 391 for path in paths: 392 is_dir = os.path.isdir(path) 393 self.new_thread.emit(CopyDeleteThread(src=path, is_folder=is_dir,delete=True)) 394 395 def _open_in_explorer(self, path): 396 """Open a file or folder using the system's file explorer or default app.""" 397 try: 398 url = QUrl.fromLocalFile(path) 399 success = QDesktopServices.openUrl(url) 400 401 if not success: 402 # Extract file extension 403 ext = os.path.splitext(path)[1] or QtCore.QCoreApplication.translate("TreeView", "(unknown)") 404 QMessageBox.warning( 405 self, 406 QtCore.QCoreApplication.translate("TreeView", "No default application"), 407 QtCore.QCoreApplication.translate("TreeView", "No default application is registered for files with extension {0}.").format(ext) 408 ) 409 log.debug(f"No default application is registered for files with extension {ext}.") 410 except Exception as e: 411 QMessageBox.critical( 412 self, 413 QtCore.QCoreApplication.translate("TreeView", "Error"), 414 QtCore.QCoreApplication.translate("TreeView", "An unexpected error occurred while opening the file:\n{0}").format(e) 415 ) 416 log.error(f"An unexpected error occurred while opening the file:\n{e}") 417 418 def _open_nifti(self, file_path): 419 """Open a NIfTI file in the integrated viewer.""" 420 try: 421 self.context["open_nifti_viewer"](file_path) 422 except Exception as e: 423 QMessageBox.critical( 424 self, 425 QtCore.QCoreApplication.translate("TreeView", "Error"), 426 QtCore.QCoreApplication.translate("TreeView", "Error opening file NIfTI:\n{0}").format(str(e))) 427 log.error(f"Error opening file NIfTI:\n{str(e)}")
Custom QTreeView to manage files and folders within a workspace.
This widget:
- Displays the workspace directory structure using QFileSystemModel.
- Handles context menus for file/folder operations (add, remove, export, open).
- Supports multiple file selection and drag/drop-like functionality via threads.
- Integrates with the main application context for shared signals and callbacks.
WorkspaceTreeView(context)
35 def __init__(self, context): 36 super().__init__() 37 38 # Context dictionary (shared among pages/windows) 39 self.context = context 40 self.workspace_path = self.context["workspace_path"] 41 42 self.selected_files = [] 43 44 # --- File System Model setup --- 45 self.tree_model = QFileSystemModel() 46 self.tree_model.setRootPath(self.workspace_path) 47 48 # Apply model to the tree view 49 self.setModel(self.tree_model) 50 self.setRootIndex(self.tree_model.index(self.workspace_path)) 51 self.setMinimumSize(QtCore.QSize(200, 0)) 52 53 # Enable extended selection (multi-selection) 54 self.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection) 55 56 # Enable custom context menu 57 self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) 58 59 # --- Signal connections --- 60 self.clicked.connect(self.handle_workspace_click) 61 self.doubleClicked.connect(self.handle_double_click) 62 self.customContextMenuRequested.connect(self.open_tree_context_menu)
def
new_thread(unknown):
Signal(object): Emitted when a new background thread (e.g., for copy or delete operations) is created.
Parameter:
object: Reference to the created thread instance.
def
handle_workspace_click(self):
68 def handle_workspace_click(self): 69 """Handle single-click event: update selected files and emit them via context signal.""" 70 selected_indexes = self.selectionModel().selectedRows() 71 selected_files = [self.tree_model.filePath(idx) for idx in selected_indexes] 72 73 self.selected_files = selected_files 74 self.context["selected_files_signal"].emit(selected_files)
Handle single-click event: update selected files and emit them via context signal.
def
handle_double_click(self, index):
76 def handle_double_click(self, index): 77 """ 78 Handle double-click event: 79 - If the selected item is a NIfTI file (.nii / .nii.gz), open it in the viewer. 80 - Otherwise, open it with the system's default application. 81 """ 82 file_path = self.tree_model.filePath(index) 83 if not os.path.isfile(file_path): 84 return 85 86 if file_path.endswith((".nii", ".nii.gz")): 87 try: 88 self.context["open_nifti_viewer"](file_path) 89 except Exception as e: 90 QMessageBox.critical( 91 self, 92 QtCore.QCoreApplication.translate("TreeView", "Error"), 93 QtCore.QCoreApplication.translate("TreeView", "Error when opening NIfTI file:\n{0}").format(str(e)) 94 ) 95 log.error(f"Error opening NIfTI file:\n{str(e)}") 96 else: 97 self._open_in_explorer(file_path)
Handle double-click event:
- If the selected item is a NIfTI file (.nii / .nii.gz), open it in the viewer.
- Otherwise, open it with the system's default application.
def
adjust_tree_columns(self):
99 def adjust_tree_columns(self): 100 """Automatically hide or show extra columns based on the widget width.""" 101 width = self.width() 102 for i in range(1, self.tree_model.columnCount()): 103 if width > 400: 104 self.showColumn(i) 105 self.setColumnWidth(i, 100) 106 else: 107 self.hideColumn(i)
Automatically hide or show extra columns based on the widget width.
def
add_file_to_workspace(self, folder_path, is_dir):
223 def add_file_to_workspace(self, folder_path, is_dir): 224 """ 225 Add a single file into the workspace. 226 Opens a file dialog, optionally adds related JSON, and triggers background copy thread. 227 """ 228 dialog = QFileDialog(self.context['main_window'], "Select File") 229 dialog.setFileMode(QFileDialog.FileMode.ExistingFile) 230 dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True) 231 dialog.setOption(QFileDialog.Option.ReadOnly, True) 232 dialog.setDirectory(os.path.expanduser("~")) 233 234 if not dialog.exec(): 235 return 236 237 file = dialog.selectedFiles()[0] 238 file_name = os.path.basename(file) 239 dir_name = os.path.dirname(file) 240 name, ext = os.path.splitext(file_name) 241 json_file = None 242 243 # Handle potential .nii.gz format 244 if ext == ".gz": 245 name2, ext2 = os.path.splitext(name) 246 if ext2 == ".nii": 247 json_file = self._including_json(name2, dir_name) 248 elif ext == ".nii": 249 json_file = self._including_json(name, dir_name) 250 251 # Determine destination folder 252 if folder_path: 253 if is_dir: 254 folder = os.path.basename(folder_path) 255 else: 256 folder = os.path.dirname(folder_path) 257 else: 258 folder = "workspace" 259 260 # Specialized behavior based on folder type 261 if folder in ("anat", "pet"): 262 self.new_thread.emit(CopyDeleteThread(src=file, dst=folder_path, is_folder=is_dir, copy=True)) 263 elif re.match(r"^ses-\d+$", folder): 264 self.new_thread.emit(CopyDeleteThread(src=file, dst=os.path.join(folder_path, "pet"), is_folder=is_dir, copy=True)) 265 elif re.match(r"^sub-\d+$", folder): 266 return self.open_role_dialog(files=[file, json_file], folder_path=folder_path, subj=folder) 267 elif folder == "derivatives": 268 return self.open_role_dialog(files=[file, json_file], folder_path=folder_path, main=folder) 269 else: 270 return self.open_role_dialog(files=[file, json_file], folder_path=self.workspace_path)
Add a single file into the workspace. Opens a file dialog, optionally adds related JSON, and triggers background copy thread.
def
open_role_dialog(self, files, folder_path=None, subj=None, role=None, main=None):
272 def open_role_dialog(self, files, folder_path=None, subj=None, role=None, main=None): 273 """Open a FileRoleDialog to determine where to place files in the workspace hierarchy.""" 274 dialog = FileRoleDialog(workspace_path=self.workspace_path, subj=subj, role=role, main=main, parent=self) 275 if dialog.exec(): 276 relative_path = dialog.get_relative_path() 277 path = os.path.join(folder_path, relative_path) 278 os.makedirs(path, exist_ok=True) 279 for file in files: 280 if file: 281 self.new_thread.emit(CopyDeleteThread(src=file, dst=path, is_folder=False, copy=True))
Open a FileRoleDialog to determine where to place files in the workspace hierarchy.
def
export_files(self, paths, is_dir):
305 def export_files(self, paths, is_dir): 306 """Export selected files or folders to a chosen destination.""" 307 if not paths: 308 return 309 310 def _get_file_filter(ext): 311 """Return the QFileDialog filter based on file extension.""" 312 if ext == ".nii.gz": 313 return "NIfTI compressed (*.nii.gz);;NIfTI (*.nii);;All files (*.*)" 314 elif ext == ".nii": 315 return "NIfTI (*.nii);;All files (*.*)" 316 elif ext == ".json": 317 return "JSON (*.json);;All files (*.*)" 318 return "All files (*.*)" 319 320 def _get_json_file(path): 321 """Return associated JSON file if it exists.""" 322 base, ext = os.path.splitext(path) 323 if ext == ".gz" and base.endswith(".nii"): 324 base2 = base[:-4] # remove .nii 325 json_file = os.path.join(os.path.dirname(path), base2 + ".json") 326 return json_file if os.path.exists(json_file) else None 327 elif ext == ".nii": 328 json_file = os.path.join(os.path.dirname(path), base + ".json") 329 return json_file if os.path.exists(json_file) else None 330 return None 331 332 if len(paths) == 1: 333 path = paths[0] 334 if is_dir: 335 # Choose destination folder for a single directory 336 dst_path = QFileDialog.getExistingDirectory(self, "Select destination folder") 337 if dst_path: 338 dst_path = os.path.join(dst_path, os.path.basename(path)) 339 self.new_thread.emit(CopyDeleteThread(src=path, dst=dst_path, is_folder=True, copy=True)) 340 else: 341 # Single file export 342 file_filter = _get_file_filter(os.path.splitext(path)[1]) 343 dst_path, _ = QFileDialog.getSaveFileName(self, "Save file as...", os.path.basename(path), file_filter) 344 if not dst_path: 345 return 346 347 self.new_thread.emit(CopyDeleteThread(src=path, dst=dst_path, is_folder=False, copy=True)) 348 349 # Copy associated JSON if exists 350 json_file = _get_json_file(path) 351 if json_file: 352 dst_json = os.path.splitext(dst_path)[0] + ".json" 353 self.new_thread.emit(CopyDeleteThread(src=json_file, dst=dst_json, is_folder=False, copy=True)) 354 else: 355 # Multiple files/folders 356 dst_dir = QFileDialog.getExistingDirectory(self, "Select destination folder") 357 if not dst_dir: 358 return 359 360 for path in paths: 361 if not os.path.exists(path): 362 continue 363 364 is_folder = os.path.isdir(path) 365 dst_path = os.path.join(dst_dir, os.path.basename(path)) if is_folder else os.path.join(dst_dir, 366 os.path.basename( 367 path)) 368 self.new_thread.emit(CopyDeleteThread(src=path, dst=dst_path, is_folder=is_folder, copy=True))
Export selected files or folders to a chosen destination.
def
remove_from_workspace(self, paths):
370 def remove_from_workspace(self, paths): 371 """Ask confirmation before deleting selected files/folders from workspace.""" 372 for path in paths: 373 if not os.path.exists(path): 374 return 375 if len(paths)<2: 376 is_dir = os.path.isdir(paths[0]) 377 item_type = QtCore.QCoreApplication.translate("TreeView", "this folder: ") if is_dir else QtCore.QCoreApplication.translate("TreeView", "this file:") 378 else: item_type = QtCore.QCoreApplication.translate("TreeView", "these files") 379 380 item_name = os.path.basename(paths[0]) if len(paths) == 1 else "" 381 message = QtCore.QCoreApplication.translate("TreeView", "Are you sure you want to remove {0} {1}?").format(item_type, item_name) 382 383 reply = QMessageBox.question( 384 self, 385 QtCore.QCoreApplication.translate("TreeView", "Confirm"), 386 message, 387 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No 388 ) 389 390 if reply == QMessageBox.StandardButton.Yes: 391 for path in paths: 392 is_dir = os.path.isdir(path) 393 self.new_thread.emit(CopyDeleteThread(src=path, is_folder=is_dir,delete=True))
Ask confirmation before deleting selected files/folders from workspace.