ui.patient_selection_page
1import shutil 2import sys 3import os 4 5from PyQt6 import QtGui 6from PyQt6.QtWidgets import ( 7 QWidget, QVBoxLayout, QLabel, QPushButton, QScrollArea, QFrame, 8 QGridLayout, QHBoxLayout, QMessageBox, QSizePolicy 9) 10from PyQt6.QtCore import Qt, QCoreApplication 11 12from ui.tool_selection_page import ToolSelectionPage 13from utils import resource_path 14from page import Page 15from logger import get_logger 16 17log = get_logger() 18 19 20class PatientSelectionPage(Page): 21 """ 22 Page for selecting patients to analyze. 23 24 Displays all patient directories found in the workspace and allows 25 selecting/deselecting them individually or in bulk. Supports dynamic layout 26 resizing, deletion of unselected patients, and navigation to the next step. 27 28 Attributes 29 ---------- 30 context : dict 31 Application-wide shared state (paths, callbacks, etc.). 32 previous_page : Page | None 33 The page to return to when navigating backward. 34 next_page : Page | None 35 The next page to navigate to. 36 workspace_path : str 37 Base folder containing patient data. 38 patient_buttons : dict[str, QPushButton] 39 Mapping of patient IDs to their toggle buttons. 40 selected_patients : set[str] 41 IDs of currently selected patients. 42 column_count : int 43 Number of columns in the patient grid. 44 """ 45 46 def __init__(self, context=None, previous_page=None): 47 """ 48 Initialize the patient selection page UI and logic. 49 50 Parameters 51 ---------- 52 context : dict, optional 53 Shared context containing app settings and UI references. 54 previous_page : Page, optional 55 Reference to the previous page for navigation. 56 """ 57 super().__init__() 58 59 self.context = context 60 self.previous_page = previous_page 61 self.next_page = None 62 self.workspace_path = context["workspace_path"] 63 64 self.patient_buttons = {} 65 self.selected_patients = set() 66 67 # ----- Layout setup ----- 68 self.layout = QVBoxLayout(self) 69 self.title = QLabel("Select Patients to Analyze") 70 self.title.setStyleSheet("font-size: 18px; font-weight: bold;") 71 self.title.setAlignment(Qt.AlignmentFlag.AlignHCenter) 72 self.layout.addWidget(self.title) 73 74 # ----- Top button controls ----- 75 top_buttons_layout = QHBoxLayout() 76 self.select_all_btn = QPushButton("Select All") 77 self.deselect_all_btn = QPushButton("Deselect All") 78 79 # Button styling 80 btn_style = """ 81 QPushButton { 82 background-color: #e0e0e0; 83 padding: 10px 20px; 84 border-radius: 10px; 85 border: 1px solid #bdc3c7; 86 font-weight: bold; 87 } 88 QPushButton:hover { 89 background-color: #d0d0d0; 90 } 91 """ 92 self.select_all_btn.setStyleSheet(btn_style) 93 self.deselect_all_btn.setStyleSheet(btn_style) 94 95 # Button actions 96 self.select_all_btn.clicked.connect(self._select_all_patients) 97 self.deselect_all_btn.clicked.connect(self._deselect_all_patients) 98 99 top_buttons_layout.addStretch() 100 top_buttons_layout.addWidget(self.select_all_btn) 101 top_buttons_layout.addWidget(self.deselect_all_btn) 102 self.layout.addLayout(top_buttons_layout) 103 104 # ----- Scroll area for patient cards ----- 105 self.scroll_area = QScrollArea() 106 self.scroll_area.setWidgetResizable(True) 107 self.scroll_area.setStyleSheet(""" 108 QScrollArea { 109 font-size: 13px; 110 border: 1px solid #CCCCCC; 111 border-radius: 10px; 112 padding: 5px; 113 } 114 """) 115 116 self.scroll_content = QWidget() 117 self.grid_layout = QGridLayout(self.scroll_content) 118 self.scroll_area.setWidget(self.scroll_content) 119 self.layout.addWidget(self.scroll_area) 120 121 self.column_count = 2 # Default column layout 122 self._load_patients() 123 124 # ----- Translation / Localization ----- 125 self._translate_ui() 126 if context and "language_changed" in context: 127 context["language_changed"].connect(self._translate_ui) 128 129 # ------------------------------------------------------- 130 # Dynamic Layout & Resizing 131 # ------------------------------------------------------- 132 133 def _update_column_count(self): 134 """Adjust grid column count dynamically based on window width.""" 135 available_width = self.scroll_area.viewport().width() - 40 136 min_card_width = 250 137 new_column_count = max(1, available_width // min_card_width) 138 139 if new_column_count != self.column_count: 140 self.column_count = new_column_count 141 self._reload_patient_grid() 142 143 def resizeEvent(self, event): 144 """Update layout columns when the window is resized.""" 145 super().resizeEvent(event) 146 self._update_column_count() 147 148 def _reload_patient_grid(self): 149 """Reload patient cards keeping previous selection state.""" 150 selected = self.selected_patients.copy() 151 while self.grid_layout.count(): 152 item = self.grid_layout.takeAt(0) 153 widget = item.widget() 154 if widget: 155 widget.setParent(None) 156 widget.deleteLater() 157 self._load_patients() 158 self.selected_patients = selected 159 160 # ------------------------------------------------------- 161 # Navigation 162 # ------------------------------------------------------- 163 164 def on_enter(self): 165 """Called when the page becomes active.""" 166 while self.grid_layout.count(): 167 item = self.grid_layout.takeAt(0) 168 widget = item.widget() 169 if widget is not None: 170 widget.setParent(None) 171 widget.deleteLater() 172 self._load_patients() 173 174 def is_ready_to_advance(self): 175 """Return True if at least one patient is selected.""" 176 return bool(self.selected_patients) 177 178 def is_ready_to_go_back(self): 179 """This page can always go back to the previous one.""" 180 return True 181 182 def next(self, context): 183 """ 184 Proceed to the next page, deleting unselected patient directories. 185 186 Returns 187 ------- 188 Page | None 189 The next page to show, or None if the user cancels. 190 """ 191 to_delete = [ 192 p for p in self._find_patient_dirs() 193 if os.path.basename(p) not in self.selected_patients and os.path.basename(p) != "derivatives" 194 ] 195 unselected_ids = [os.path.basename(p) for p in to_delete] 196 197 # Confirm deletion of unselected patients 198 if to_delete: 199 reply = QMessageBox.question( 200 self, 201 QCoreApplication.translate("PatientSelectionPage", "Confirm Cleanup"), 202 QCoreApplication.translate( 203 "PatientSelectionPage", 204 "{0} unselected patient(s) will be removed from the workspace. Continue?" 205 ).format(len(to_delete)), 206 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No 207 ) 208 209 if reply == QMessageBox.StandardButton.No: 210 return None 211 212 # Delete directories 213 for patient_path in to_delete: 214 try: 215 shutil.rmtree(patient_path) 216 patient_id = os.path.basename(patient_path) 217 self.selected_patients.discard(patient_id) 218 log.info(f"Deleted patient directory: {patient_path}") 219 except Exception as e: 220 log.error(f"Failed to delete {patient_path}: {e}") 221 222 # Remove from 'derivatives' 223 derivatives_root = os.path.join(self.workspace_path, "derivatives") 224 if os.path.exists(derivatives_root): 225 for root, dirs, _ in os.walk(derivatives_root, topdown=False): 226 for dir_name in dirs: 227 if dir_name in unselected_ids: 228 full_path = os.path.join(root, dir_name) 229 try: 230 shutil.rmtree(full_path) 231 log.info(f"Deleted from derivatives: {full_path}") 232 except Exception as e: 233 log.error(f"Failed to delete from derivatives: {full_path}: {e}") 234 235 # Create next page if needed 236 if not self.next_page: 237 self.next_page = ToolSelectionPage(context, self) 238 self.context["history"].append(self.next_page) 239 240 self.next_page.on_enter() 241 return self.next_page 242 243 def back(self): 244 """Return to the previous page, if any.""" 245 if self.previous_page: 246 self.previous_page.on_enter() 247 return self.previous_page 248 return None 249 250 # ------------------------------------------------------- 251 # Patient Loading & Selection 252 # ------------------------------------------------------- 253 254 def _load_patients(self): 255 """Load patient directories into the grid view.""" 256 patient_dirs = self._find_patient_dirs() 257 patient_dirs.sort() 258 self.patient_buttons.clear() 259 260 for i, patient_path in enumerate(patient_dirs): 261 patient_id = os.path.basename(patient_path) 262 263 # Card container 264 patient_frame = QFrame() 265 patient_frame.setObjectName("patientCard") 266 patient_frame.setStyleSheet(""" 267 QFrame#patientCard { 268 border: 1px solid #CCCCCC; 269 border-radius: 10px; 270 background-color: #FFFFFF; 271 padding: 6px; 272 } 273 """) 274 patient_frame.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) 275 patient_frame.setMaximumHeight(140) 276 277 # Layout for each patient card 278 patient_layout = QHBoxLayout(patient_frame) 279 patient_layout.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) 280 281 profile = QFrame() 282 profile_layout = QVBoxLayout(profile) 283 284 # Patient icon 285 image = QLabel() 286 pixmap = QtGui.QPixmap(resource_path("resources/user.png")).scaled( 287 30, 30, 288 Qt.AspectRatioMode.KeepAspectRatio, 289 Qt.TransformationMode.SmoothTransformation 290 ) 291 image.setPixmap(pixmap) 292 image.setAlignment(Qt.AlignmentFlag.AlignCenter) 293 294 # Label and selection button 295 label = QLabel(f"{patient_id}") 296 label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) 297 298 button = QPushButton(QCoreApplication.translate("PatientSelectionFrame", "Select")) 299 button.setCheckable(True) 300 button.setStyleSheet(""" 301 QPushButton { 302 border-radius: 12px; 303 padding: 8px 16px; 304 background-color: #DADADA; 305 } 306 QPushButton:checked { 307 background-color: #4CAF50; 308 color: white; 309 } 310 """) 311 312 # Restore selection state 313 is_selected = patient_id in self.selected_patients 314 button.setChecked(is_selected) 315 button.setText( 316 QCoreApplication.translate("PatientSelectionFrame", "Selected") 317 if is_selected else 318 QCoreApplication.translate("PatientSelectionFrame", "Select") 319 ) 320 button.clicked.connect(lambda checked, pid=patient_id, btn=button: 321 self._toggle_patient(pid, checked, btn)) 322 323 self.patient_buttons[patient_id] = button 324 325 # Assemble card 326 profile_layout.addWidget(image) 327 profile_layout.addWidget(label) 328 patient_layout.addWidget(profile) 329 patient_layout.addStretch() 330 patient_layout.addWidget(button) 331 332 self.grid_layout.addWidget(patient_frame, i // self.column_count, i % self.column_count) 333 334 def _select_all_patients(self): 335 """Mark all patients as selected.""" 336 for patient_id, button in self.patient_buttons.items(): 337 if not button.isChecked(): 338 button.setChecked(True) 339 button.setText(QCoreApplication.translate("PatientSelectionFrame", "Selected")) 340 self.selected_patients.add(patient_id) 341 if self.context and "update_main_buttons" in self.context: 342 self.context["update_main_buttons"]() 343 344 def _deselect_all_patients(self): 345 """Unselect all patients.""" 346 for patient_id, button in self.patient_buttons.items(): 347 if button.isChecked(): 348 button.setChecked(False) 349 button.setText(QCoreApplication.translate("PatientSelectionFrame", "Select")) 350 self.selected_patients.discard(patient_id) 351 if self.context and "update_main_buttons" in self.context: 352 self.context["update_main_buttons"]() 353 354 def _find_patient_dirs(self): 355 """Recursively find all patient directories (sub-*) in the workspace.""" 356 patient_dirs = [] 357 for root, dirs, _ in os.walk(self.workspace_path): 358 # Skip derivatives and pipeline folders 359 for skip in ("derivatives", "pipeline"): 360 if skip in dirs: 361 dirs.remove(skip) 362 363 # Collect patient directories 364 for dir_name in dirs: 365 if dir_name.startswith("sub-"): 366 patient_dirs.append(os.path.join(root, dir_name)) 367 368 # Prevent recursion into patient subdirectories 369 dirs[:] = [d for d in dirs if not d.startswith("sub-")] 370 371 return patient_dirs 372 373 def _toggle_patient(self, patient_id, is_selected, button): 374 """Toggle selection for a single patient.""" 375 if is_selected: 376 self.selected_patients.add(patient_id) 377 button.setText(QCoreApplication.translate("PatientSelectionFrame", "Selected")) 378 else: 379 self.selected_patients.discard(patient_id) 380 button.setText(QCoreApplication.translate("PatientSelectionFrame", "Select")) 381 382 if self.context and "update_main_buttons" in self.context: 383 self.context["update_main_buttons"]() 384 385 def get_selected_patients(self): 386 """Return a list of selected patient IDs.""" 387 return list(self.selected_patients) 388 389 def reset_page(self): 390 """Reset the UI, clearing selections and reloading patients.""" 391 while self.grid_layout.count(): 392 item = self.grid_layout.takeAt(0) 393 widget = item.widget() 394 if widget is not None: 395 widget.setParent(None) 396 widget.deleteLater() 397 398 self.selected_patients.clear() 399 self.patient_buttons.clear() 400 self._load_patients() 401 402 # ------------------------------------------------------- 403 # Translation 404 # ------------------------------------------------------- 405 406 def _translate_ui(self): 407 """Apply localized text to all UI elements.""" 408 self.title.setText(QCoreApplication.translate("PatientSelectionFrame", "Select Patients to Analyze")) 409 self.select_all_btn.setText(QCoreApplication.translate("PatientSelectionFrame", "Select All")) 410 self.deselect_all_btn.setText(QCoreApplication.translate("PatientSelectionFrame", "Deselect All")) 411 self._load_patients()
21class PatientSelectionPage(Page): 22 """ 23 Page for selecting patients to analyze. 24 25 Displays all patient directories found in the workspace and allows 26 selecting/deselecting them individually or in bulk. Supports dynamic layout 27 resizing, deletion of unselected patients, and navigation to the next step. 28 29 Attributes 30 ---------- 31 context : dict 32 Application-wide shared state (paths, callbacks, etc.). 33 previous_page : Page | None 34 The page to return to when navigating backward. 35 next_page : Page | None 36 The next page to navigate to. 37 workspace_path : str 38 Base folder containing patient data. 39 patient_buttons : dict[str, QPushButton] 40 Mapping of patient IDs to their toggle buttons. 41 selected_patients : set[str] 42 IDs of currently selected patients. 43 column_count : int 44 Number of columns in the patient grid. 45 """ 46 47 def __init__(self, context=None, previous_page=None): 48 """ 49 Initialize the patient selection page UI and logic. 50 51 Parameters 52 ---------- 53 context : dict, optional 54 Shared context containing app settings and UI references. 55 previous_page : Page, optional 56 Reference to the previous page for navigation. 57 """ 58 super().__init__() 59 60 self.context = context 61 self.previous_page = previous_page 62 self.next_page = None 63 self.workspace_path = context["workspace_path"] 64 65 self.patient_buttons = {} 66 self.selected_patients = set() 67 68 # ----- Layout setup ----- 69 self.layout = QVBoxLayout(self) 70 self.title = QLabel("Select Patients to Analyze") 71 self.title.setStyleSheet("font-size: 18px; font-weight: bold;") 72 self.title.setAlignment(Qt.AlignmentFlag.AlignHCenter) 73 self.layout.addWidget(self.title) 74 75 # ----- Top button controls ----- 76 top_buttons_layout = QHBoxLayout() 77 self.select_all_btn = QPushButton("Select All") 78 self.deselect_all_btn = QPushButton("Deselect All") 79 80 # Button styling 81 btn_style = """ 82 QPushButton { 83 background-color: #e0e0e0; 84 padding: 10px 20px; 85 border-radius: 10px; 86 border: 1px solid #bdc3c7; 87 font-weight: bold; 88 } 89 QPushButton:hover { 90 background-color: #d0d0d0; 91 } 92 """ 93 self.select_all_btn.setStyleSheet(btn_style) 94 self.deselect_all_btn.setStyleSheet(btn_style) 95 96 # Button actions 97 self.select_all_btn.clicked.connect(self._select_all_patients) 98 self.deselect_all_btn.clicked.connect(self._deselect_all_patients) 99 100 top_buttons_layout.addStretch() 101 top_buttons_layout.addWidget(self.select_all_btn) 102 top_buttons_layout.addWidget(self.deselect_all_btn) 103 self.layout.addLayout(top_buttons_layout) 104 105 # ----- Scroll area for patient cards ----- 106 self.scroll_area = QScrollArea() 107 self.scroll_area.setWidgetResizable(True) 108 self.scroll_area.setStyleSheet(""" 109 QScrollArea { 110 font-size: 13px; 111 border: 1px solid #CCCCCC; 112 border-radius: 10px; 113 padding: 5px; 114 } 115 """) 116 117 self.scroll_content = QWidget() 118 self.grid_layout = QGridLayout(self.scroll_content) 119 self.scroll_area.setWidget(self.scroll_content) 120 self.layout.addWidget(self.scroll_area) 121 122 self.column_count = 2 # Default column layout 123 self._load_patients() 124 125 # ----- Translation / Localization ----- 126 self._translate_ui() 127 if context and "language_changed" in context: 128 context["language_changed"].connect(self._translate_ui) 129 130 # ------------------------------------------------------- 131 # Dynamic Layout & Resizing 132 # ------------------------------------------------------- 133 134 def _update_column_count(self): 135 """Adjust grid column count dynamically based on window width.""" 136 available_width = self.scroll_area.viewport().width() - 40 137 min_card_width = 250 138 new_column_count = max(1, available_width // min_card_width) 139 140 if new_column_count != self.column_count: 141 self.column_count = new_column_count 142 self._reload_patient_grid() 143 144 def resizeEvent(self, event): 145 """Update layout columns when the window is resized.""" 146 super().resizeEvent(event) 147 self._update_column_count() 148 149 def _reload_patient_grid(self): 150 """Reload patient cards keeping previous selection state.""" 151 selected = self.selected_patients.copy() 152 while self.grid_layout.count(): 153 item = self.grid_layout.takeAt(0) 154 widget = item.widget() 155 if widget: 156 widget.setParent(None) 157 widget.deleteLater() 158 self._load_patients() 159 self.selected_patients = selected 160 161 # ------------------------------------------------------- 162 # Navigation 163 # ------------------------------------------------------- 164 165 def on_enter(self): 166 """Called when the page becomes active.""" 167 while self.grid_layout.count(): 168 item = self.grid_layout.takeAt(0) 169 widget = item.widget() 170 if widget is not None: 171 widget.setParent(None) 172 widget.deleteLater() 173 self._load_patients() 174 175 def is_ready_to_advance(self): 176 """Return True if at least one patient is selected.""" 177 return bool(self.selected_patients) 178 179 def is_ready_to_go_back(self): 180 """This page can always go back to the previous one.""" 181 return True 182 183 def next(self, context): 184 """ 185 Proceed to the next page, deleting unselected patient directories. 186 187 Returns 188 ------- 189 Page | None 190 The next page to show, or None if the user cancels. 191 """ 192 to_delete = [ 193 p for p in self._find_patient_dirs() 194 if os.path.basename(p) not in self.selected_patients and os.path.basename(p) != "derivatives" 195 ] 196 unselected_ids = [os.path.basename(p) for p in to_delete] 197 198 # Confirm deletion of unselected patients 199 if to_delete: 200 reply = QMessageBox.question( 201 self, 202 QCoreApplication.translate("PatientSelectionPage", "Confirm Cleanup"), 203 QCoreApplication.translate( 204 "PatientSelectionPage", 205 "{0} unselected patient(s) will be removed from the workspace. Continue?" 206 ).format(len(to_delete)), 207 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No 208 ) 209 210 if reply == QMessageBox.StandardButton.No: 211 return None 212 213 # Delete directories 214 for patient_path in to_delete: 215 try: 216 shutil.rmtree(patient_path) 217 patient_id = os.path.basename(patient_path) 218 self.selected_patients.discard(patient_id) 219 log.info(f"Deleted patient directory: {patient_path}") 220 except Exception as e: 221 log.error(f"Failed to delete {patient_path}: {e}") 222 223 # Remove from 'derivatives' 224 derivatives_root = os.path.join(self.workspace_path, "derivatives") 225 if os.path.exists(derivatives_root): 226 for root, dirs, _ in os.walk(derivatives_root, topdown=False): 227 for dir_name in dirs: 228 if dir_name in unselected_ids: 229 full_path = os.path.join(root, dir_name) 230 try: 231 shutil.rmtree(full_path) 232 log.info(f"Deleted from derivatives: {full_path}") 233 except Exception as e: 234 log.error(f"Failed to delete from derivatives: {full_path}: {e}") 235 236 # Create next page if needed 237 if not self.next_page: 238 self.next_page = ToolSelectionPage(context, self) 239 self.context["history"].append(self.next_page) 240 241 self.next_page.on_enter() 242 return self.next_page 243 244 def back(self): 245 """Return to the previous page, if any.""" 246 if self.previous_page: 247 self.previous_page.on_enter() 248 return self.previous_page 249 return None 250 251 # ------------------------------------------------------- 252 # Patient Loading & Selection 253 # ------------------------------------------------------- 254 255 def _load_patients(self): 256 """Load patient directories into the grid view.""" 257 patient_dirs = self._find_patient_dirs() 258 patient_dirs.sort() 259 self.patient_buttons.clear() 260 261 for i, patient_path in enumerate(patient_dirs): 262 patient_id = os.path.basename(patient_path) 263 264 # Card container 265 patient_frame = QFrame() 266 patient_frame.setObjectName("patientCard") 267 patient_frame.setStyleSheet(""" 268 QFrame#patientCard { 269 border: 1px solid #CCCCCC; 270 border-radius: 10px; 271 background-color: #FFFFFF; 272 padding: 6px; 273 } 274 """) 275 patient_frame.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) 276 patient_frame.setMaximumHeight(140) 277 278 # Layout for each patient card 279 patient_layout = QHBoxLayout(patient_frame) 280 patient_layout.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) 281 282 profile = QFrame() 283 profile_layout = QVBoxLayout(profile) 284 285 # Patient icon 286 image = QLabel() 287 pixmap = QtGui.QPixmap(resource_path("resources/user.png")).scaled( 288 30, 30, 289 Qt.AspectRatioMode.KeepAspectRatio, 290 Qt.TransformationMode.SmoothTransformation 291 ) 292 image.setPixmap(pixmap) 293 image.setAlignment(Qt.AlignmentFlag.AlignCenter) 294 295 # Label and selection button 296 label = QLabel(f"{patient_id}") 297 label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) 298 299 button = QPushButton(QCoreApplication.translate("PatientSelectionFrame", "Select")) 300 button.setCheckable(True) 301 button.setStyleSheet(""" 302 QPushButton { 303 border-radius: 12px; 304 padding: 8px 16px; 305 background-color: #DADADA; 306 } 307 QPushButton:checked { 308 background-color: #4CAF50; 309 color: white; 310 } 311 """) 312 313 # Restore selection state 314 is_selected = patient_id in self.selected_patients 315 button.setChecked(is_selected) 316 button.setText( 317 QCoreApplication.translate("PatientSelectionFrame", "Selected") 318 if is_selected else 319 QCoreApplication.translate("PatientSelectionFrame", "Select") 320 ) 321 button.clicked.connect(lambda checked, pid=patient_id, btn=button: 322 self._toggle_patient(pid, checked, btn)) 323 324 self.patient_buttons[patient_id] = button 325 326 # Assemble card 327 profile_layout.addWidget(image) 328 profile_layout.addWidget(label) 329 patient_layout.addWidget(profile) 330 patient_layout.addStretch() 331 patient_layout.addWidget(button) 332 333 self.grid_layout.addWidget(patient_frame, i // self.column_count, i % self.column_count) 334 335 def _select_all_patients(self): 336 """Mark all patients as selected.""" 337 for patient_id, button in self.patient_buttons.items(): 338 if not button.isChecked(): 339 button.setChecked(True) 340 button.setText(QCoreApplication.translate("PatientSelectionFrame", "Selected")) 341 self.selected_patients.add(patient_id) 342 if self.context and "update_main_buttons" in self.context: 343 self.context["update_main_buttons"]() 344 345 def _deselect_all_patients(self): 346 """Unselect all patients.""" 347 for patient_id, button in self.patient_buttons.items(): 348 if button.isChecked(): 349 button.setChecked(False) 350 button.setText(QCoreApplication.translate("PatientSelectionFrame", "Select")) 351 self.selected_patients.discard(patient_id) 352 if self.context and "update_main_buttons" in self.context: 353 self.context["update_main_buttons"]() 354 355 def _find_patient_dirs(self): 356 """Recursively find all patient directories (sub-*) in the workspace.""" 357 patient_dirs = [] 358 for root, dirs, _ in os.walk(self.workspace_path): 359 # Skip derivatives and pipeline folders 360 for skip in ("derivatives", "pipeline"): 361 if skip in dirs: 362 dirs.remove(skip) 363 364 # Collect patient directories 365 for dir_name in dirs: 366 if dir_name.startswith("sub-"): 367 patient_dirs.append(os.path.join(root, dir_name)) 368 369 # Prevent recursion into patient subdirectories 370 dirs[:] = [d for d in dirs if not d.startswith("sub-")] 371 372 return patient_dirs 373 374 def _toggle_patient(self, patient_id, is_selected, button): 375 """Toggle selection for a single patient.""" 376 if is_selected: 377 self.selected_patients.add(patient_id) 378 button.setText(QCoreApplication.translate("PatientSelectionFrame", "Selected")) 379 else: 380 self.selected_patients.discard(patient_id) 381 button.setText(QCoreApplication.translate("PatientSelectionFrame", "Select")) 382 383 if self.context and "update_main_buttons" in self.context: 384 self.context["update_main_buttons"]() 385 386 def get_selected_patients(self): 387 """Return a list of selected patient IDs.""" 388 return list(self.selected_patients) 389 390 def reset_page(self): 391 """Reset the UI, clearing selections and reloading patients.""" 392 while self.grid_layout.count(): 393 item = self.grid_layout.takeAt(0) 394 widget = item.widget() 395 if widget is not None: 396 widget.setParent(None) 397 widget.deleteLater() 398 399 self.selected_patients.clear() 400 self.patient_buttons.clear() 401 self._load_patients() 402 403 # ------------------------------------------------------- 404 # Translation 405 # ------------------------------------------------------- 406 407 def _translate_ui(self): 408 """Apply localized text to all UI elements.""" 409 self.title.setText(QCoreApplication.translate("PatientSelectionFrame", "Select Patients to Analyze")) 410 self.select_all_btn.setText(QCoreApplication.translate("PatientSelectionFrame", "Select All")) 411 self.deselect_all_btn.setText(QCoreApplication.translate("PatientSelectionFrame", "Deselect All")) 412 self._load_patients()
Page for selecting patients to analyze.
Displays all patient directories found in the workspace and allows selecting/deselecting them individually or in bulk. Supports dynamic layout resizing, deletion of unselected patients, and navigation to the next step.
Attributes
context : dict Application-wide shared state (paths, callbacks, etc.). previous_page : Page | None The page to return to when navigating backward. next_page : Page | None The next page to navigate to. workspace_path : str Base folder containing patient data. patient_buttons : dict[str, QPushButton] Mapping of patient IDs to their toggle buttons. selected_patients : set[str] IDs of currently selected patients. column_count : int Number of columns in the patient grid.
47 def __init__(self, context=None, previous_page=None): 48 """ 49 Initialize the patient selection page UI and logic. 50 51 Parameters 52 ---------- 53 context : dict, optional 54 Shared context containing app settings and UI references. 55 previous_page : Page, optional 56 Reference to the previous page for navigation. 57 """ 58 super().__init__() 59 60 self.context = context 61 self.previous_page = previous_page 62 self.next_page = None 63 self.workspace_path = context["workspace_path"] 64 65 self.patient_buttons = {} 66 self.selected_patients = set() 67 68 # ----- Layout setup ----- 69 self.layout = QVBoxLayout(self) 70 self.title = QLabel("Select Patients to Analyze") 71 self.title.setStyleSheet("font-size: 18px; font-weight: bold;") 72 self.title.setAlignment(Qt.AlignmentFlag.AlignHCenter) 73 self.layout.addWidget(self.title) 74 75 # ----- Top button controls ----- 76 top_buttons_layout = QHBoxLayout() 77 self.select_all_btn = QPushButton("Select All") 78 self.deselect_all_btn = QPushButton("Deselect All") 79 80 # Button styling 81 btn_style = """ 82 QPushButton { 83 background-color: #e0e0e0; 84 padding: 10px 20px; 85 border-radius: 10px; 86 border: 1px solid #bdc3c7; 87 font-weight: bold; 88 } 89 QPushButton:hover { 90 background-color: #d0d0d0; 91 } 92 """ 93 self.select_all_btn.setStyleSheet(btn_style) 94 self.deselect_all_btn.setStyleSheet(btn_style) 95 96 # Button actions 97 self.select_all_btn.clicked.connect(self._select_all_patients) 98 self.deselect_all_btn.clicked.connect(self._deselect_all_patients) 99 100 top_buttons_layout.addStretch() 101 top_buttons_layout.addWidget(self.select_all_btn) 102 top_buttons_layout.addWidget(self.deselect_all_btn) 103 self.layout.addLayout(top_buttons_layout) 104 105 # ----- Scroll area for patient cards ----- 106 self.scroll_area = QScrollArea() 107 self.scroll_area.setWidgetResizable(True) 108 self.scroll_area.setStyleSheet(""" 109 QScrollArea { 110 font-size: 13px; 111 border: 1px solid #CCCCCC; 112 border-radius: 10px; 113 padding: 5px; 114 } 115 """) 116 117 self.scroll_content = QWidget() 118 self.grid_layout = QGridLayout(self.scroll_content) 119 self.scroll_area.setWidget(self.scroll_content) 120 self.layout.addWidget(self.scroll_area) 121 122 self.column_count = 2 # Default column layout 123 self._load_patients() 124 125 # ----- Translation / Localization ----- 126 self._translate_ui() 127 if context and "language_changed" in context: 128 context["language_changed"].connect(self._translate_ui)
Initialize the patient selection page UI and logic.
Parameters
context : dict, optional Shared context containing app settings and UI references. previous_page : Page, optional Reference to the previous page for navigation.
144 def resizeEvent(self, event): 145 """Update layout columns when the window is resized.""" 146 super().resizeEvent(event) 147 self._update_column_count()
Update layout columns when the window is resized.
165 def on_enter(self): 166 """Called when the page becomes active.""" 167 while self.grid_layout.count(): 168 item = self.grid_layout.takeAt(0) 169 widget = item.widget() 170 if widget is not None: 171 widget.setParent(None) 172 widget.deleteLater() 173 self._load_patients()
Called when the page becomes active.
175 def is_ready_to_advance(self): 176 """Return True if at least one patient is selected.""" 177 return bool(self.selected_patients)
Return True if at least one patient is selected.
179 def is_ready_to_go_back(self): 180 """This page can always go back to the previous one.""" 181 return True
This page can always go back to the previous one.
183 def next(self, context): 184 """ 185 Proceed to the next page, deleting unselected patient directories. 186 187 Returns 188 ------- 189 Page | None 190 The next page to show, or None if the user cancels. 191 """ 192 to_delete = [ 193 p for p in self._find_patient_dirs() 194 if os.path.basename(p) not in self.selected_patients and os.path.basename(p) != "derivatives" 195 ] 196 unselected_ids = [os.path.basename(p) for p in to_delete] 197 198 # Confirm deletion of unselected patients 199 if to_delete: 200 reply = QMessageBox.question( 201 self, 202 QCoreApplication.translate("PatientSelectionPage", "Confirm Cleanup"), 203 QCoreApplication.translate( 204 "PatientSelectionPage", 205 "{0} unselected patient(s) will be removed from the workspace. Continue?" 206 ).format(len(to_delete)), 207 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No 208 ) 209 210 if reply == QMessageBox.StandardButton.No: 211 return None 212 213 # Delete directories 214 for patient_path in to_delete: 215 try: 216 shutil.rmtree(patient_path) 217 patient_id = os.path.basename(patient_path) 218 self.selected_patients.discard(patient_id) 219 log.info(f"Deleted patient directory: {patient_path}") 220 except Exception as e: 221 log.error(f"Failed to delete {patient_path}: {e}") 222 223 # Remove from 'derivatives' 224 derivatives_root = os.path.join(self.workspace_path, "derivatives") 225 if os.path.exists(derivatives_root): 226 for root, dirs, _ in os.walk(derivatives_root, topdown=False): 227 for dir_name in dirs: 228 if dir_name in unselected_ids: 229 full_path = os.path.join(root, dir_name) 230 try: 231 shutil.rmtree(full_path) 232 log.info(f"Deleted from derivatives: {full_path}") 233 except Exception as e: 234 log.error(f"Failed to delete from derivatives: {full_path}: {e}") 235 236 # Create next page if needed 237 if not self.next_page: 238 self.next_page = ToolSelectionPage(context, self) 239 self.context["history"].append(self.next_page) 240 241 self.next_page.on_enter() 242 return self.next_page
Proceed to the next page, deleting unselected patient directories.
Returns
Page | None The next page to show, or None if the user cancels.
244 def back(self): 245 """Return to the previous page, if any.""" 246 if self.previous_page: 247 self.previous_page.on_enter() 248 return self.previous_page 249 return None
Return to the previous page, if any.
386 def get_selected_patients(self): 387 """Return a list of selected patient IDs.""" 388 return list(self.selected_patients)
Return a list of selected patient IDs.
390 def reset_page(self): 391 """Reset the UI, clearing selections and reloading patients.""" 392 while self.grid_layout.count(): 393 item = self.grid_layout.takeAt(0) 394 widget = item.widget() 395 if widget is not None: 396 widget.setParent(None) 397 widget.deleteLater() 398 399 self.selected_patients.clear() 400 self.patient_buttons.clear() 401 self._load_patients()
Reset the UI, clearing selections and reloading patients.