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()
log = <Logger GliAAns-UI (WARNING)>
class PatientSelectionPage(page.Page):
 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.

PatientSelectionPage(context=None, previous_page=None)
 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.

context
previous_page
next_page
workspace_path
patient_buttons
selected_patients
def layout(unknown):
title
select_all_btn
deselect_all_btn
scroll_area
scroll_content
grid_layout
column_count
def resizeEvent(self, event):
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.

def on_enter(self):
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.

def is_ready_to_advance(self):
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.

def is_ready_to_go_back(self):
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.

def next(self, context):
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.

def back(self):
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.

def get_selected_patients(self):
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.

def reset_page(self):
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.