ui.pipeline_patient_selection_page

   1import json
   2import os
   3import glob
   4
   5from PyQt6 import QtGui
   6from PyQt6.QtGui import QFont
   7from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QLabel, QPushButton, QScrollArea,
   8                             QFrame, QGridLayout, QHBoxLayout, QSizePolicy)
   9from PyQt6.QtCore import Qt, QCoreApplication
  10
  11from ui.pipeline_review_page import PipelineReviewPage
  12from utils import resource_path
  13from page import Page
  14from logger import get_logger
  15
  16log = get_logger()
  17
  18
  19class PipelinePatientSelectionPage(Page):
  20    """
  21    This class scans the workspace for patient directories, verifies if the required
  22    imaging files (FLAIR, skull-stripped, segmentation) are present, and presents
  23    a visual grid of patient cards. Users can select eligible patients to proceed
  24    to the pipeline configuration stage.
  25
  26    Attributes:
  27        context (dict): The application context containing shared data and state.
  28        workspace_path (str): The root directory path containing patient data.
  29        patient_buttons (dict): Maps patient IDs to their selection `QPushButton`.
  30        selected_patients (set): A set of strings containing IDs of currently selected patients.
  31        patient_status (dict): Stores eligibility status and missing file details for each patient.
  32    """
  33
  34    def __init__(self, context=None, previous_page=None):
  35        """
  36        Initializes the PipelinePatientSelectionPage.
  37
  38        Args:
  39            context (dict, optional): The shared application context. Defaults to None.
  40            previous_page (Page, optional): The page instance navigated from. Defaults to None.
  41        """
  42        super().__init__()
  43
  44        self.context = context
  45        self.previous_page = previous_page
  46        self.next_page = None
  47
  48        # Path to the workspace directory containing patient data
  49        self.workspace_path = context["workspace_path"]
  50
  51        # Dictionaries to track patient widgets, selected patients, and their processing status
  52        self.patient_buttons = {}
  53        self.selected_patients = set()
  54        self.patient_status = {}  # Stores eligibility and missing data for each patient
  55
  56        # Initialize the user interface
  57        self._setup_ui()
  58
  59        # Set translations (for multilingual UI)
  60        self._translate_ui()
  61        if context and "language_changed" in context:
  62            # Connect language change signal for dynamic translation
  63            context["language_changed"].connect(self._translate_ui)
  64
  65    def _setup_ui(self):
  66        """
  67        Sets up the main layout and UI components.
  68
  69        This method initializes the title, the bulk selection control buttons,
  70        the scrollable area for the patient grid, and the statistical summary widget.
  71        """
  72        self.layout = QVBoxLayout(self)
  73
  74        # --- Title ---
  75        self.title = QLabel("Select Patients for Pipeline Analysis")
  76        self.title.setFont(QFont("Arial", 18, QFont.Weight.Bold))
  77        self.title.setAlignment(Qt.AlignmentFlag.AlignHCenter)
  78        self.layout.addWidget(self.title)
  79
  80        # --- Top buttons (bulk selection controls) ---
  81        top_buttons_layout = QHBoxLayout()
  82
  83        self.select_eligible_btn = QPushButton("Select All Eligible")
  84        self.deselect_all_btn = QPushButton("Deselect All")
  85        self.refresh_btn = QPushButton("Refresh Status")
  86
  87        self.buttons = [self.select_eligible_btn, self.deselect_all_btn, self.refresh_btn]
  88
  89        # Apply a consistent visual style to all buttons
  90        btn_style = """
  91            QPushButton {
  92                background-color: #e0e0e0;
  93                padding: 10px 20px;
  94                border-radius: 10px;
  95                border: 1px solid #bdc3c7;
  96                font-weight: bold;
  97                margin: 2px;
  98            }
  99            QPushButton:hover {
 100                background-color: #d0d0d0;
 101            }
 102            QPushButton:disabled {
 103                background-color: #f0f0f0;
 104                color: #888888;
 105            }
 106        """
 107        for btn in self.buttons:
 108            btn.setStyleSheet(btn_style)
 109
 110        # Connect buttons to actions
 111        self.select_eligible_btn.clicked.connect(self._select_all_eligible_patients)
 112        self.deselect_all_btn.clicked.connect(self._deselect_all_patients)
 113        self.refresh_btn.clicked.connect(self._refresh_patient_status)
 114
 115        # Add the buttons to layout with stretch for alignment
 116        top_buttons_layout.addStretch()
 117        top_buttons_layout.addWidget(self.select_eligible_btn)
 118        top_buttons_layout.addWidget(self.deselect_all_btn)
 119        top_buttons_layout.addWidget(self.refresh_btn)
 120        self.layout.addLayout(top_buttons_layout)
 121
 122        # --- Scroll area for displaying patients ---
 123        self.scroll_area = QScrollArea()
 124        self.scroll_area.setWidgetResizable(True)
 125        self.scroll_area.setStyleSheet("""
 126            QScrollArea {
 127                font-size: 13px;
 128                border: 1px solid #CCCCCC;
 129                border-radius: 10px;
 130                padding: 5px;
 131            }
 132        """)
 133
 134        # Container widget inside scroll area (grid of patient cards)
 135        self.scroll_content = QWidget()
 136        self.grid_layout = QGridLayout(self.scroll_content)
 137        self.scroll_area.setWidget(self.scroll_content)
 138
 139        # --- Summary panel showing total and eligible counts ---
 140        self.summary_widget = self._create_summary_widget()
 141
 142        # Add widgets to the main layout
 143        self.layout.addWidget(self.summary_widget)
 144        self.layout.addWidget(self.scroll_area)
 145
 146        # Number of patient columns per row in grid
 147        self.column_count = 2
 148
 149        # Load patient data and build UI
 150        self._load_patients()
 151
 152    def _create_summary_widget(self):
 153        """
 154        Creates a modern-style summary widget for displaying patient statistics.
 155
 156        Constructs a `QFrame` containing "pill" style cards that display the
 157        total number of patients, the number of eligible patients, and the
 158        number of ineligible patients.
 159
 160        Returns:
 161            QFrame: The constructed summary widget.
 162        """
 163        summary_frame = QFrame()
 164        summary_frame.setObjectName("summaryCard")
 165        summary_frame.setStyleSheet("""
 166            QFrame#summaryCard {
 167                border: 1px solid #e0e0e0;
 168                border-radius: 12px;
 169                background-color: #ffffff;
 170                padding: 0.1em;
 171            }
 172        """)
 173
 174        main_layout = QVBoxLayout(summary_frame)
 175
 176        # Section title
 177        self.title_summary = QLabel(QCoreApplication.translate("PipelinePatientSelectionPage", "Pipeline Requirements Summary"))
 178        self.title_summary.setAlignment(Qt.AlignmentFlag.AlignCenter)
 179        self.title_summary.setStyleSheet("font-size: 16px; font-weight: bold; color: #000000; margin-bottom: 10px;")
 180        main_layout.addWidget(self.title_summary)
 181
 182        # Horizontal layout for summary “pill” cards
 183        stats_layout = QHBoxLayout()
 184        stats_layout.setSpacing(15)
 185
 186        # Create pill cards showing totals
 187        self.total_label = self._create_stat_pill(resource_path("resources/icon_total.png"),
 188                                                  QCoreApplication.translate("PipelinePatientSelectionPage", "Total Patients"), "0")
 189        self.eligible_label = self._create_stat_pill(resource_path("resources/icon_check.png"),
 190                                                     QCoreApplication.translate("PipelinePatientSelectionPage", "Eligible"), "0", color="#27ae60")
 191        self.not_eligible_label = self._create_stat_pill(resource_path("resources/icon_cross.png"),
 192                                                         QCoreApplication.translate("PipelinePatientSelectionPage", "Not Eligible"), "0", color="#c0392b")
 193
 194        # Add pills to layout
 195        for pill in [self.total_label, self.eligible_label, self.not_eligible_label]:
 196            pill.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
 197            stats_layout.addWidget(pill)
 198
 199        main_layout.addLayout(stats_layout)
 200
 201        return summary_frame
 202
 203    def _create_stat_pill(self, icon_path, label_text, value_text, color="#000000"):
 204        """
 205        Creates a card-like label with an icon, text, and numeric value.
 206
 207        Args:
 208            icon_path (str): Path to the icon image (currently unused in the simplified implementation).
 209            label_text (str): The description label (e.g., "Eligible").
 210            value_text (str): The numeric value as a string.
 211            color (str, optional): Hex color code for the numeric value text. Defaults to "#000000".
 212
 213        Returns:
 214            QFrame: A frame widget representing the statistic pill.
 215        """
 216        pill = QFrame()
 217        pill.setObjectName("summaryCard")
 218        pill.setStyleSheet(f"""
 219            QFrame#summaryCard {{
 220                border: 1px solid #CCCCCC;
 221                border-radius: 10px;
 222                background-color: #f9f9f9;
 223            }}
 224        """)
 225
 226        layout = QVBoxLayout(pill)
 227        layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
 228
 229        # Text label (e.g., “Total Patients”)
 230        label = QLabel(label_text)
 231        label.setStyleSheet("font-size: 13px; font-weight: bold;")
 232        label.setAlignment(Qt.AlignmentFlag.AlignCenter)
 233
 234        # Numeric value (e.g., “5”)
 235        value = QLabel(value_text)
 236        value.setStyleSheet(f"font-size: 20px; font-weight: bold; color: {color};")
 237        value.setAlignment(Qt.AlignmentFlag.AlignCenter)
 238
 239        layout.addWidget(label)
 240        layout.addWidget(value)
 241
 242        # Keep reference to labels for later updates
 243        pill.label = label
 244        pill.value_label = value
 245
 246        pill.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
 247        pill.setMinimumHeight(50)
 248        pill.setMaximumHeight(120)
 249
 250        return pill
 251
 252    def _check_patient_requirements(self, patient_path, patient_id):
 253        """
 254        Checks whether a patient meets all necessary pipeline file requirements.
 255
 256        It looks for the existence of:
 257        1.  **FLAIR image**: `anat/*_flair.nii[.gz]`
 258        2.  **Skull-stripped image**: `derivatives/skullstrips/.../anat/*_brain.nii[.gz]`
 259        3.  **Segmentation/Mask**: `manual_masks` or `deep_learning_seg`
 260
 261        Args:
 262            patient_path (str): The absolute path to the patient's folder.
 263            patient_id (str): The unique identifier of the patient (e.g., 'sub-01').
 264
 265        Returns:
 266            dict: A dictionary containing:
 267                - `eligible` (bool): True if all files are present.
 268                - `requirements` (dict): Boolean status for each file type ('flair', 'skull_stripping', 'segmentation').
 269                - `missing_files` (list): List of translated strings describing missing components.
 270                - `segmentation_type` (str or None): The type of segmentation found ("Manual Mask" or "deep_learning_seg Segmentation").
 271        """
 272        # Each key corresponds to a necessary preprocessing step
 273        requirements = {
 274            'flair': False,
 275            'skull_stripping': False,
 276            'segmentation': False
 277        }
 278
 279        missing_files = []  # Collect missing components for display
 280
 281        # --- 1. Check for FLAIR anatomical image ---
 282        flair_patterns = [
 283            os.path.join(patient_path, "anat", "*_flair.nii"),
 284            os.path.join(patient_path, "anat", "*_flair.nii.gz")
 285        ]
 286
 287        flair_found = False
 288        for pattern in flair_patterns:
 289            if glob.glob(pattern):  # Search for matching files
 290                flair_found = True
 291                break
 292
 293        requirements['flair'] = flair_found
 294        if not flair_found:
 295            missing_files.append(QCoreApplication.translate(
 296                "PipelinePatientSelectionPage", "FLAIR image (anat/*_flair.nii[.gz])"))
 297
 298        # --- 2. Check for Skull-Stripped image ---
 299        skull_strip_patterns = [
 300            os.path.join(self.workspace_path, "derivatives", "skullstrips", patient_id, "anat", "*_brain.nii"),
 301            os.path.join(self.workspace_path, "derivatives", "skullstrips", patient_id, "anat", "*_brain.nii.gz")
 302        ]
 303
 304        skull_strip_found = False
 305        for pattern in skull_strip_patterns:
 306            if glob.glob(pattern):
 307                skull_strip_found = True
 308                break
 309
 310        requirements['skull_stripping'] = skull_strip_found
 311        if not skull_strip_found:
 312            missing_files.append(QCoreApplication.translate(
 313                "PipelinePatientSelectionPage", "Skull stripped image (derivatives/skullstrips/.../anat/*_brain.nii[.gz])"))
 314
 315        # --- 3. Check for Segmentation or Manual Mask ---
 316        segmentation_patterns = [
 317            # Manual masks
 318            os.path.join(self.workspace_path, "derivatives", "manual_masks", patient_id, "anat", "*_mask.nii"),
 319            os.path.join(self.workspace_path, "derivatives", "manual_masks", patient_id, "anat", "*_mask.nii.gz"),
 320            # Deep learning segmentation output
 321            os.path.join(self.workspace_path, "derivatives", "deep_learning_seg", patient_id, "anat", "*_seg.nii"),
 322            os.path.join(self.workspace_path, "derivatives", "deep_learning_seg", patient_id, "anat", "*_seg.nii.gz")
 323        ]
 324
 325        segmentation_found = False
 326        segmentation_type = None
 327        for pattern in segmentation_patterns:
 328            if glob.glob(pattern):
 329                segmentation_found = True
 330                # Identify segmentation source type
 331                if "manual_masks" in pattern:
 332                    segmentation_type = "Manual Mask"
 333                elif "deep_learning_seg" in pattern:
 334                    segmentation_type = "deep_learning_seg Segmentation"
 335                break
 336
 337        requirements['segmentation'] = segmentation_found
 338        if not segmentation_found:
 339            missing_files.append(QCoreApplication.translate(
 340                "PipelinePatientSelectionPage", "Segmentation (manual_masks/*_mask.nii[.gz] or deep_learning_seg /*_seg.nii[.gz])"))
 341
 342        # --- Final eligibility decision ---
 343        # Patient is eligible if all required files are present
 344        is_eligible = all(requirements.values())
 345
 346        # Return structured summary of findings
 347        return {
 348            'eligible': is_eligible,
 349            'requirements': requirements,
 350            'missing_files': missing_files,
 351            'segmentation_type': segmentation_type
 352        }
 353
 354    def _load_patients(self):
 355        """
 356        Loads patient directories and builds the UI grid.
 357
 358        This method:
 359        1. Identifies patient directories in the workspace.
 360        2. Verifies the eligibility of each patient via `_check_patient_requirements`.
 361        3. Populates the `grid_layout` with visual cards created by `_create_patient_frame`.
 362        4. Updates the summary statistics.
 363        """
 364        # Find all patient folders (sub-*)
 365        patient_dirs = self._find_patient_dirs()
 366        patient_dirs.sort()  # Sort alphabetically for visual consistency
 367
 368        # Clear the dictionaries of buttons and patient states
 369        self.patient_buttons.clear()
 370        self.patient_status.clear()
 371
 372        eligible_count = 0
 373        total_count = len(patient_dirs)
 374
 375        # Iterate over all found patients
 376        for i, patient_path in enumerate(patient_dirs):
 377            patient_id = os.path.basename(patient_path)
 378
 379            # Check the required conditions for each patient
 380            status = self._check_patient_requirements(patient_path, patient_id)
 381            self.patient_status[patient_id] = status
 382
 383            # Count eligible patients
 384            if status['eligible']:
 385                eligible_count += 1
 386
 387            # Create a card (frame) for the patient
 388            patient_frame = self._create_patient_frame(patient_id, patient_path, status)
 389
 390            # Insert the card into the grid layout
 391            self.grid_layout.addWidget(patient_frame, i // self.column_count, i % self.column_count)
 392
 393        # Update the statistical summary
 394        self._update_summary(eligible_count, total_count)
 395
 396    def _create_patient_frame(self, patient_id, patient_path, status):
 397        """
 398        Creates a UI card (QFrame) representing a single patient.
 399
 400        The card displays:
 401        - Patient ID and user icon.
 402        - Eligibility status (Green checkmark or Red cross).
 403        - Detailed checklist of requirements (FLAIR, Skull Strip, Segmentation).
 404        - A "Select" button (disabled if the patient is ineligible).
 405
 406        Args:
 407            patient_id (str): The ID of the patient.
 408            patient_path (str): The file system path to the patient data.
 409            status (dict): The eligibility status dict returned by `_check_patient_requirements`.
 410
 411        Returns:
 412            QFrame: The constructed patient card widget.
 413        """
 414        patient_frame = QFrame()
 415        patient_frame.setObjectName("patientCard")
 416        patient_frame.setMaximumHeight(140)  # Card height limit
 417        patient_frame.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
 418
 419        # Apply different visual style based on patient eligibility
 420        if status['eligible']:
 421            frame_style = """
 422                QFrame#patientCard {
 423                    border: 2px solid #4CAF50;
 424                    border-radius: 10px;
 425                    background-color: #f0fff0;
 426                    padding: 10px;
 427                    margin: 2px;
 428                }
 429            """
 430        else:
 431            frame_style = """
 432                QFrame#patientCard {
 433                    border: 2px solid #f44336;
 434                    border-radius: 10px;
 435                    background-color: #fff0f0;
 436                    padding: 10px;
 437                    margin: 2px;
 438                }
 439            """
 440
 441        patient_frame.setStyleSheet(frame_style)
 442
 443        # Main horizontal layout (left → right)
 444        patient_layout = QHBoxLayout(patient_frame)
 445
 446        # Left section: profile information
 447        profile_frame = QFrame()
 448        profile_frame.setFixedSize(150, 100)
 449        profile_layout = QVBoxLayout(profile_frame)
 450        profile_layout.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
 451
 452        # User image (icon)
 453        image = QLabel()
 454        pixmap = QtGui.QPixmap(resource_path("resources/user.png")).scaled(
 455            30, 30, Qt.AspectRatioMode.KeepAspectRatio,
 456            Qt.TransformationMode.SmoothTransformation
 457        )
 458        image.setPixmap(pixmap)
 459        image.setAlignment(Qt.AlignmentFlag.AlignCenter)
 460
 461        # Patient ID label
 462        patient_label = QLabel(f"{patient_id}")
 463        patient_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
 464        patient_label.setStyleSheet("font-weight: bold; font-size: 12px;")
 465
 466        # Status label (Eligible / Not eligible)
 467        if status['eligible']:
 468            status_label = QLabel(QCoreApplication.translate("PipelinePatientSelectionPage", "✓ Ready for Pipeline"))
 469            status_label.setStyleSheet("color: #4CAF50; font-weight: bold; font-size: 10px;")
 470        else:
 471            status_label = QLabel(QCoreApplication.translate("PipelinePatientSelectionPage", "✗ Missing Requirements"))
 472            status_label.setStyleSheet("color: #f44336; font-weight: bold; font-size: 10px;")
 473        status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
 474
 475        # Add widgets to the profile layout
 476        profile_layout.addWidget(image)
 477        profile_layout.addWidget(patient_label)
 478        profile_layout.addWidget(status_label)
 479
 480        # Center section: requirement details
 481        details_frame = QFrame()
 482        details_layout = QVBoxLayout(details_frame)
 483        details_layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)
 484
 485        # Status indicators for each required step
 486        req_indicators = []
 487        req_labels = {
 488            'flair': 'FLAIR',
 489            'skull_stripping': 'Skull Strip',
 490            'segmentation': QCoreApplication.translate("PipelinePatientSelectionPage", 'Segmentation')
 491        }
 492
 493        # Display ✓ or ✗ next to each requirement
 494        for req, label in req_labels.items():
 495            indicator = QLabel()
 496            if status['requirements'][req]:
 497                indicator.setText(f"✓ {label}")
 498                indicator.setStyleSheet("color: #4CAF50; font-size: 10px; padding: 1px;")
 499            else:
 500                indicator.setText(f"✗ {label}")
 501                indicator.setStyleSheet("color: #f44336; font-size: 10px; padding: 1px;")
 502            req_indicators.append(indicator)
 503            details_layout.addWidget(indicator)
 504
 505        # Show segmentation type if available
 506        if status['segmentation_type']:
 507            seg_type_label = QLabel(f"({status['segmentation_type']})")
 508            seg_type_label.setStyleSheet("color: #666666; font-size: 9px; font-style: italic;")
 509            details_layout.addWidget(seg_type_label)
 510
 511        # Right section: selection button
 512        button = QPushButton(QCoreApplication.translate("PipelinePatientSelectionPage", "Select"))
 513        button.setCheckable(True)  # Allows to toggle selection
 514
 515        if status['eligible']:
 516            # Style for active (eligible) button
 517            button.setStyleSheet("""
 518                QPushButton {
 519                    border-radius: 12px;
 520                    padding: 8px 16px;
 521                    background-color: #DADADA;
 522                    font-weight: bold;
 523                    min-width: 80px;
 524                }
 525                QPushButton:checked {
 526                    background-color: #4CAF50;
 527                    color: white;
 528                }
 529                QPushButton:hover {
 530                    background-color: #c0c0c0;
 531                }
 532                QPushButton:checked:hover {
 533                    background-color: #45a049;
 534                }
 535            """)
 536
 537            # Restore previous selection if it exists
 538            is_selected = patient_id in self.selected_patients
 539            button.setChecked(is_selected)
 540            button.setText(
 541                QCoreApplication.translate("PipelinePatientSelectionPage", "Selected")
 542                if is_selected else QCoreApplication.translate("PipelinePatientSelectionPage", "Select")
 543            )
 544
 545            # Connect button click to selection handler
 546            button.clicked.connect(lambda checked, pid=patient_id, btn=button: self._toggle_patient(pid, checked, btn))
 547        else:
 548            # If not eligible, disable the button
 549            button.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Not Eligible"))
 550            button.setEnabled(False)
 551            button.setStyleSheet("""
 552                QPushButton {
 553                    border-radius: 12px;
 554                    padding: 8px 16px;
 555                    background-color: #f0f0f0;
 556                    color: #888888;
 557                    font-weight: bold;
 558                    min-width: 80px;
 559                }
 560            """)
 561
 562        # Save reference of the button in the dictionary
 563        self.patient_buttons[patient_id] = button
 564
 565        # Assemble all sections into the main layout
 566        patient_layout.addWidget(profile_frame)
 567        patient_layout.addWidget(details_frame)
 568        patient_layout.addStretch()  # Flexible space
 569        patient_layout.addWidget(button)
 570
 571        return patient_frame
 572
 573    def _update_summary(self, eligible_count, total_count):
 574        """
 575        Updates the numeric values in the summary widget.
 576
 577        Args:
 578            eligible_count (int): Number of patients who met all requirements.
 579            total_count (int): Total number of patients found.
 580        """
 581        self.total_label.value_label.setText(str(total_count))
 582        self.eligible_label.value_label.setText(str(eligible_count))
 583        self.not_eligible_label.value_label.setText(str(total_count - eligible_count))
 584
 585    def _select_all_eligible_patients(self):
 586        """
 587        Action handler: Automatically selects all patients marked as eligible.
 588        Updates the main application buttons after selection.
 589        """
 590        for patient_id, button in self.patient_buttons.items():
 591            if self.patient_status[patient_id]['eligible'] and not button.isChecked():
 592                button.setChecked(True)
 593                button.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Selected"))
 594                self.selected_patients.add(patient_id)
 595        # Update main app buttons
 596        if self.context and "update_main_buttons" in self.context:
 597            self.context["update_main_buttons"]()
 598
 599    def _deselect_all_patients(self):
 600        """
 601        Action handler: Deselects all currently selected patients.
 602        Updates the main application buttons after deselection.
 603        """
 604        for patient_id, button in self.patient_buttons.items():
 605            if button.isChecked() and button.isEnabled():
 606                button.setChecked(False)
 607                button.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Select"))
 608                self.selected_patients.discard(patient_id)
 609        # Update main buttons
 610        if self.context and "update_main_buttons" in self.context:
 611            self.context["update_main_buttons"]()
 612
 613    def _refresh_patient_status(self):
 614        """
 615        Action handler: Reloads all patients and re-verifies their status.
 616
 617        This is useful if files have been added to the workspace while the
 618        application was open. It attempts to preserve existing selections if
 619        the patient remains eligible.
 620        """
 621        # Save current selections
 622        current_selections = self.selected_patients.copy()
 623
 624        # Remove existing widgets from the grid
 625        while self.grid_layout.count():
 626            item = self.grid_layout.takeAt(0)
 627            widget = item.widget()
 628            if widget:
 629                widget.setParent(None)
 630                widget.deleteLater()
 631
 632        # Reload patients and update status
 633        self._load_patients()
 634
 635        # Restore still-valid selections
 636        valid_selections = set()
 637        for patient_id in current_selections:
 638            if (patient_id in self.patient_status and
 639                    self.patient_status[patient_id]['eligible'] and
 640                    patient_id in self.patient_buttons):
 641                valid_selections.add(patient_id)
 642                button = self.patient_buttons[patient_id]
 643                button.setChecked(True)
 644                button.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Selected"))
 645
 646        self.selected_patients = valid_selections
 647
 648    def _toggle_patient(self, patient_id, is_selected, button):
 649        """
 650        Toggles the selection status of a single patient.
 651
 652        Args:
 653            patient_id (str): The ID of the patient.
 654            is_selected (bool): The new state (True for selected).
 655            button (QPushButton): The button widget triggering the event.
 656        """
 657        if is_selected:
 658            self.selected_patients.add(patient_id)
 659            button.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Selected"))
 660        else:
 661            self.selected_patients.discard(patient_id)
 662            button.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Select"))
 663        # Update main buttons
 664        if self.context and "update_main_buttons" in self.context:
 665            self.context["update_main_buttons"]()
 666
 667    def _find_patient_dirs(self):
 668        """
 669        Scans the workspace path for patient directories.
 670
 671        It looks for directories starting with "sub-" and excludes "derivatives"
 672        and "pipeline" directories.
 673
 674        Returns:
 675            list: A list of full paths to patient directories.
 676        """
 677        patient_dirs = []
 678
 679        for root, dirs, files in os.walk(self.workspace_path):
 680            # Exclude irrelevant folders
 681            if "derivatives" in dirs:
 682                dirs.remove("derivatives")
 683            if "pipeline" in dirs:
 684                dirs.remove("pipeline")
 685
 686            # Add directories starting with "sub-"
 687            for dir_name in dirs:
 688                if dir_name.startswith("sub-"):
 689                    full_path = os.path.join(root, dir_name)
 690                    patient_dirs.append(full_path)
 691
 692            # Prevent going deeper into sub-* directories
 693            dirs[:] = [d for d in dirs if not d.startswith("sub-")]
 694
 695        return patient_dirs
 696
 697    def _update_column_count(self):
 698        """
 699        Recalculates the number of grid columns based on the available viewport width.
 700
 701        If the calculated column count differs from the current count, the grid
 702        is reloaded to adjust the layout.
 703        """
 704        available_width = self.scroll_area.viewport().width() - 40
 705        min_card_width = 400  # Minimum width for a card
 706
 707        new_column_count = max(1, available_width // min_card_width)
 708
 709        # If column count changes, reload the grid
 710        if new_column_count != self.column_count:
 711            self.column_count = new_column_count
 712            self._reload_patient_grid()
 713
 714    def _reload_patient_grid(self):
 715        """
 716        Rebuilds the patient grid (e.g., after a resize) while maintaining selections.
 717        """
 718        selected = self.selected_patients.copy()
 719
 720        # Remove existing widgets
 721        while self.grid_layout.count():
 722            item = self.grid_layout.takeAt(0)
 723            widget = item.widget()
 724            if widget:
 725                widget.setParent(None)
 726                widget.deleteLater()
 727
 728        # Reload patients in the new layout
 729        self._load_patients()
 730
 731        # Restore valid selections
 732        valid_selections = set()
 733        for patient_id in selected:
 734            if (patient_id in self.patient_status and
 735                    self.patient_status[patient_id]['eligible'] and
 736                    patient_id in self.patient_buttons):
 737                valid_selections.add(patient_id)
 738                button = self.patient_buttons[patient_id]
 739                button.setChecked(True)
 740                button.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Selected"))
 741
 742        self.selected_patients = valid_selections
 743
 744    def _build_pipeline_config(self):
 745        """
 746        Generates and saves a JSON file containing the initial pipeline configuration.
 747
 748        This method iterates through selected patients, resolves relative paths for
 749        all required files (MRI, Skull-strip, PET, Masks), and detects ambiguities
 750        (multiple files found) which are flagged as `need_revision`.
 751
 752        The configuration is saved to `workspace/pipeline/XX_config.json`, where XX
 753        is an auto-incrementing sequence number.
 754
 755        Returns:
 756            str: The full path to the generated JSON configuration file.
 757        """
 758        config = {}  # Main dictionary that will store configuration data for all patients
 759
 760        # Iterate over all selected patients
 761        for patient_id in self.selected_patients:
 762            patient_entry = {}  # Holds the configuration for a single patient
 763            need_revision = False  # Flag indicating if manual review is required due to ambiguities
 764
 765            # ----------------------------------------------------
 766            # MRI (FLAIR)
 767            # ----------------------------------------------------
 768            flair_patterns = [
 769                os.path.join(self.workspace_path, patient_id, "anat", "*_flair.nii"),
 770                os.path.join(self.workspace_path, patient_id, "anat", "*_flair.nii.gz")
 771            ]
 772            flair_files = []
 773            # Search for FLAIR MRI files matching the defined patterns
 774            for p in flair_patterns:
 775                flair_files.extend(glob.glob(p))
 776            # If more than one FLAIR file exists, mark for manual revision
 777            if len(flair_files) > 1:
 778                need_revision = True
 779            # Store the relative path of the FLAIR file if found
 780            patient_entry["mri"] = os.path.relpath(flair_files[0], self.workspace_path) if flair_files else None
 781
 782            # ----------------------------------------------------
 783            # MRI Skull Stripped
 784            # ----------------------------------------------------
 785            mri_str_patterns = [
 786                os.path.join(self.workspace_path, "derivatives", "skullstrips", patient_id, "anat", "*_brain.nii"),
 787                os.path.join(self.workspace_path, "derivatives", "skullstrips", patient_id, "anat", "*_brain.nii.gz")
 788            ]
 789            mri_str_files = []
 790            # Search for preprocessed (skull-stripped) MRI files
 791            for p in mri_str_patterns:
 792                mri_str_files.extend(glob.glob(p))
 793            if len(mri_str_files) > 1:
 794                need_revision = True
 795            patient_entry["mri_str"] = os.path.relpath(mri_str_files[0], self.workspace_path) if mri_str_files else None
 796
 797            # ----------------------------------------------------
 798            # Static PET (Session 01)
 799            # ----------------------------------------------------
 800            pet_patterns = [
 801                os.path.join(self.workspace_path, patient_id, "ses-01", "pet", "*_pet.nii"),
 802                os.path.join(self.workspace_path, patient_id, "ses-01", "pet", "*_pet.nii.gz")
 803            ]
 804            pet_files = []
 805            # Locate static PET scans
 806            for p in pet_patterns:
 807                pet_files.extend(glob.glob(p))
 808            if len(pet_files) > 1:
 809                need_revision = True
 810            patient_entry["pet"] = os.path.relpath(pet_files[0], self.workspace_path) if pet_files else None
 811
 812            # ----------------------------------------------------
 813            # Dynamic PET (4D, optional)
 814            # ----------------------------------------------------
 815            pet4d_patterns = [
 816                os.path.join(self.workspace_path, patient_id, "ses-02", "pet", "*_pet.nii"),
 817                os.path.join(self.workspace_path, patient_id, "ses-02", "pet", "*_pet.nii.gz")
 818            ]
 819            pet4d_files = []
 820            # Locate dynamic PET files (4D data)
 821            for p in pet4d_patterns:
 822                pet4d_files.extend(glob.glob(p))
 823            if len(pet4d_files) > 1:
 824                need_revision = True
 825            pet4d_file = pet4d_files[0] if pet4d_files else None
 826            patient_entry["pet4d"] = os.path.relpath(pet4d_files[0], self.workspace_path) if pet4d_files else None
 827
 828            # ----------------------------------------------------
 829            # PET JSON sidecar (metadata for dynamic PET)
 830            # ----------------------------------------------------
 831            pet_json_file = None
 832            if pet4d_file:
 833                # Derive the expected .json filename from the PET file prefix
 834                basename = os.path.basename(pet4d_file)
 835                stem_no_ext = basename.split('.')[0]
 836                candidate = os.path.join(os.path.dirname(pet4d_file), stem_no_ext + '.json')
 837                if os.path.exists(candidate):
 838                    pet_json_file = candidate
 839            # Store relative path to PET JSON file if it exists
 840            patient_entry["pet4d_json"] = os.path.relpath(pet_json_file, self.workspace_path) if pet_json_file else None
 841
 842            # ----------------------------------------------------
 843            # Tumor MRI Mask (manual or automated)
 844            # ----------------------------------------------------
 845            tumor_patterns = [
 846                # Manual segmentation masks
 847                os.path.join(self.workspace_path, "derivatives", "manual_masks", patient_id, "anat", "*_mask.nii"),
 848                os.path.join(self.workspace_path, "derivatives", "manual_masks", patient_id, "anat", "*_mask.nii.gz"),
 849                # Deep learning-based segmentations
 850                os.path.join(self.workspace_path, "derivatives", "deep_learning_seg", patient_id, "anat",
 851                             "*_seg.nii"),
 852                os.path.join(self.workspace_path, "derivatives", "deep_learning_seg", patient_id, "anat",
 853                             "*_seg.nii.gz")
 854            ]
 855            tumor_files = []
 856            # Search for tumor mask files
 857            for p in tumor_patterns:
 858                tumor_files.extend(glob.glob(p))
 859            if len(tumor_files) > 1:
 860                need_revision = True
 861            patient_entry["tumor_mri"] = os.path.relpath(tumor_files[0], self.workspace_path) if tumor_files else None
 862
 863            # Add the final review flag to indicate if manual review is needed
 864            patient_entry["need_revision"] = need_revision
 865
 866            # Store the patient's configuration in the main config dictionary
 867            config[patient_id] = patient_entry
 868
 869        # ----------------------------------------------------
 870        # Create and save the pipeline configuration JSON file
 871        # ----------------------------------------------------
 872        pipeline_dir = os.path.join(self.workspace_path, "pipeline")
 873
 874        # Create the 'pipeline' directory if it doesn't exist
 875        if not os.path.exists(pipeline_dir):
 876            os.makedirs(pipeline_dir)
 877            log.info(f"Created pipeline directory: {pipeline_dir}")
 878
 879        # Determine the next sequential ID number (e.g., 01, 02, 03, ...)
 880        config_id = self._get_next_config_id(pipeline_dir)
 881
 882        # Build the filename using the sequential ID
 883        filename = f"{config_id:02d}_config.json"
 884        output_path = os.path.join(pipeline_dir, filename)
 885
 886        # Save the configuration dictionary as a formatted JSON file
 887        with open(output_path, "w", encoding="utf-8") as f:
 888            json.dump(config, f, indent=4)
 889
 890        # Log the output path for reference
 891        log.info(f"Pipeline configuration saved to: {output_path}")
 892
 893        # Return the full path to the generated configuration file
 894        return output_path
 895
 896    def _get_next_config_id(self, pipeline_dir):
 897        """
 898        Finds the next available sequential numeric ID for configuration files.
 899
 900        This method scans the pipeline directory for existing configuration files
 901        matching `*_config.json`, extracts their numeric prefixes, and determines
 902        the next sequence number.
 903
 904        Args:
 905            pipeline_dir (str): Path to the pipeline directory where config files are stored.
 906
 907        Returns:
 908            int: The next available numeric ID for a new configuration file.
 909        """
 910        # Pattern used to search for existing pipeline configuration files
 911        config_pattern = os.path.join(pipeline_dir, "*_config.json")
 912        existing_configs = glob.glob(config_pattern)
 913
 914        # If no config files exist, start numbering from 1
 915        if not existing_configs:
 916            return 1
 917
 918        existing_ids = []  # Stores all numeric IDs extracted from existing filenames
 919
 920        # Iterate through all existing config files to extract their numeric IDs
 921        for config_file in existing_configs:
 922            filename = os.path.basename(config_file)  # Example: "12_config.json"
 923            try:
 924                # Extract the numeric part before "_config.json"
 925                id_str = filename.split("_")[0]  # → "12"
 926                config_id = int(id_str)  # Convert to integer
 927                existing_ids.append(config_id)
 928            except (ValueError, IndexError):
 929                # Skip any files that don't follow the expected naming format
 930                continue
 931
 932        # If no valid numeric IDs were found, default to 1
 933        if not existing_ids:
 934            return 1
 935
 936        # Return the next sequential ID (maximum existing + 1)
 937        return max(existing_ids) + 1
 938
 939    def on_enter(self):
 940        """
 941        Triggered when the application navigates to this page.
 942        Refreshes the patient status to ensure file availability is up to date.
 943        """
 944        self._refresh_patient_status()
 945
 946    def is_ready_to_advance(self):
 947        """
 948        Checks if the user can proceed to the next page.
 949
 950        Returns:
 951            bool: True if at least one patient has been selected, False otherwise.
 952        """
 953        return len(self.selected_patients) > 0
 954
 955    def is_ready_to_go_back(self):
 956        """
 957        Checks if the user can navigate back to the previous page.
 958
 959        Returns:
 960            bool: Always True for this page.
 961        """
 962        return True
 963
 964    def next(self, context):
 965        """
 966        Proceeds to the next page in the workflow.
 967
 968        This method builds the pipeline configuration file based on the selection
 969        and initializes the `PipelineReviewPage`.
 970
 971        Args:
 972            context (dict): The application context.
 973
 974        Returns:
 975            Page: The instance of the next page to be displayed.
 976        """
 977        # Generate a new pipeline configuration file before continuing
 978        self._build_pipeline_config()
 979
 980        # If a next page already exists, reuse it
 981        if self.next_page:
 982            self.next_page.on_enter()
 983            return self.next_page
 984        else:
 985            # Otherwise, create a new review page and register it in navigation history
 986            self.next_page = PipelineReviewPage(context, self)
 987            self.context["history"].append(self.next_page)
 988            self.next_page.on_enter()
 989            return self.next_page
 990
 991    def back(self):
 992        """
 993        Navigates back to the previous page.
 994
 995        Returns:
 996            Page: The previous page instance, or None if no previous page exists.
 997        """
 998        if self.previous_page:
 999            self.previous_page.on_enter()
1000            return self.previous_page
1001        return None
1002
1003    def get_selected_patients(self):
1004        """
1005        Retrieves the IDs of all currently selected patients.
1006
1007        Returns:
1008            list: A list of strings representing patient IDs.
1009        """
1010        return list(self.selected_patients)
1011
1012    def get_eligible_patients(self):
1013        """
1014        Retrieves the IDs of all patients marked as eligible.
1015
1016        Returns:
1017            list: A list of strings representing eligible patient IDs.
1018        """
1019        return [pid for pid, status in self.patient_status.items() if status['eligible']]
1020
1021    def get_patient_status_summary(self):
1022        """
1023        Calculates a statistical summary of patient eligibility.
1024
1025        Returns:
1026            dict: A dictionary containing:
1027                - 'total': Total number of patients scanned.
1028                - 'eligible': Number of patients meeting requirements.
1029                - 'selected': Number of currently selected patients.
1030                - 'not_eligible': Number of patients missing requirements.
1031        """
1032        total = len(self.patient_status)  # Count total patients being tracked
1033        eligible = len([s for s in self.patient_status.values() if s['eligible']])  # Count eligible ones
1034        selected = len(self.selected_patients)  # Count selected patients
1035
1036        # Return a dictionary summarizing all relevant counts
1037        return {
1038            'total': total,
1039            'eligible': eligible,
1040            'selected': selected,
1041            'not_eligible': total - eligible
1042        }
1043
1044    def reset_page(self):
1045        """
1046        Resets the page to its initial state.
1047
1048        This clears the grid, selections, and internal status data, then re-scans
1049        the workspace for patients.
1050        """
1051        # Clear all widgets from the grid layout
1052        while self.grid_layout.count():
1053            item = self.grid_layout.takeAt(0)
1054            widget = item.widget()
1055            if widget is not None:
1056                widget.setParent(None)  # Detach widget from layout
1057                widget.deleteLater()  # Schedule for deletion
1058
1059        # Reset all patient-related states
1060        self.selected_patients.clear()  # Clear selected patients
1061        self.patient_buttons.clear()  # Clear patient button references
1062        self.patient_status.clear()  # Clear patient status dictionary
1063
1064        # Reload all patient data from the workspace
1065        self._load_patients()
1066
1067    def resizeEvent(self, event):
1068        """
1069        Handles window resize events to make the UI responsive.
1070
1071        Adjusts the number of columns in the grid, font sizes, and button padding
1072        based on the window height and width.
1073
1074        Args:
1075            event (QResizeEvent): The resize event.
1076        """
1077        super().resizeEvent(event)
1078
1079        # Update column count dynamically based on width
1080        self._update_column_count()
1081
1082        height = self.height()  # Current window height
1083
1084        # Adjust UI style dynamically for small or large window height
1085        if height < 500:
1086            # Compact layout for small windows
1087            self.title.setStyleSheet("font-size: 14px; font-weight: bold; margin-bottom: 5px;")
1088            btn_padding = "5px 10px"
1089            font_size_btn = 11
1090            max_pill_height = 70
1091            font_size_title = 12
1092            font_size_value = 14
1093            font_size_label = 10
1094            max_pill_height = 60
1095        else:
1096            # Normal layout for larger windows
1097            self.title.setStyleSheet("font-size: 18px; font-weight: bold; margin-bottom: 10px;")
1098            btn_padding = "10px 20px"
1099            font_size_btn = 13
1100            font_size_title = 16
1101            font_size_value = 20
1102            font_size_label = 13
1103            max_pill_height = 100
1104
1105        # Update button styles dynamically
1106        for btn in self.buttons:
1107            btn.setStyleSheet(f"""
1108                QPushButton {{
1109                    background-color: #e0e0e0;
1110                    padding: {btn_padding};
1111                    border-radius: 10px;
1112                    border: 1px solid #bdc3c7;
1113                    font-weight: bold;
1114                    font-size: {font_size_btn}px;
1115                    margin: 2px;
1116                }}
1117                QPushButton:hover {{
1118                    background-color: #d0d0d0;
1119                }}
1120                QPushButton:disabled {{
1121                    background-color: #f0f0f0;
1122                    color: #888888;
1123                }}
1124            """)
1125
1126        # Update summary title styling
1127        self.title_summary.setStyleSheet(
1128            f"font-size: {font_size_title}px; font-weight: bold; color: #000000; margin-bottom: 8px;"
1129        )
1130
1131        # Update pill-style status indicators (total, eligible, not eligible)
1132        for pill in [self.total_label, self.eligible_label, self.not_eligible_label]:
1133            pill.label.setStyleSheet(f"font-size: {font_size_label}px; font-weight: bold;")
1134            pill.value_label.setStyleSheet(
1135                f"font-size: {font_size_value}px; font-weight: bold; color: {pill.value_label.palette().color(pill.value_label.foregroundRole()).name()};"
1136            )
1137            pill.setMaximumHeight(max_pill_height)  # Limit vertical space
1138
1139    def _translate_ui(self):
1140        """
1141        Translates all UI text elements to the current language.
1142
1143        Updates labels and buttons with localized strings using Qt's translation system.
1144        Triggers a patient list reload to refresh status texts within patient cards.
1145        """
1146        # Set translated texts for main title and control buttons
1147        self.title.setText(
1148            QCoreApplication.translate("PipelinePatientSelectionPage", "Select Patients for Pipeline Analysis"))
1149        self.select_eligible_btn.setText(
1150            QCoreApplication.translate("PipelinePatientSelectionPage", "Select All Eligible"))
1151        self.deselect_all_btn.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Deselect All"))
1152        self.refresh_btn.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Refresh Status"))
1153        self.title_summary.setText(
1154            QCoreApplication.translate("PipelinePatientSelectionPage", "Pipeline Requirements Summary"))
1155
1156        # Set translated labels for patient status summary
1157        self.total_label.label.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Total Patients"))
1158        self.eligible_label.label.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Eligible"))
1159        self.not_eligible_label.label.setText(
1160            QCoreApplication.translate("PipelinePatientSelectionPage", "Not Eligible"))
1161
1162        # Reload patient list after updating UI text
1163        self._load_patients()
log = <Logger GliAAns-UI (WARNING)>
class PipelinePatientSelectionPage(page.Page):
  20class PipelinePatientSelectionPage(Page):
  21    """
  22    This class scans the workspace for patient directories, verifies if the required
  23    imaging files (FLAIR, skull-stripped, segmentation) are present, and presents
  24    a visual grid of patient cards. Users can select eligible patients to proceed
  25    to the pipeline configuration stage.
  26
  27    Attributes:
  28        context (dict): The application context containing shared data and state.
  29        workspace_path (str): The root directory path containing patient data.
  30        patient_buttons (dict): Maps patient IDs to their selection `QPushButton`.
  31        selected_patients (set): A set of strings containing IDs of currently selected patients.
  32        patient_status (dict): Stores eligibility status and missing file details for each patient.
  33    """
  34
  35    def __init__(self, context=None, previous_page=None):
  36        """
  37        Initializes the PipelinePatientSelectionPage.
  38
  39        Args:
  40            context (dict, optional): The shared application context. Defaults to None.
  41            previous_page (Page, optional): The page instance navigated from. Defaults to None.
  42        """
  43        super().__init__()
  44
  45        self.context = context
  46        self.previous_page = previous_page
  47        self.next_page = None
  48
  49        # Path to the workspace directory containing patient data
  50        self.workspace_path = context["workspace_path"]
  51
  52        # Dictionaries to track patient widgets, selected patients, and their processing status
  53        self.patient_buttons = {}
  54        self.selected_patients = set()
  55        self.patient_status = {}  # Stores eligibility and missing data for each patient
  56
  57        # Initialize the user interface
  58        self._setup_ui()
  59
  60        # Set translations (for multilingual UI)
  61        self._translate_ui()
  62        if context and "language_changed" in context:
  63            # Connect language change signal for dynamic translation
  64            context["language_changed"].connect(self._translate_ui)
  65
  66    def _setup_ui(self):
  67        """
  68        Sets up the main layout and UI components.
  69
  70        This method initializes the title, the bulk selection control buttons,
  71        the scrollable area for the patient grid, and the statistical summary widget.
  72        """
  73        self.layout = QVBoxLayout(self)
  74
  75        # --- Title ---
  76        self.title = QLabel("Select Patients for Pipeline Analysis")
  77        self.title.setFont(QFont("Arial", 18, QFont.Weight.Bold))
  78        self.title.setAlignment(Qt.AlignmentFlag.AlignHCenter)
  79        self.layout.addWidget(self.title)
  80
  81        # --- Top buttons (bulk selection controls) ---
  82        top_buttons_layout = QHBoxLayout()
  83
  84        self.select_eligible_btn = QPushButton("Select All Eligible")
  85        self.deselect_all_btn = QPushButton("Deselect All")
  86        self.refresh_btn = QPushButton("Refresh Status")
  87
  88        self.buttons = [self.select_eligible_btn, self.deselect_all_btn, self.refresh_btn]
  89
  90        # Apply a consistent visual style to all buttons
  91        btn_style = """
  92            QPushButton {
  93                background-color: #e0e0e0;
  94                padding: 10px 20px;
  95                border-radius: 10px;
  96                border: 1px solid #bdc3c7;
  97                font-weight: bold;
  98                margin: 2px;
  99            }
 100            QPushButton:hover {
 101                background-color: #d0d0d0;
 102            }
 103            QPushButton:disabled {
 104                background-color: #f0f0f0;
 105                color: #888888;
 106            }
 107        """
 108        for btn in self.buttons:
 109            btn.setStyleSheet(btn_style)
 110
 111        # Connect buttons to actions
 112        self.select_eligible_btn.clicked.connect(self._select_all_eligible_patients)
 113        self.deselect_all_btn.clicked.connect(self._deselect_all_patients)
 114        self.refresh_btn.clicked.connect(self._refresh_patient_status)
 115
 116        # Add the buttons to layout with stretch for alignment
 117        top_buttons_layout.addStretch()
 118        top_buttons_layout.addWidget(self.select_eligible_btn)
 119        top_buttons_layout.addWidget(self.deselect_all_btn)
 120        top_buttons_layout.addWidget(self.refresh_btn)
 121        self.layout.addLayout(top_buttons_layout)
 122
 123        # --- Scroll area for displaying patients ---
 124        self.scroll_area = QScrollArea()
 125        self.scroll_area.setWidgetResizable(True)
 126        self.scroll_area.setStyleSheet("""
 127            QScrollArea {
 128                font-size: 13px;
 129                border: 1px solid #CCCCCC;
 130                border-radius: 10px;
 131                padding: 5px;
 132            }
 133        """)
 134
 135        # Container widget inside scroll area (grid of patient cards)
 136        self.scroll_content = QWidget()
 137        self.grid_layout = QGridLayout(self.scroll_content)
 138        self.scroll_area.setWidget(self.scroll_content)
 139
 140        # --- Summary panel showing total and eligible counts ---
 141        self.summary_widget = self._create_summary_widget()
 142
 143        # Add widgets to the main layout
 144        self.layout.addWidget(self.summary_widget)
 145        self.layout.addWidget(self.scroll_area)
 146
 147        # Number of patient columns per row in grid
 148        self.column_count = 2
 149
 150        # Load patient data and build UI
 151        self._load_patients()
 152
 153    def _create_summary_widget(self):
 154        """
 155        Creates a modern-style summary widget for displaying patient statistics.
 156
 157        Constructs a `QFrame` containing "pill" style cards that display the
 158        total number of patients, the number of eligible patients, and the
 159        number of ineligible patients.
 160
 161        Returns:
 162            QFrame: The constructed summary widget.
 163        """
 164        summary_frame = QFrame()
 165        summary_frame.setObjectName("summaryCard")
 166        summary_frame.setStyleSheet("""
 167            QFrame#summaryCard {
 168                border: 1px solid #e0e0e0;
 169                border-radius: 12px;
 170                background-color: #ffffff;
 171                padding: 0.1em;
 172            }
 173        """)
 174
 175        main_layout = QVBoxLayout(summary_frame)
 176
 177        # Section title
 178        self.title_summary = QLabel(QCoreApplication.translate("PipelinePatientSelectionPage", "Pipeline Requirements Summary"))
 179        self.title_summary.setAlignment(Qt.AlignmentFlag.AlignCenter)
 180        self.title_summary.setStyleSheet("font-size: 16px; font-weight: bold; color: #000000; margin-bottom: 10px;")
 181        main_layout.addWidget(self.title_summary)
 182
 183        # Horizontal layout for summary “pill” cards
 184        stats_layout = QHBoxLayout()
 185        stats_layout.setSpacing(15)
 186
 187        # Create pill cards showing totals
 188        self.total_label = self._create_stat_pill(resource_path("resources/icon_total.png"),
 189                                                  QCoreApplication.translate("PipelinePatientSelectionPage", "Total Patients"), "0")
 190        self.eligible_label = self._create_stat_pill(resource_path("resources/icon_check.png"),
 191                                                     QCoreApplication.translate("PipelinePatientSelectionPage", "Eligible"), "0", color="#27ae60")
 192        self.not_eligible_label = self._create_stat_pill(resource_path("resources/icon_cross.png"),
 193                                                         QCoreApplication.translate("PipelinePatientSelectionPage", "Not Eligible"), "0", color="#c0392b")
 194
 195        # Add pills to layout
 196        for pill in [self.total_label, self.eligible_label, self.not_eligible_label]:
 197            pill.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
 198            stats_layout.addWidget(pill)
 199
 200        main_layout.addLayout(stats_layout)
 201
 202        return summary_frame
 203
 204    def _create_stat_pill(self, icon_path, label_text, value_text, color="#000000"):
 205        """
 206        Creates a card-like label with an icon, text, and numeric value.
 207
 208        Args:
 209            icon_path (str): Path to the icon image (currently unused in the simplified implementation).
 210            label_text (str): The description label (e.g., "Eligible").
 211            value_text (str): The numeric value as a string.
 212            color (str, optional): Hex color code for the numeric value text. Defaults to "#000000".
 213
 214        Returns:
 215            QFrame: A frame widget representing the statistic pill.
 216        """
 217        pill = QFrame()
 218        pill.setObjectName("summaryCard")
 219        pill.setStyleSheet(f"""
 220            QFrame#summaryCard {{
 221                border: 1px solid #CCCCCC;
 222                border-radius: 10px;
 223                background-color: #f9f9f9;
 224            }}
 225        """)
 226
 227        layout = QVBoxLayout(pill)
 228        layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
 229
 230        # Text label (e.g., “Total Patients”)
 231        label = QLabel(label_text)
 232        label.setStyleSheet("font-size: 13px; font-weight: bold;")
 233        label.setAlignment(Qt.AlignmentFlag.AlignCenter)
 234
 235        # Numeric value (e.g., “5”)
 236        value = QLabel(value_text)
 237        value.setStyleSheet(f"font-size: 20px; font-weight: bold; color: {color};")
 238        value.setAlignment(Qt.AlignmentFlag.AlignCenter)
 239
 240        layout.addWidget(label)
 241        layout.addWidget(value)
 242
 243        # Keep reference to labels for later updates
 244        pill.label = label
 245        pill.value_label = value
 246
 247        pill.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
 248        pill.setMinimumHeight(50)
 249        pill.setMaximumHeight(120)
 250
 251        return pill
 252
 253    def _check_patient_requirements(self, patient_path, patient_id):
 254        """
 255        Checks whether a patient meets all necessary pipeline file requirements.
 256
 257        It looks for the existence of:
 258        1.  **FLAIR image**: `anat/*_flair.nii[.gz]`
 259        2.  **Skull-stripped image**: `derivatives/skullstrips/.../anat/*_brain.nii[.gz]`
 260        3.  **Segmentation/Mask**: `manual_masks` or `deep_learning_seg`
 261
 262        Args:
 263            patient_path (str): The absolute path to the patient's folder.
 264            patient_id (str): The unique identifier of the patient (e.g., 'sub-01').
 265
 266        Returns:
 267            dict: A dictionary containing:
 268                - `eligible` (bool): True if all files are present.
 269                - `requirements` (dict): Boolean status for each file type ('flair', 'skull_stripping', 'segmentation').
 270                - `missing_files` (list): List of translated strings describing missing components.
 271                - `segmentation_type` (str or None): The type of segmentation found ("Manual Mask" or "deep_learning_seg Segmentation").
 272        """
 273        # Each key corresponds to a necessary preprocessing step
 274        requirements = {
 275            'flair': False,
 276            'skull_stripping': False,
 277            'segmentation': False
 278        }
 279
 280        missing_files = []  # Collect missing components for display
 281
 282        # --- 1. Check for FLAIR anatomical image ---
 283        flair_patterns = [
 284            os.path.join(patient_path, "anat", "*_flair.nii"),
 285            os.path.join(patient_path, "anat", "*_flair.nii.gz")
 286        ]
 287
 288        flair_found = False
 289        for pattern in flair_patterns:
 290            if glob.glob(pattern):  # Search for matching files
 291                flair_found = True
 292                break
 293
 294        requirements['flair'] = flair_found
 295        if not flair_found:
 296            missing_files.append(QCoreApplication.translate(
 297                "PipelinePatientSelectionPage", "FLAIR image (anat/*_flair.nii[.gz])"))
 298
 299        # --- 2. Check for Skull-Stripped image ---
 300        skull_strip_patterns = [
 301            os.path.join(self.workspace_path, "derivatives", "skullstrips", patient_id, "anat", "*_brain.nii"),
 302            os.path.join(self.workspace_path, "derivatives", "skullstrips", patient_id, "anat", "*_brain.nii.gz")
 303        ]
 304
 305        skull_strip_found = False
 306        for pattern in skull_strip_patterns:
 307            if glob.glob(pattern):
 308                skull_strip_found = True
 309                break
 310
 311        requirements['skull_stripping'] = skull_strip_found
 312        if not skull_strip_found:
 313            missing_files.append(QCoreApplication.translate(
 314                "PipelinePatientSelectionPage", "Skull stripped image (derivatives/skullstrips/.../anat/*_brain.nii[.gz])"))
 315
 316        # --- 3. Check for Segmentation or Manual Mask ---
 317        segmentation_patterns = [
 318            # Manual masks
 319            os.path.join(self.workspace_path, "derivatives", "manual_masks", patient_id, "anat", "*_mask.nii"),
 320            os.path.join(self.workspace_path, "derivatives", "manual_masks", patient_id, "anat", "*_mask.nii.gz"),
 321            # Deep learning segmentation output
 322            os.path.join(self.workspace_path, "derivatives", "deep_learning_seg", patient_id, "anat", "*_seg.nii"),
 323            os.path.join(self.workspace_path, "derivatives", "deep_learning_seg", patient_id, "anat", "*_seg.nii.gz")
 324        ]
 325
 326        segmentation_found = False
 327        segmentation_type = None
 328        for pattern in segmentation_patterns:
 329            if glob.glob(pattern):
 330                segmentation_found = True
 331                # Identify segmentation source type
 332                if "manual_masks" in pattern:
 333                    segmentation_type = "Manual Mask"
 334                elif "deep_learning_seg" in pattern:
 335                    segmentation_type = "deep_learning_seg Segmentation"
 336                break
 337
 338        requirements['segmentation'] = segmentation_found
 339        if not segmentation_found:
 340            missing_files.append(QCoreApplication.translate(
 341                "PipelinePatientSelectionPage", "Segmentation (manual_masks/*_mask.nii[.gz] or deep_learning_seg /*_seg.nii[.gz])"))
 342
 343        # --- Final eligibility decision ---
 344        # Patient is eligible if all required files are present
 345        is_eligible = all(requirements.values())
 346
 347        # Return structured summary of findings
 348        return {
 349            'eligible': is_eligible,
 350            'requirements': requirements,
 351            'missing_files': missing_files,
 352            'segmentation_type': segmentation_type
 353        }
 354
 355    def _load_patients(self):
 356        """
 357        Loads patient directories and builds the UI grid.
 358
 359        This method:
 360        1. Identifies patient directories in the workspace.
 361        2. Verifies the eligibility of each patient via `_check_patient_requirements`.
 362        3. Populates the `grid_layout` with visual cards created by `_create_patient_frame`.
 363        4. Updates the summary statistics.
 364        """
 365        # Find all patient folders (sub-*)
 366        patient_dirs = self._find_patient_dirs()
 367        patient_dirs.sort()  # Sort alphabetically for visual consistency
 368
 369        # Clear the dictionaries of buttons and patient states
 370        self.patient_buttons.clear()
 371        self.patient_status.clear()
 372
 373        eligible_count = 0
 374        total_count = len(patient_dirs)
 375
 376        # Iterate over all found patients
 377        for i, patient_path in enumerate(patient_dirs):
 378            patient_id = os.path.basename(patient_path)
 379
 380            # Check the required conditions for each patient
 381            status = self._check_patient_requirements(patient_path, patient_id)
 382            self.patient_status[patient_id] = status
 383
 384            # Count eligible patients
 385            if status['eligible']:
 386                eligible_count += 1
 387
 388            # Create a card (frame) for the patient
 389            patient_frame = self._create_patient_frame(patient_id, patient_path, status)
 390
 391            # Insert the card into the grid layout
 392            self.grid_layout.addWidget(patient_frame, i // self.column_count, i % self.column_count)
 393
 394        # Update the statistical summary
 395        self._update_summary(eligible_count, total_count)
 396
 397    def _create_patient_frame(self, patient_id, patient_path, status):
 398        """
 399        Creates a UI card (QFrame) representing a single patient.
 400
 401        The card displays:
 402        - Patient ID and user icon.
 403        - Eligibility status (Green checkmark or Red cross).
 404        - Detailed checklist of requirements (FLAIR, Skull Strip, Segmentation).
 405        - A "Select" button (disabled if the patient is ineligible).
 406
 407        Args:
 408            patient_id (str): The ID of the patient.
 409            patient_path (str): The file system path to the patient data.
 410            status (dict): The eligibility status dict returned by `_check_patient_requirements`.
 411
 412        Returns:
 413            QFrame: The constructed patient card widget.
 414        """
 415        patient_frame = QFrame()
 416        patient_frame.setObjectName("patientCard")
 417        patient_frame.setMaximumHeight(140)  # Card height limit
 418        patient_frame.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
 419
 420        # Apply different visual style based on patient eligibility
 421        if status['eligible']:
 422            frame_style = """
 423                QFrame#patientCard {
 424                    border: 2px solid #4CAF50;
 425                    border-radius: 10px;
 426                    background-color: #f0fff0;
 427                    padding: 10px;
 428                    margin: 2px;
 429                }
 430            """
 431        else:
 432            frame_style = """
 433                QFrame#patientCard {
 434                    border: 2px solid #f44336;
 435                    border-radius: 10px;
 436                    background-color: #fff0f0;
 437                    padding: 10px;
 438                    margin: 2px;
 439                }
 440            """
 441
 442        patient_frame.setStyleSheet(frame_style)
 443
 444        # Main horizontal layout (left → right)
 445        patient_layout = QHBoxLayout(patient_frame)
 446
 447        # Left section: profile information
 448        profile_frame = QFrame()
 449        profile_frame.setFixedSize(150, 100)
 450        profile_layout = QVBoxLayout(profile_frame)
 451        profile_layout.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
 452
 453        # User image (icon)
 454        image = QLabel()
 455        pixmap = QtGui.QPixmap(resource_path("resources/user.png")).scaled(
 456            30, 30, Qt.AspectRatioMode.KeepAspectRatio,
 457            Qt.TransformationMode.SmoothTransformation
 458        )
 459        image.setPixmap(pixmap)
 460        image.setAlignment(Qt.AlignmentFlag.AlignCenter)
 461
 462        # Patient ID label
 463        patient_label = QLabel(f"{patient_id}")
 464        patient_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
 465        patient_label.setStyleSheet("font-weight: bold; font-size: 12px;")
 466
 467        # Status label (Eligible / Not eligible)
 468        if status['eligible']:
 469            status_label = QLabel(QCoreApplication.translate("PipelinePatientSelectionPage", "✓ Ready for Pipeline"))
 470            status_label.setStyleSheet("color: #4CAF50; font-weight: bold; font-size: 10px;")
 471        else:
 472            status_label = QLabel(QCoreApplication.translate("PipelinePatientSelectionPage", "✗ Missing Requirements"))
 473            status_label.setStyleSheet("color: #f44336; font-weight: bold; font-size: 10px;")
 474        status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
 475
 476        # Add widgets to the profile layout
 477        profile_layout.addWidget(image)
 478        profile_layout.addWidget(patient_label)
 479        profile_layout.addWidget(status_label)
 480
 481        # Center section: requirement details
 482        details_frame = QFrame()
 483        details_layout = QVBoxLayout(details_frame)
 484        details_layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)
 485
 486        # Status indicators for each required step
 487        req_indicators = []
 488        req_labels = {
 489            'flair': 'FLAIR',
 490            'skull_stripping': 'Skull Strip',
 491            'segmentation': QCoreApplication.translate("PipelinePatientSelectionPage", 'Segmentation')
 492        }
 493
 494        # Display ✓ or ✗ next to each requirement
 495        for req, label in req_labels.items():
 496            indicator = QLabel()
 497            if status['requirements'][req]:
 498                indicator.setText(f"✓ {label}")
 499                indicator.setStyleSheet("color: #4CAF50; font-size: 10px; padding: 1px;")
 500            else:
 501                indicator.setText(f"✗ {label}")
 502                indicator.setStyleSheet("color: #f44336; font-size: 10px; padding: 1px;")
 503            req_indicators.append(indicator)
 504            details_layout.addWidget(indicator)
 505
 506        # Show segmentation type if available
 507        if status['segmentation_type']:
 508            seg_type_label = QLabel(f"({status['segmentation_type']})")
 509            seg_type_label.setStyleSheet("color: #666666; font-size: 9px; font-style: italic;")
 510            details_layout.addWidget(seg_type_label)
 511
 512        # Right section: selection button
 513        button = QPushButton(QCoreApplication.translate("PipelinePatientSelectionPage", "Select"))
 514        button.setCheckable(True)  # Allows to toggle selection
 515
 516        if status['eligible']:
 517            # Style for active (eligible) button
 518            button.setStyleSheet("""
 519                QPushButton {
 520                    border-radius: 12px;
 521                    padding: 8px 16px;
 522                    background-color: #DADADA;
 523                    font-weight: bold;
 524                    min-width: 80px;
 525                }
 526                QPushButton:checked {
 527                    background-color: #4CAF50;
 528                    color: white;
 529                }
 530                QPushButton:hover {
 531                    background-color: #c0c0c0;
 532                }
 533                QPushButton:checked:hover {
 534                    background-color: #45a049;
 535                }
 536            """)
 537
 538            # Restore previous selection if it exists
 539            is_selected = patient_id in self.selected_patients
 540            button.setChecked(is_selected)
 541            button.setText(
 542                QCoreApplication.translate("PipelinePatientSelectionPage", "Selected")
 543                if is_selected else QCoreApplication.translate("PipelinePatientSelectionPage", "Select")
 544            )
 545
 546            # Connect button click to selection handler
 547            button.clicked.connect(lambda checked, pid=patient_id, btn=button: self._toggle_patient(pid, checked, btn))
 548        else:
 549            # If not eligible, disable the button
 550            button.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Not Eligible"))
 551            button.setEnabled(False)
 552            button.setStyleSheet("""
 553                QPushButton {
 554                    border-radius: 12px;
 555                    padding: 8px 16px;
 556                    background-color: #f0f0f0;
 557                    color: #888888;
 558                    font-weight: bold;
 559                    min-width: 80px;
 560                }
 561            """)
 562
 563        # Save reference of the button in the dictionary
 564        self.patient_buttons[patient_id] = button
 565
 566        # Assemble all sections into the main layout
 567        patient_layout.addWidget(profile_frame)
 568        patient_layout.addWidget(details_frame)
 569        patient_layout.addStretch()  # Flexible space
 570        patient_layout.addWidget(button)
 571
 572        return patient_frame
 573
 574    def _update_summary(self, eligible_count, total_count):
 575        """
 576        Updates the numeric values in the summary widget.
 577
 578        Args:
 579            eligible_count (int): Number of patients who met all requirements.
 580            total_count (int): Total number of patients found.
 581        """
 582        self.total_label.value_label.setText(str(total_count))
 583        self.eligible_label.value_label.setText(str(eligible_count))
 584        self.not_eligible_label.value_label.setText(str(total_count - eligible_count))
 585
 586    def _select_all_eligible_patients(self):
 587        """
 588        Action handler: Automatically selects all patients marked as eligible.
 589        Updates the main application buttons after selection.
 590        """
 591        for patient_id, button in self.patient_buttons.items():
 592            if self.patient_status[patient_id]['eligible'] and not button.isChecked():
 593                button.setChecked(True)
 594                button.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Selected"))
 595                self.selected_patients.add(patient_id)
 596        # Update main app buttons
 597        if self.context and "update_main_buttons" in self.context:
 598            self.context["update_main_buttons"]()
 599
 600    def _deselect_all_patients(self):
 601        """
 602        Action handler: Deselects all currently selected patients.
 603        Updates the main application buttons after deselection.
 604        """
 605        for patient_id, button in self.patient_buttons.items():
 606            if button.isChecked() and button.isEnabled():
 607                button.setChecked(False)
 608                button.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Select"))
 609                self.selected_patients.discard(patient_id)
 610        # Update main buttons
 611        if self.context and "update_main_buttons" in self.context:
 612            self.context["update_main_buttons"]()
 613
 614    def _refresh_patient_status(self):
 615        """
 616        Action handler: Reloads all patients and re-verifies their status.
 617
 618        This is useful if files have been added to the workspace while the
 619        application was open. It attempts to preserve existing selections if
 620        the patient remains eligible.
 621        """
 622        # Save current selections
 623        current_selections = self.selected_patients.copy()
 624
 625        # Remove existing widgets from the grid
 626        while self.grid_layout.count():
 627            item = self.grid_layout.takeAt(0)
 628            widget = item.widget()
 629            if widget:
 630                widget.setParent(None)
 631                widget.deleteLater()
 632
 633        # Reload patients and update status
 634        self._load_patients()
 635
 636        # Restore still-valid selections
 637        valid_selections = set()
 638        for patient_id in current_selections:
 639            if (patient_id in self.patient_status and
 640                    self.patient_status[patient_id]['eligible'] and
 641                    patient_id in self.patient_buttons):
 642                valid_selections.add(patient_id)
 643                button = self.patient_buttons[patient_id]
 644                button.setChecked(True)
 645                button.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Selected"))
 646
 647        self.selected_patients = valid_selections
 648
 649    def _toggle_patient(self, patient_id, is_selected, button):
 650        """
 651        Toggles the selection status of a single patient.
 652
 653        Args:
 654            patient_id (str): The ID of the patient.
 655            is_selected (bool): The new state (True for selected).
 656            button (QPushButton): The button widget triggering the event.
 657        """
 658        if is_selected:
 659            self.selected_patients.add(patient_id)
 660            button.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Selected"))
 661        else:
 662            self.selected_patients.discard(patient_id)
 663            button.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Select"))
 664        # Update main buttons
 665        if self.context and "update_main_buttons" in self.context:
 666            self.context["update_main_buttons"]()
 667
 668    def _find_patient_dirs(self):
 669        """
 670        Scans the workspace path for patient directories.
 671
 672        It looks for directories starting with "sub-" and excludes "derivatives"
 673        and "pipeline" directories.
 674
 675        Returns:
 676            list: A list of full paths to patient directories.
 677        """
 678        patient_dirs = []
 679
 680        for root, dirs, files in os.walk(self.workspace_path):
 681            # Exclude irrelevant folders
 682            if "derivatives" in dirs:
 683                dirs.remove("derivatives")
 684            if "pipeline" in dirs:
 685                dirs.remove("pipeline")
 686
 687            # Add directories starting with "sub-"
 688            for dir_name in dirs:
 689                if dir_name.startswith("sub-"):
 690                    full_path = os.path.join(root, dir_name)
 691                    patient_dirs.append(full_path)
 692
 693            # Prevent going deeper into sub-* directories
 694            dirs[:] = [d for d in dirs if not d.startswith("sub-")]
 695
 696        return patient_dirs
 697
 698    def _update_column_count(self):
 699        """
 700        Recalculates the number of grid columns based on the available viewport width.
 701
 702        If the calculated column count differs from the current count, the grid
 703        is reloaded to adjust the layout.
 704        """
 705        available_width = self.scroll_area.viewport().width() - 40
 706        min_card_width = 400  # Minimum width for a card
 707
 708        new_column_count = max(1, available_width // min_card_width)
 709
 710        # If column count changes, reload the grid
 711        if new_column_count != self.column_count:
 712            self.column_count = new_column_count
 713            self._reload_patient_grid()
 714
 715    def _reload_patient_grid(self):
 716        """
 717        Rebuilds the patient grid (e.g., after a resize) while maintaining selections.
 718        """
 719        selected = self.selected_patients.copy()
 720
 721        # Remove existing widgets
 722        while self.grid_layout.count():
 723            item = self.grid_layout.takeAt(0)
 724            widget = item.widget()
 725            if widget:
 726                widget.setParent(None)
 727                widget.deleteLater()
 728
 729        # Reload patients in the new layout
 730        self._load_patients()
 731
 732        # Restore valid selections
 733        valid_selections = set()
 734        for patient_id in selected:
 735            if (patient_id in self.patient_status and
 736                    self.patient_status[patient_id]['eligible'] and
 737                    patient_id in self.patient_buttons):
 738                valid_selections.add(patient_id)
 739                button = self.patient_buttons[patient_id]
 740                button.setChecked(True)
 741                button.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Selected"))
 742
 743        self.selected_patients = valid_selections
 744
 745    def _build_pipeline_config(self):
 746        """
 747        Generates and saves a JSON file containing the initial pipeline configuration.
 748
 749        This method iterates through selected patients, resolves relative paths for
 750        all required files (MRI, Skull-strip, PET, Masks), and detects ambiguities
 751        (multiple files found) which are flagged as `need_revision`.
 752
 753        The configuration is saved to `workspace/pipeline/XX_config.json`, where XX
 754        is an auto-incrementing sequence number.
 755
 756        Returns:
 757            str: The full path to the generated JSON configuration file.
 758        """
 759        config = {}  # Main dictionary that will store configuration data for all patients
 760
 761        # Iterate over all selected patients
 762        for patient_id in self.selected_patients:
 763            patient_entry = {}  # Holds the configuration for a single patient
 764            need_revision = False  # Flag indicating if manual review is required due to ambiguities
 765
 766            # ----------------------------------------------------
 767            # MRI (FLAIR)
 768            # ----------------------------------------------------
 769            flair_patterns = [
 770                os.path.join(self.workspace_path, patient_id, "anat", "*_flair.nii"),
 771                os.path.join(self.workspace_path, patient_id, "anat", "*_flair.nii.gz")
 772            ]
 773            flair_files = []
 774            # Search for FLAIR MRI files matching the defined patterns
 775            for p in flair_patterns:
 776                flair_files.extend(glob.glob(p))
 777            # If more than one FLAIR file exists, mark for manual revision
 778            if len(flair_files) > 1:
 779                need_revision = True
 780            # Store the relative path of the FLAIR file if found
 781            patient_entry["mri"] = os.path.relpath(flair_files[0], self.workspace_path) if flair_files else None
 782
 783            # ----------------------------------------------------
 784            # MRI Skull Stripped
 785            # ----------------------------------------------------
 786            mri_str_patterns = [
 787                os.path.join(self.workspace_path, "derivatives", "skullstrips", patient_id, "anat", "*_brain.nii"),
 788                os.path.join(self.workspace_path, "derivatives", "skullstrips", patient_id, "anat", "*_brain.nii.gz")
 789            ]
 790            mri_str_files = []
 791            # Search for preprocessed (skull-stripped) MRI files
 792            for p in mri_str_patterns:
 793                mri_str_files.extend(glob.glob(p))
 794            if len(mri_str_files) > 1:
 795                need_revision = True
 796            patient_entry["mri_str"] = os.path.relpath(mri_str_files[0], self.workspace_path) if mri_str_files else None
 797
 798            # ----------------------------------------------------
 799            # Static PET (Session 01)
 800            # ----------------------------------------------------
 801            pet_patterns = [
 802                os.path.join(self.workspace_path, patient_id, "ses-01", "pet", "*_pet.nii"),
 803                os.path.join(self.workspace_path, patient_id, "ses-01", "pet", "*_pet.nii.gz")
 804            ]
 805            pet_files = []
 806            # Locate static PET scans
 807            for p in pet_patterns:
 808                pet_files.extend(glob.glob(p))
 809            if len(pet_files) > 1:
 810                need_revision = True
 811            patient_entry["pet"] = os.path.relpath(pet_files[0], self.workspace_path) if pet_files else None
 812
 813            # ----------------------------------------------------
 814            # Dynamic PET (4D, optional)
 815            # ----------------------------------------------------
 816            pet4d_patterns = [
 817                os.path.join(self.workspace_path, patient_id, "ses-02", "pet", "*_pet.nii"),
 818                os.path.join(self.workspace_path, patient_id, "ses-02", "pet", "*_pet.nii.gz")
 819            ]
 820            pet4d_files = []
 821            # Locate dynamic PET files (4D data)
 822            for p in pet4d_patterns:
 823                pet4d_files.extend(glob.glob(p))
 824            if len(pet4d_files) > 1:
 825                need_revision = True
 826            pet4d_file = pet4d_files[0] if pet4d_files else None
 827            patient_entry["pet4d"] = os.path.relpath(pet4d_files[0], self.workspace_path) if pet4d_files else None
 828
 829            # ----------------------------------------------------
 830            # PET JSON sidecar (metadata for dynamic PET)
 831            # ----------------------------------------------------
 832            pet_json_file = None
 833            if pet4d_file:
 834                # Derive the expected .json filename from the PET file prefix
 835                basename = os.path.basename(pet4d_file)
 836                stem_no_ext = basename.split('.')[0]
 837                candidate = os.path.join(os.path.dirname(pet4d_file), stem_no_ext + '.json')
 838                if os.path.exists(candidate):
 839                    pet_json_file = candidate
 840            # Store relative path to PET JSON file if it exists
 841            patient_entry["pet4d_json"] = os.path.relpath(pet_json_file, self.workspace_path) if pet_json_file else None
 842
 843            # ----------------------------------------------------
 844            # Tumor MRI Mask (manual or automated)
 845            # ----------------------------------------------------
 846            tumor_patterns = [
 847                # Manual segmentation masks
 848                os.path.join(self.workspace_path, "derivatives", "manual_masks", patient_id, "anat", "*_mask.nii"),
 849                os.path.join(self.workspace_path, "derivatives", "manual_masks", patient_id, "anat", "*_mask.nii.gz"),
 850                # Deep learning-based segmentations
 851                os.path.join(self.workspace_path, "derivatives", "deep_learning_seg", patient_id, "anat",
 852                             "*_seg.nii"),
 853                os.path.join(self.workspace_path, "derivatives", "deep_learning_seg", patient_id, "anat",
 854                             "*_seg.nii.gz")
 855            ]
 856            tumor_files = []
 857            # Search for tumor mask files
 858            for p in tumor_patterns:
 859                tumor_files.extend(glob.glob(p))
 860            if len(tumor_files) > 1:
 861                need_revision = True
 862            patient_entry["tumor_mri"] = os.path.relpath(tumor_files[0], self.workspace_path) if tumor_files else None
 863
 864            # Add the final review flag to indicate if manual review is needed
 865            patient_entry["need_revision"] = need_revision
 866
 867            # Store the patient's configuration in the main config dictionary
 868            config[patient_id] = patient_entry
 869
 870        # ----------------------------------------------------
 871        # Create and save the pipeline configuration JSON file
 872        # ----------------------------------------------------
 873        pipeline_dir = os.path.join(self.workspace_path, "pipeline")
 874
 875        # Create the 'pipeline' directory if it doesn't exist
 876        if not os.path.exists(pipeline_dir):
 877            os.makedirs(pipeline_dir)
 878            log.info(f"Created pipeline directory: {pipeline_dir}")
 879
 880        # Determine the next sequential ID number (e.g., 01, 02, 03, ...)
 881        config_id = self._get_next_config_id(pipeline_dir)
 882
 883        # Build the filename using the sequential ID
 884        filename = f"{config_id:02d}_config.json"
 885        output_path = os.path.join(pipeline_dir, filename)
 886
 887        # Save the configuration dictionary as a formatted JSON file
 888        with open(output_path, "w", encoding="utf-8") as f:
 889            json.dump(config, f, indent=4)
 890
 891        # Log the output path for reference
 892        log.info(f"Pipeline configuration saved to: {output_path}")
 893
 894        # Return the full path to the generated configuration file
 895        return output_path
 896
 897    def _get_next_config_id(self, pipeline_dir):
 898        """
 899        Finds the next available sequential numeric ID for configuration files.
 900
 901        This method scans the pipeline directory for existing configuration files
 902        matching `*_config.json`, extracts their numeric prefixes, and determines
 903        the next sequence number.
 904
 905        Args:
 906            pipeline_dir (str): Path to the pipeline directory where config files are stored.
 907
 908        Returns:
 909            int: The next available numeric ID for a new configuration file.
 910        """
 911        # Pattern used to search for existing pipeline configuration files
 912        config_pattern = os.path.join(pipeline_dir, "*_config.json")
 913        existing_configs = glob.glob(config_pattern)
 914
 915        # If no config files exist, start numbering from 1
 916        if not existing_configs:
 917            return 1
 918
 919        existing_ids = []  # Stores all numeric IDs extracted from existing filenames
 920
 921        # Iterate through all existing config files to extract their numeric IDs
 922        for config_file in existing_configs:
 923            filename = os.path.basename(config_file)  # Example: "12_config.json"
 924            try:
 925                # Extract the numeric part before "_config.json"
 926                id_str = filename.split("_")[0]  # → "12"
 927                config_id = int(id_str)  # Convert to integer
 928                existing_ids.append(config_id)
 929            except (ValueError, IndexError):
 930                # Skip any files that don't follow the expected naming format
 931                continue
 932
 933        # If no valid numeric IDs were found, default to 1
 934        if not existing_ids:
 935            return 1
 936
 937        # Return the next sequential ID (maximum existing + 1)
 938        return max(existing_ids) + 1
 939
 940    def on_enter(self):
 941        """
 942        Triggered when the application navigates to this page.
 943        Refreshes the patient status to ensure file availability is up to date.
 944        """
 945        self._refresh_patient_status()
 946
 947    def is_ready_to_advance(self):
 948        """
 949        Checks if the user can proceed to the next page.
 950
 951        Returns:
 952            bool: True if at least one patient has been selected, False otherwise.
 953        """
 954        return len(self.selected_patients) > 0
 955
 956    def is_ready_to_go_back(self):
 957        """
 958        Checks if the user can navigate back to the previous page.
 959
 960        Returns:
 961            bool: Always True for this page.
 962        """
 963        return True
 964
 965    def next(self, context):
 966        """
 967        Proceeds to the next page in the workflow.
 968
 969        This method builds the pipeline configuration file based on the selection
 970        and initializes the `PipelineReviewPage`.
 971
 972        Args:
 973            context (dict): The application context.
 974
 975        Returns:
 976            Page: The instance of the next page to be displayed.
 977        """
 978        # Generate a new pipeline configuration file before continuing
 979        self._build_pipeline_config()
 980
 981        # If a next page already exists, reuse it
 982        if self.next_page:
 983            self.next_page.on_enter()
 984            return self.next_page
 985        else:
 986            # Otherwise, create a new review page and register it in navigation history
 987            self.next_page = PipelineReviewPage(context, self)
 988            self.context["history"].append(self.next_page)
 989            self.next_page.on_enter()
 990            return self.next_page
 991
 992    def back(self):
 993        """
 994        Navigates back to the previous page.
 995
 996        Returns:
 997            Page: The previous page instance, or None if no previous page exists.
 998        """
 999        if self.previous_page:
1000            self.previous_page.on_enter()
1001            return self.previous_page
1002        return None
1003
1004    def get_selected_patients(self):
1005        """
1006        Retrieves the IDs of all currently selected patients.
1007
1008        Returns:
1009            list: A list of strings representing patient IDs.
1010        """
1011        return list(self.selected_patients)
1012
1013    def get_eligible_patients(self):
1014        """
1015        Retrieves the IDs of all patients marked as eligible.
1016
1017        Returns:
1018            list: A list of strings representing eligible patient IDs.
1019        """
1020        return [pid for pid, status in self.patient_status.items() if status['eligible']]
1021
1022    def get_patient_status_summary(self):
1023        """
1024        Calculates a statistical summary of patient eligibility.
1025
1026        Returns:
1027            dict: A dictionary containing:
1028                - 'total': Total number of patients scanned.
1029                - 'eligible': Number of patients meeting requirements.
1030                - 'selected': Number of currently selected patients.
1031                - 'not_eligible': Number of patients missing requirements.
1032        """
1033        total = len(self.patient_status)  # Count total patients being tracked
1034        eligible = len([s for s in self.patient_status.values() if s['eligible']])  # Count eligible ones
1035        selected = len(self.selected_patients)  # Count selected patients
1036
1037        # Return a dictionary summarizing all relevant counts
1038        return {
1039            'total': total,
1040            'eligible': eligible,
1041            'selected': selected,
1042            'not_eligible': total - eligible
1043        }
1044
1045    def reset_page(self):
1046        """
1047        Resets the page to its initial state.
1048
1049        This clears the grid, selections, and internal status data, then re-scans
1050        the workspace for patients.
1051        """
1052        # Clear all widgets from the grid layout
1053        while self.grid_layout.count():
1054            item = self.grid_layout.takeAt(0)
1055            widget = item.widget()
1056            if widget is not None:
1057                widget.setParent(None)  # Detach widget from layout
1058                widget.deleteLater()  # Schedule for deletion
1059
1060        # Reset all patient-related states
1061        self.selected_patients.clear()  # Clear selected patients
1062        self.patient_buttons.clear()  # Clear patient button references
1063        self.patient_status.clear()  # Clear patient status dictionary
1064
1065        # Reload all patient data from the workspace
1066        self._load_patients()
1067
1068    def resizeEvent(self, event):
1069        """
1070        Handles window resize events to make the UI responsive.
1071
1072        Adjusts the number of columns in the grid, font sizes, and button padding
1073        based on the window height and width.
1074
1075        Args:
1076            event (QResizeEvent): The resize event.
1077        """
1078        super().resizeEvent(event)
1079
1080        # Update column count dynamically based on width
1081        self._update_column_count()
1082
1083        height = self.height()  # Current window height
1084
1085        # Adjust UI style dynamically for small or large window height
1086        if height < 500:
1087            # Compact layout for small windows
1088            self.title.setStyleSheet("font-size: 14px; font-weight: bold; margin-bottom: 5px;")
1089            btn_padding = "5px 10px"
1090            font_size_btn = 11
1091            max_pill_height = 70
1092            font_size_title = 12
1093            font_size_value = 14
1094            font_size_label = 10
1095            max_pill_height = 60
1096        else:
1097            # Normal layout for larger windows
1098            self.title.setStyleSheet("font-size: 18px; font-weight: bold; margin-bottom: 10px;")
1099            btn_padding = "10px 20px"
1100            font_size_btn = 13
1101            font_size_title = 16
1102            font_size_value = 20
1103            font_size_label = 13
1104            max_pill_height = 100
1105
1106        # Update button styles dynamically
1107        for btn in self.buttons:
1108            btn.setStyleSheet(f"""
1109                QPushButton {{
1110                    background-color: #e0e0e0;
1111                    padding: {btn_padding};
1112                    border-radius: 10px;
1113                    border: 1px solid #bdc3c7;
1114                    font-weight: bold;
1115                    font-size: {font_size_btn}px;
1116                    margin: 2px;
1117                }}
1118                QPushButton:hover {{
1119                    background-color: #d0d0d0;
1120                }}
1121                QPushButton:disabled {{
1122                    background-color: #f0f0f0;
1123                    color: #888888;
1124                }}
1125            """)
1126
1127        # Update summary title styling
1128        self.title_summary.setStyleSheet(
1129            f"font-size: {font_size_title}px; font-weight: bold; color: #000000; margin-bottom: 8px;"
1130        )
1131
1132        # Update pill-style status indicators (total, eligible, not eligible)
1133        for pill in [self.total_label, self.eligible_label, self.not_eligible_label]:
1134            pill.label.setStyleSheet(f"font-size: {font_size_label}px; font-weight: bold;")
1135            pill.value_label.setStyleSheet(
1136                f"font-size: {font_size_value}px; font-weight: bold; color: {pill.value_label.palette().color(pill.value_label.foregroundRole()).name()};"
1137            )
1138            pill.setMaximumHeight(max_pill_height)  # Limit vertical space
1139
1140    def _translate_ui(self):
1141        """
1142        Translates all UI text elements to the current language.
1143
1144        Updates labels and buttons with localized strings using Qt's translation system.
1145        Triggers a patient list reload to refresh status texts within patient cards.
1146        """
1147        # Set translated texts for main title and control buttons
1148        self.title.setText(
1149            QCoreApplication.translate("PipelinePatientSelectionPage", "Select Patients for Pipeline Analysis"))
1150        self.select_eligible_btn.setText(
1151            QCoreApplication.translate("PipelinePatientSelectionPage", "Select All Eligible"))
1152        self.deselect_all_btn.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Deselect All"))
1153        self.refresh_btn.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Refresh Status"))
1154        self.title_summary.setText(
1155            QCoreApplication.translate("PipelinePatientSelectionPage", "Pipeline Requirements Summary"))
1156
1157        # Set translated labels for patient status summary
1158        self.total_label.label.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Total Patients"))
1159        self.eligible_label.label.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Eligible"))
1160        self.not_eligible_label.label.setText(
1161            QCoreApplication.translate("PipelinePatientSelectionPage", "Not Eligible"))
1162
1163        # Reload patient list after updating UI text
1164        self._load_patients()

This class scans the workspace for patient directories, verifies if the required imaging files (FLAIR, skull-stripped, segmentation) are present, and presents a visual grid of patient cards. Users can select eligible patients to proceed to the pipeline configuration stage.

Attributes: context (dict): The application context containing shared data and state. workspace_path (str): The root directory path containing patient data. patient_buttons (dict): Maps patient IDs to their selection QPushButton. selected_patients (set): A set of strings containing IDs of currently selected patients. patient_status (dict): Stores eligibility status and missing file details for each patient.

PipelinePatientSelectionPage(context=None, previous_page=None)
35    def __init__(self, context=None, previous_page=None):
36        """
37        Initializes the PipelinePatientSelectionPage.
38
39        Args:
40            context (dict, optional): The shared application context. Defaults to None.
41            previous_page (Page, optional): The page instance navigated from. Defaults to None.
42        """
43        super().__init__()
44
45        self.context = context
46        self.previous_page = previous_page
47        self.next_page = None
48
49        # Path to the workspace directory containing patient data
50        self.workspace_path = context["workspace_path"]
51
52        # Dictionaries to track patient widgets, selected patients, and their processing status
53        self.patient_buttons = {}
54        self.selected_patients = set()
55        self.patient_status = {}  # Stores eligibility and missing data for each patient
56
57        # Initialize the user interface
58        self._setup_ui()
59
60        # Set translations (for multilingual UI)
61        self._translate_ui()
62        if context and "language_changed" in context:
63            # Connect language change signal for dynamic translation
64            context["language_changed"].connect(self._translate_ui)

Initializes the PipelinePatientSelectionPage.

Args: context (dict, optional): The shared application context. Defaults to None. previous_page (Page, optional): The page instance navigated from. Defaults to None.

context
previous_page
next_page
workspace_path
patient_buttons
selected_patients
patient_status
def on_enter(self):
940    def on_enter(self):
941        """
942        Triggered when the application navigates to this page.
943        Refreshes the patient status to ensure file availability is up to date.
944        """
945        self._refresh_patient_status()

Triggered when the application navigates to this page. Refreshes the patient status to ensure file availability is up to date.

def is_ready_to_advance(self):
947    def is_ready_to_advance(self):
948        """
949        Checks if the user can proceed to the next page.
950
951        Returns:
952            bool: True if at least one patient has been selected, False otherwise.
953        """
954        return len(self.selected_patients) > 0

Checks if the user can proceed to the next page.

Returns: bool: True if at least one patient has been selected, False otherwise.

def is_ready_to_go_back(self):
956    def is_ready_to_go_back(self):
957        """
958        Checks if the user can navigate back to the previous page.
959
960        Returns:
961            bool: Always True for this page.
962        """
963        return True

Checks if the user can navigate back to the previous page.

Returns: bool: Always True for this page.

def next(self, context):
965    def next(self, context):
966        """
967        Proceeds to the next page in the workflow.
968
969        This method builds the pipeline configuration file based on the selection
970        and initializes the `PipelineReviewPage`.
971
972        Args:
973            context (dict): The application context.
974
975        Returns:
976            Page: The instance of the next page to be displayed.
977        """
978        # Generate a new pipeline configuration file before continuing
979        self._build_pipeline_config()
980
981        # If a next page already exists, reuse it
982        if self.next_page:
983            self.next_page.on_enter()
984            return self.next_page
985        else:
986            # Otherwise, create a new review page and register it in navigation history
987            self.next_page = PipelineReviewPage(context, self)
988            self.context["history"].append(self.next_page)
989            self.next_page.on_enter()
990            return self.next_page

Proceeds to the next page in the workflow.

This method builds the pipeline configuration file based on the selection and initializes the PipelineReviewPage.

Args: context (dict): The application context.

Returns: Page: The instance of the next page to be displayed.

def back(self):
 992    def back(self):
 993        """
 994        Navigates back to the previous page.
 995
 996        Returns:
 997            Page: The previous page instance, or None if no previous page exists.
 998        """
 999        if self.previous_page:
1000            self.previous_page.on_enter()
1001            return self.previous_page
1002        return None

Navigates back to the previous page.

Returns: Page: The previous page instance, or None if no previous page exists.

def get_selected_patients(self):
1004    def get_selected_patients(self):
1005        """
1006        Retrieves the IDs of all currently selected patients.
1007
1008        Returns:
1009            list: A list of strings representing patient IDs.
1010        """
1011        return list(self.selected_patients)

Retrieves the IDs of all currently selected patients.

Returns: list: A list of strings representing patient IDs.

def get_eligible_patients(self):
1013    def get_eligible_patients(self):
1014        """
1015        Retrieves the IDs of all patients marked as eligible.
1016
1017        Returns:
1018            list: A list of strings representing eligible patient IDs.
1019        """
1020        return [pid for pid, status in self.patient_status.items() if status['eligible']]

Retrieves the IDs of all patients marked as eligible.

Returns: list: A list of strings representing eligible patient IDs.

def get_patient_status_summary(self):
1022    def get_patient_status_summary(self):
1023        """
1024        Calculates a statistical summary of patient eligibility.
1025
1026        Returns:
1027            dict: A dictionary containing:
1028                - 'total': Total number of patients scanned.
1029                - 'eligible': Number of patients meeting requirements.
1030                - 'selected': Number of currently selected patients.
1031                - 'not_eligible': Number of patients missing requirements.
1032        """
1033        total = len(self.patient_status)  # Count total patients being tracked
1034        eligible = len([s for s in self.patient_status.values() if s['eligible']])  # Count eligible ones
1035        selected = len(self.selected_patients)  # Count selected patients
1036
1037        # Return a dictionary summarizing all relevant counts
1038        return {
1039            'total': total,
1040            'eligible': eligible,
1041            'selected': selected,
1042            'not_eligible': total - eligible
1043        }

Calculates a statistical summary of patient eligibility.

Returns: dict: A dictionary containing: - 'total': Total number of patients scanned. - 'eligible': Number of patients meeting requirements. - 'selected': Number of currently selected patients. - 'not_eligible': Number of patients missing requirements.

def reset_page(self):
1045    def reset_page(self):
1046        """
1047        Resets the page to its initial state.
1048
1049        This clears the grid, selections, and internal status data, then re-scans
1050        the workspace for patients.
1051        """
1052        # Clear all widgets from the grid layout
1053        while self.grid_layout.count():
1054            item = self.grid_layout.takeAt(0)
1055            widget = item.widget()
1056            if widget is not None:
1057                widget.setParent(None)  # Detach widget from layout
1058                widget.deleteLater()  # Schedule for deletion
1059
1060        # Reset all patient-related states
1061        self.selected_patients.clear()  # Clear selected patients
1062        self.patient_buttons.clear()  # Clear patient button references
1063        self.patient_status.clear()  # Clear patient status dictionary
1064
1065        # Reload all patient data from the workspace
1066        self._load_patients()

Resets the page to its initial state.

This clears the grid, selections, and internal status data, then re-scans the workspace for patients.

def resizeEvent(self, event):
1068    def resizeEvent(self, event):
1069        """
1070        Handles window resize events to make the UI responsive.
1071
1072        Adjusts the number of columns in the grid, font sizes, and button padding
1073        based on the window height and width.
1074
1075        Args:
1076            event (QResizeEvent): The resize event.
1077        """
1078        super().resizeEvent(event)
1079
1080        # Update column count dynamically based on width
1081        self._update_column_count()
1082
1083        height = self.height()  # Current window height
1084
1085        # Adjust UI style dynamically for small or large window height
1086        if height < 500:
1087            # Compact layout for small windows
1088            self.title.setStyleSheet("font-size: 14px; font-weight: bold; margin-bottom: 5px;")
1089            btn_padding = "5px 10px"
1090            font_size_btn = 11
1091            max_pill_height = 70
1092            font_size_title = 12
1093            font_size_value = 14
1094            font_size_label = 10
1095            max_pill_height = 60
1096        else:
1097            # Normal layout for larger windows
1098            self.title.setStyleSheet("font-size: 18px; font-weight: bold; margin-bottom: 10px;")
1099            btn_padding = "10px 20px"
1100            font_size_btn = 13
1101            font_size_title = 16
1102            font_size_value = 20
1103            font_size_label = 13
1104            max_pill_height = 100
1105
1106        # Update button styles dynamically
1107        for btn in self.buttons:
1108            btn.setStyleSheet(f"""
1109                QPushButton {{
1110                    background-color: #e0e0e0;
1111                    padding: {btn_padding};
1112                    border-radius: 10px;
1113                    border: 1px solid #bdc3c7;
1114                    font-weight: bold;
1115                    font-size: {font_size_btn}px;
1116                    margin: 2px;
1117                }}
1118                QPushButton:hover {{
1119                    background-color: #d0d0d0;
1120                }}
1121                QPushButton:disabled {{
1122                    background-color: #f0f0f0;
1123                    color: #888888;
1124                }}
1125            """)
1126
1127        # Update summary title styling
1128        self.title_summary.setStyleSheet(
1129            f"font-size: {font_size_title}px; font-weight: bold; color: #000000; margin-bottom: 8px;"
1130        )
1131
1132        # Update pill-style status indicators (total, eligible, not eligible)
1133        for pill in [self.total_label, self.eligible_label, self.not_eligible_label]:
1134            pill.label.setStyleSheet(f"font-size: {font_size_label}px; font-weight: bold;")
1135            pill.value_label.setStyleSheet(
1136                f"font-size: {font_size_value}px; font-weight: bold; color: {pill.value_label.palette().color(pill.value_label.foregroundRole()).name()};"
1137            )
1138            pill.setMaximumHeight(max_pill_height)  # Limit vertical space

Handles window resize events to make the UI responsive.

Adjusts the number of columns in the grid, font sizes, and button padding based on the window height and width.

Args: event (QResizeEvent): The resize event.