components.collapsible_patient_frame

  1import glob
  2import os
  3
  4from PyQt6.QtCore import pyqtSignal, Qt, QSize, QPropertyAnimation, QEasingCurve, QCoreApplication
  5from PyQt6.QtGui import QColor, QCursor
  6from PyQt6.QtWidgets import (
  7    QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QHBoxLayout,
  8    QLabel, QToolButton, QComboBox, QPushButton
  9)
 10
 11# ------------------------------
 12# Custom QFrame subclass that emits a signal when clicked.
 13# Used to make headers clickable.
 14# ------------------------------
 15class ClickableFrame(QFrame):
 16    # Signal emitted when the frame is clicked
 17    clicked = pyqtSignal()
 18
 19    def mousePressEvent(self, event):
 20        super().mousePressEvent(event)
 21        self.clicked.emit()  # Emit custom signal when frame is clicked
 22
 23
 24# ------------------------------
 25# Main collapsible frame for displaying patient data.
 26# It shows the patient name, available files (one or multiple),
 27# and supports saving a configuration per patient.
 28# ------------------------------
 29class CollapsiblePatientFrame(QFrame):
 30    def __init__(self, context, patient_id, files, patterns, multiple_choice=False, save_callback=None):
 31        super().__init__()
 32        self.patient_id = patient_id
 33        self.workspace_path = context["workspace_path"]  # Root folder of workspace
 34        self.patterns = patterns  # File search patterns per category (e.g. {"pet4d": ["*/PET4D*.nii.gz"]})
 35        self.files = files  # Dictionary of selected files per category
 36        self.multiple_choice = multiple_choice  # Whether multiple files are allowed to be chosen
 37        self.is_expanded = False  # State of the collapsible section
 38        self.category_widgets = {}  # To store widgets (like combos) per category
 39        self.save_callback = save_callback  # Function to call when saving
 40        self.locked = not multiple_choice  # If single-choice mode, automatically locked
 41
 42        self.setFrameShape(QFrame.Shape.StyledPanel)
 43        self.setObjectName("collapsiblePatientFrame")
 44
 45        # Add a subtle drop shadow to the frame for depth
 46        shadow = QGraphicsDropShadowEffect()
 47        shadow.setBlurRadius(8)
 48        shadow.setColor(QColor(0, 0, 0, 30))
 49        shadow.setOffset(0, 1)
 50        self.setGraphicsEffect(shadow)
 51
 52        # Build visual layout and connect signals
 53        self._build_ui()
 54        self._apply_style()
 55
 56        # Handle translation/localization
 57        self._translate_ui()
 58        if context and "language_changed" in context:
 59            context["language_changed"].connect(self._translate_ui)
 60
 61    # ------------------------------
 62    # Set visual style based on "locked" status.
 63    # Locked (white frame) = auto-selected
 64    # Unlocked (yellow frame) = requires manual selection.
 65    # ------------------------------
 66    def _apply_style(self):
 67        if self.locked:
 68            self.setStyleSheet("""
 69                QFrame#collapsiblePatientFrame {
 70                    background: white;
 71                    border: 1px solid #4CAF50;
 72                    border-radius: 10px;
 73                    padding: 10px;
 74                    margin: 2px;
 75                }
 76            """)
 77            self.toggle_button.setStyleSheet("""
 78                QToolButton {
 79                    font-size: 13px;
 80                    font-weight: bold;
 81                    color: #222;
 82                    border: none;
 83                    padding: 6px 8px 6px 4px;
 84                    text-align: right;
 85                    border-radius: 6px;
 86                }
 87                QToolButton:hover {
 88                    background-color: rgba(0, 0, 0, 0.05);
 89                }
 90                QToolButton:checked {
 91                    background-color: rgba(155, 155, 155, 0.15);
 92                }
 93            """)
 94        else:
 95            self.setStyleSheet("""
 96                QFrame#collapsiblePatientFrame {
 97                    border: 2px solid #FFC107;
 98                    border-radius: 10px;
 99                    background-color: #FFF8E1;
100                    padding: 10px;
101                    margin: 2px;
102                }
103            """)
104            self.toggle_button.setStyleSheet("""
105                QToolButton {
106                    font-size: 13px;
107                    font-weight: bold;
108                    color: #222;
109                    border: none;
110                    padding: 6px 8px 6px 4px;
111                    text-align: right;
112                    border-radius: 6px;
113                }
114                QToolButton:hover {
115                    background-color: rgba(0, 0, 0, 0.05);
116                }
117                QToolButton:checked {
118                    background-color: rgba(255, 193, 7, 0.15);
119                }
120            """)
121
122    # ------------------------------
123    # Build the UI layout and main widgets.
124    # Includes header, toggle button, collapsible content area.
125    # ------------------------------
126    def _build_ui(self):
127        layout = QVBoxLayout(self)
128        layout.setContentsMargins(6, 6, 6, 6)
129        layout.setSpacing(4)
130
131        # --- HEADER SECTION ---
132        frame_header = ClickableFrame(self)
133        frame_header_layout = QHBoxLayout(frame_header)
134
135        # Patient label
136        self.subject_name = QLabel(self)
137        self.subject_name.setText(QCoreApplication.translate("Components", "Patient: {0}").format(self.patient_id))
138        self.subject_name.setStyleSheet("font-size: 13px; font-weight: bold;")
139        frame_header_layout.addWidget(self.subject_name)
140
141        # Expand/collapse button
142        self.toggle_button = QToolButton(self)
143        self.toggle_button.setText(QCoreApplication.translate("Components", "Patient: {patient}").format(patient=self.patient_id))
144        self.toggle_button.setCheckable(True)
145        self.toggle_button.setChecked(False)
146        self.toggle_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
147        self.toggle_button.setArrowType(Qt.ArrowType.RightArrow)
148        self.toggle_button.setIconSize(QSize(14, 14))
149        self.toggle_button.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
150
151        # Connect toggle behavior
152        self.toggle_button.clicked.connect(self._toggle_expand)
153        frame_header.clicked.connect(self._on_header_clicked)
154
155        frame_header_layout.addWidget(self.toggle_button)
156        layout.addWidget(frame_header)
157
158        # --- COLLAPSIBLE CONTENT SECTION ---
159        self.content_frame = QFrame()
160        self.content_frame.setStyleSheet("QFrame { border-radius: 4px; padding: 4px; }")
161        self.content_frame.setMaximumHeight(0)  # Start collapsed
162        self.content_layout = QVBoxLayout(self.content_frame)
163        self.content_layout.setContentsMargins(8, 4, 8, 4)
164        self.content_layout.setSpacing(6)
165
166        self._populate_content()
167        layout.addWidget(self.content_frame)
168
169        # --- COLLAPSE/EXPAND ANIMATION ---
170        self.animation = QPropertyAnimation(self.content_frame, b"maximumHeight")
171        self.animation.setDuration(300)
172        self.animation.setEasingCurve(QEasingCurve.Type.InOutCubic)
173
174    # ------------------------------
175    # Populate the collapsible content area with file options.
176    # If locked → static label. If editable → combo box.
177    # ------------------------------
178    def _populate_content(self):
179        # Clear existing widgets
180        while self.content_layout.count():
181            item = self.content_layout.takeAt(0)
182            widget = item.widget()
183            if widget:
184                widget.deleteLater()
185
186        # For each category (like CT, PET4D, etc.)
187        for category, pat_list in self.patterns.items():
188            category_container = QFrame()
189            category_layout = QVBoxLayout(category_container)
190            category_layout.setContentsMargins(6, 4, 6, 4)
191            category_layout.setSpacing(4)
192
193            category_label = QLabel(category.replace("_", " ").title())
194            category_label.setStyleSheet("font-size: 13px; font-weight: bold;")
195
196            # Search for files matching the patterns
197            all_files = []
198            for pat in pat_list:
199                all_files.extend(glob.glob(pat))
200            all_files_rel = [os.path.relpath(f, self.workspace_path) for f in all_files]
201
202            # --- SINGLE FILE MODE (locked) ---
203            if self.locked:
204                self.chosen_file = self.files.get(category, "")
205                self.file_label = QLabel(self.chosen_file if self.chosen_file else QCoreApplication.translate("Components", "No file found"))
206                category_layout.addWidget(category_label)
207                category_layout.addWidget(self.file_label)
208
209                # If category is PET4D, also show JSON info
210                if category == "pet4d":
211                    self._add_pet4d_json_display(category_layout, self.chosen_file)
212
213            # --- MULTIPLE CHOICE MODE ---
214            else:
215                combo = QComboBox()
216                combo.setMinimumHeight(28)
217                combo.addItems(all_files_rel)
218                current_file = self.files.get(category)
219                if current_file in all_files_rel:
220                    combo.setCurrentIndex(all_files_rel.index(current_file))
221                elif all_files_rel:
222                    combo.setCurrentIndex(0)
223                self.category_widgets[category] = combo
224                category_layout.addWidget(category_label)
225                category_layout.addWidget(combo)
226
227                if category == "pet4d":
228                    # Display associated JSON dynamically
229                    self.pet4d_json_label = QLabel()
230                    self.pet4d_json_label.setWordWrap(True)
231                    category_layout.addWidget(self.pet4d_json_label)
232
233                    combo.currentIndexChanged.connect(self._update_pet4d_json_display)
234                    self._update_pet4d_json_display()
235
236            self.content_layout.addWidget(category_container)
237
238        # Add Save button if editable
239        if not self.locked:
240            save_container = QFrame()
241            save_layout = QHBoxLayout(save_container)
242            save_layout.setContentsMargins(6, 10, 6, 4)
243
244            self.save_btn = QPushButton(QCoreApplication.translate("Components", "Save Patient Configuration"))
245            self.save_btn.setMinimumHeight(32)
246            self.save_btn.setStyleSheet("""
247                QPushButton {
248                    font-size: 12px;
249                    font-weight: bold;
250                    background-color: #4CAF50;
251                    color: white;
252                    border-radius: 12px;
253                    padding: 8px 16px;
254                }
255                QPushButton:hover { background-color: #45a049; }
256            """)
257            self.save_btn.clicked.connect(self._save_patient)
258
259            save_layout.addStretch()
260            save_layout.addWidget(self.save_btn)
261            save_layout.addStretch()
262            self.content_layout.addWidget(save_container)
263
264    # ------------------------------
265    # Helper: show JSON file associated with PET4D data (locked mode).
266    # ------------------------------
267    def _add_pet4d_json_display(self, parent_layout, pet4d_file_rel):
268        if not pet4d_file_rel:
269            label = QLabel(QCoreApplication.translate("Components", "<span style='color:red;'>No PET4D file selected</span>"))
270            parent_layout.addWidget(label)
271            return
272
273        abs_pet4d_path = os.path.join(self.workspace_path, pet4d_file_rel)
274        json_candidate = abs_pet4d_path.replace(".nii.gz", ".json").replace(".nii", ".json")
275
276        if os.path.exists(json_candidate):
277            rel_json = os.path.relpath(json_candidate, self.workspace_path)
278            label = QLabel(QCoreApplication.translate("Components", "JSON related: <strong>{rel_json}</strong>").format(rel_json=rel_json))
279            label.setStyleSheet("color: black; font-size: 12px;")
280            self.files["pet4d_json"] = rel_json
281        else:
282            label = QLabel(QCoreApplication.translate("Components", "<span style='color:red;'>Error: associated JSON file not found</span>"))
283            self.files["pet4d_json"] = ""
284
285        label.setWordWrap(True)
286        parent_layout.addWidget(label)
287
288    # ------------------------------
289    # Update JSON label dynamically when PET4D combo changes.
290    # ------------------------------
291    def _update_pet4d_json_display(self):
292        if "pet4d" not in self.category_widgets:
293            return
294
295        combo = self.category_widgets["pet4d"]
296        selected_file = combo.currentText()
297        if not selected_file:
298            self.pet4d_json_label.setText(QCoreApplication.translate("Components", "<span style='color:red;'>No PET4D file selected</span>"))
299            return
300
301        abs_pet4d_path = os.path.join(self.workspace_path, selected_file)
302        json_candidate = abs_pet4d_path.replace(".nii.gz", ".json").replace(".nii", ".json")
303
304        if os.path.exists(json_candidate):
305            rel_json = os.path.relpath(json_candidate, self.workspace_path)
306            self.pet4d_json_label.setText(QCoreApplication.translate("Components", "JSON related: <strong>{rel_json}</strong>").format(rel_json=rel_json))
307            self.pet4d_json_label.setStyleSheet("color: black; font-size: 12px;")
308            self.files["pet4d_json"] = rel_json
309        else:
310            self.pet4d_json_label.setText(QCoreApplication.translate("Components", "<span style='color:red;'>Error: associated JSON file not found</span>"))
311            self.files["pet4d_json"] = ""
312
313    # ------------------------------
314    # Header click toggles expansion (alternative to pressing button).
315    # ------------------------------
316    def _on_header_clicked(self):
317        new_state = not self.toggle_button.isChecked()
318        self.toggle_button.setChecked(new_state)
319        self._toggle_expand(new_state)
320
321    # ------------------------------
322    # Animate expand/collapse of the content frame.
323    # ------------------------------
324    def _toggle_expand(self, checked):
325        self.is_expanded = checked
326        self.toggle_button.setArrowType(Qt.ArrowType.DownArrow if checked else Qt.ArrowType.RightArrow)
327        self.animation.setStartValue(self.content_frame.maximumHeight())
328        self.animation.setEndValue(self.content_frame.sizeHint().height() if checked else 0)
329        self.animation.start()
330
331    # ------------------------------
332    # Save current patient configuration (if editable).
333    # ------------------------------
334    def _save_patient(self):
335        # Save current combo selections
336        for category, combo in self.category_widgets.items():
337            self.files[category] = combo.currentText()
338
339        # Mark as reviewed
340        self.files["need_revision"] = False
341
342        # Call external save handler if provided
343        if self.save_callback:
344            self.save_callback(self.patient_id, self.files)
345
346        # Lock and refresh UI
347        self.locked = True
348        self._apply_style()
349        self._populate_content()
350
351    # ------------------------------
352    # Reapply translations dynamically.
353    # ------------------------------
354    def _translate_ui(self):
355        self.toggle_button.setText(QCoreApplication.translate("Components", "Patient: {patient}").format(patient=self.patient_id))
356        self.subject_name.setText(QCoreApplication.translate("Components", "Patient: {0}").format(self.patient_id))
357
358        if self.locked and hasattr(self, "file_label"):
359            self.file_label.setText(self.chosen_file if self.chosen_file else QCoreApplication.translate("Components", "No file found"))
360        if not self.locked:
361            self.save_btn.setText(QCoreApplication.translate("Components", "Save Patient Configuration"))
362
363        self._populate_content()
class ClickableFrame(PyQt6.QtWidgets.QFrame):
16class ClickableFrame(QFrame):
17    # Signal emitted when the frame is clicked
18    clicked = pyqtSignal()
19
20    def mousePressEvent(self, event):
21        super().mousePressEvent(event)
22        self.clicked.emit()  # Emit custom signal when frame is clicked

QFrame(parent: QWidget|None = None, flags: Qt.WindowType = Qt.WindowFlags())

def clicked(unknown):

pyqtSignal(*types, name: str = ..., revision: int = ..., arguments: Sequence = ...) -> PYQT_SIGNAL

types is normally a sequence of individual types. Each type is either a type object or a string that is the name of a C++ type. Alternatively each type could itself be a sequence of types each describing a different overloaded signal. name is the optional C++ name of the signal. If it is not specified then the name of the class attribute that is bound to the signal is used. revision is the optional revision of the signal that is exported to QML. If it is not specified then 0 is used. arguments is the optional sequence of the names of the signal's arguments.

def mousePressEvent(self, event):
20    def mousePressEvent(self, event):
21        super().mousePressEvent(event)
22        self.clicked.emit()  # Emit custom signal when frame is clicked

mousePressEvent(self, a0: QMouseEvent|None)

class CollapsiblePatientFrame(PyQt6.QtWidgets.QFrame):
 30class CollapsiblePatientFrame(QFrame):
 31    def __init__(self, context, patient_id, files, patterns, multiple_choice=False, save_callback=None):
 32        super().__init__()
 33        self.patient_id = patient_id
 34        self.workspace_path = context["workspace_path"]  # Root folder of workspace
 35        self.patterns = patterns  # File search patterns per category (e.g. {"pet4d": ["*/PET4D*.nii.gz"]})
 36        self.files = files  # Dictionary of selected files per category
 37        self.multiple_choice = multiple_choice  # Whether multiple files are allowed to be chosen
 38        self.is_expanded = False  # State of the collapsible section
 39        self.category_widgets = {}  # To store widgets (like combos) per category
 40        self.save_callback = save_callback  # Function to call when saving
 41        self.locked = not multiple_choice  # If single-choice mode, automatically locked
 42
 43        self.setFrameShape(QFrame.Shape.StyledPanel)
 44        self.setObjectName("collapsiblePatientFrame")
 45
 46        # Add a subtle drop shadow to the frame for depth
 47        shadow = QGraphicsDropShadowEffect()
 48        shadow.setBlurRadius(8)
 49        shadow.setColor(QColor(0, 0, 0, 30))
 50        shadow.setOffset(0, 1)
 51        self.setGraphicsEffect(shadow)
 52
 53        # Build visual layout and connect signals
 54        self._build_ui()
 55        self._apply_style()
 56
 57        # Handle translation/localization
 58        self._translate_ui()
 59        if context and "language_changed" in context:
 60            context["language_changed"].connect(self._translate_ui)
 61
 62    # ------------------------------
 63    # Set visual style based on "locked" status.
 64    # Locked (white frame) = auto-selected
 65    # Unlocked (yellow frame) = requires manual selection.
 66    # ------------------------------
 67    def _apply_style(self):
 68        if self.locked:
 69            self.setStyleSheet("""
 70                QFrame#collapsiblePatientFrame {
 71                    background: white;
 72                    border: 1px solid #4CAF50;
 73                    border-radius: 10px;
 74                    padding: 10px;
 75                    margin: 2px;
 76                }
 77            """)
 78            self.toggle_button.setStyleSheet("""
 79                QToolButton {
 80                    font-size: 13px;
 81                    font-weight: bold;
 82                    color: #222;
 83                    border: none;
 84                    padding: 6px 8px 6px 4px;
 85                    text-align: right;
 86                    border-radius: 6px;
 87                }
 88                QToolButton:hover {
 89                    background-color: rgba(0, 0, 0, 0.05);
 90                }
 91                QToolButton:checked {
 92                    background-color: rgba(155, 155, 155, 0.15);
 93                }
 94            """)
 95        else:
 96            self.setStyleSheet("""
 97                QFrame#collapsiblePatientFrame {
 98                    border: 2px solid #FFC107;
 99                    border-radius: 10px;
100                    background-color: #FFF8E1;
101                    padding: 10px;
102                    margin: 2px;
103                }
104            """)
105            self.toggle_button.setStyleSheet("""
106                QToolButton {
107                    font-size: 13px;
108                    font-weight: bold;
109                    color: #222;
110                    border: none;
111                    padding: 6px 8px 6px 4px;
112                    text-align: right;
113                    border-radius: 6px;
114                }
115                QToolButton:hover {
116                    background-color: rgba(0, 0, 0, 0.05);
117                }
118                QToolButton:checked {
119                    background-color: rgba(255, 193, 7, 0.15);
120                }
121            """)
122
123    # ------------------------------
124    # Build the UI layout and main widgets.
125    # Includes header, toggle button, collapsible content area.
126    # ------------------------------
127    def _build_ui(self):
128        layout = QVBoxLayout(self)
129        layout.setContentsMargins(6, 6, 6, 6)
130        layout.setSpacing(4)
131
132        # --- HEADER SECTION ---
133        frame_header = ClickableFrame(self)
134        frame_header_layout = QHBoxLayout(frame_header)
135
136        # Patient label
137        self.subject_name = QLabel(self)
138        self.subject_name.setText(QCoreApplication.translate("Components", "Patient: {0}").format(self.patient_id))
139        self.subject_name.setStyleSheet("font-size: 13px; font-weight: bold;")
140        frame_header_layout.addWidget(self.subject_name)
141
142        # Expand/collapse button
143        self.toggle_button = QToolButton(self)
144        self.toggle_button.setText(QCoreApplication.translate("Components", "Patient: {patient}").format(patient=self.patient_id))
145        self.toggle_button.setCheckable(True)
146        self.toggle_button.setChecked(False)
147        self.toggle_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
148        self.toggle_button.setArrowType(Qt.ArrowType.RightArrow)
149        self.toggle_button.setIconSize(QSize(14, 14))
150        self.toggle_button.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
151
152        # Connect toggle behavior
153        self.toggle_button.clicked.connect(self._toggle_expand)
154        frame_header.clicked.connect(self._on_header_clicked)
155
156        frame_header_layout.addWidget(self.toggle_button)
157        layout.addWidget(frame_header)
158
159        # --- COLLAPSIBLE CONTENT SECTION ---
160        self.content_frame = QFrame()
161        self.content_frame.setStyleSheet("QFrame { border-radius: 4px; padding: 4px; }")
162        self.content_frame.setMaximumHeight(0)  # Start collapsed
163        self.content_layout = QVBoxLayout(self.content_frame)
164        self.content_layout.setContentsMargins(8, 4, 8, 4)
165        self.content_layout.setSpacing(6)
166
167        self._populate_content()
168        layout.addWidget(self.content_frame)
169
170        # --- COLLAPSE/EXPAND ANIMATION ---
171        self.animation = QPropertyAnimation(self.content_frame, b"maximumHeight")
172        self.animation.setDuration(300)
173        self.animation.setEasingCurve(QEasingCurve.Type.InOutCubic)
174
175    # ------------------------------
176    # Populate the collapsible content area with file options.
177    # If locked → static label. If editable → combo box.
178    # ------------------------------
179    def _populate_content(self):
180        # Clear existing widgets
181        while self.content_layout.count():
182            item = self.content_layout.takeAt(0)
183            widget = item.widget()
184            if widget:
185                widget.deleteLater()
186
187        # For each category (like CT, PET4D, etc.)
188        for category, pat_list in self.patterns.items():
189            category_container = QFrame()
190            category_layout = QVBoxLayout(category_container)
191            category_layout.setContentsMargins(6, 4, 6, 4)
192            category_layout.setSpacing(4)
193
194            category_label = QLabel(category.replace("_", " ").title())
195            category_label.setStyleSheet("font-size: 13px; font-weight: bold;")
196
197            # Search for files matching the patterns
198            all_files = []
199            for pat in pat_list:
200                all_files.extend(glob.glob(pat))
201            all_files_rel = [os.path.relpath(f, self.workspace_path) for f in all_files]
202
203            # --- SINGLE FILE MODE (locked) ---
204            if self.locked:
205                self.chosen_file = self.files.get(category, "")
206                self.file_label = QLabel(self.chosen_file if self.chosen_file else QCoreApplication.translate("Components", "No file found"))
207                category_layout.addWidget(category_label)
208                category_layout.addWidget(self.file_label)
209
210                # If category is PET4D, also show JSON info
211                if category == "pet4d":
212                    self._add_pet4d_json_display(category_layout, self.chosen_file)
213
214            # --- MULTIPLE CHOICE MODE ---
215            else:
216                combo = QComboBox()
217                combo.setMinimumHeight(28)
218                combo.addItems(all_files_rel)
219                current_file = self.files.get(category)
220                if current_file in all_files_rel:
221                    combo.setCurrentIndex(all_files_rel.index(current_file))
222                elif all_files_rel:
223                    combo.setCurrentIndex(0)
224                self.category_widgets[category] = combo
225                category_layout.addWidget(category_label)
226                category_layout.addWidget(combo)
227
228                if category == "pet4d":
229                    # Display associated JSON dynamically
230                    self.pet4d_json_label = QLabel()
231                    self.pet4d_json_label.setWordWrap(True)
232                    category_layout.addWidget(self.pet4d_json_label)
233
234                    combo.currentIndexChanged.connect(self._update_pet4d_json_display)
235                    self._update_pet4d_json_display()
236
237            self.content_layout.addWidget(category_container)
238
239        # Add Save button if editable
240        if not self.locked:
241            save_container = QFrame()
242            save_layout = QHBoxLayout(save_container)
243            save_layout.setContentsMargins(6, 10, 6, 4)
244
245            self.save_btn = QPushButton(QCoreApplication.translate("Components", "Save Patient Configuration"))
246            self.save_btn.setMinimumHeight(32)
247            self.save_btn.setStyleSheet("""
248                QPushButton {
249                    font-size: 12px;
250                    font-weight: bold;
251                    background-color: #4CAF50;
252                    color: white;
253                    border-radius: 12px;
254                    padding: 8px 16px;
255                }
256                QPushButton:hover { background-color: #45a049; }
257            """)
258            self.save_btn.clicked.connect(self._save_patient)
259
260            save_layout.addStretch()
261            save_layout.addWidget(self.save_btn)
262            save_layout.addStretch()
263            self.content_layout.addWidget(save_container)
264
265    # ------------------------------
266    # Helper: show JSON file associated with PET4D data (locked mode).
267    # ------------------------------
268    def _add_pet4d_json_display(self, parent_layout, pet4d_file_rel):
269        if not pet4d_file_rel:
270            label = QLabel(QCoreApplication.translate("Components", "<span style='color:red;'>No PET4D file selected</span>"))
271            parent_layout.addWidget(label)
272            return
273
274        abs_pet4d_path = os.path.join(self.workspace_path, pet4d_file_rel)
275        json_candidate = abs_pet4d_path.replace(".nii.gz", ".json").replace(".nii", ".json")
276
277        if os.path.exists(json_candidate):
278            rel_json = os.path.relpath(json_candidate, self.workspace_path)
279            label = QLabel(QCoreApplication.translate("Components", "JSON related: <strong>{rel_json}</strong>").format(rel_json=rel_json))
280            label.setStyleSheet("color: black; font-size: 12px;")
281            self.files["pet4d_json"] = rel_json
282        else:
283            label = QLabel(QCoreApplication.translate("Components", "<span style='color:red;'>Error: associated JSON file not found</span>"))
284            self.files["pet4d_json"] = ""
285
286        label.setWordWrap(True)
287        parent_layout.addWidget(label)
288
289    # ------------------------------
290    # Update JSON label dynamically when PET4D combo changes.
291    # ------------------------------
292    def _update_pet4d_json_display(self):
293        if "pet4d" not in self.category_widgets:
294            return
295
296        combo = self.category_widgets["pet4d"]
297        selected_file = combo.currentText()
298        if not selected_file:
299            self.pet4d_json_label.setText(QCoreApplication.translate("Components", "<span style='color:red;'>No PET4D file selected</span>"))
300            return
301
302        abs_pet4d_path = os.path.join(self.workspace_path, selected_file)
303        json_candidate = abs_pet4d_path.replace(".nii.gz", ".json").replace(".nii", ".json")
304
305        if os.path.exists(json_candidate):
306            rel_json = os.path.relpath(json_candidate, self.workspace_path)
307            self.pet4d_json_label.setText(QCoreApplication.translate("Components", "JSON related: <strong>{rel_json}</strong>").format(rel_json=rel_json))
308            self.pet4d_json_label.setStyleSheet("color: black; font-size: 12px;")
309            self.files["pet4d_json"] = rel_json
310        else:
311            self.pet4d_json_label.setText(QCoreApplication.translate("Components", "<span style='color:red;'>Error: associated JSON file not found</span>"))
312            self.files["pet4d_json"] = ""
313
314    # ------------------------------
315    # Header click toggles expansion (alternative to pressing button).
316    # ------------------------------
317    def _on_header_clicked(self):
318        new_state = not self.toggle_button.isChecked()
319        self.toggle_button.setChecked(new_state)
320        self._toggle_expand(new_state)
321
322    # ------------------------------
323    # Animate expand/collapse of the content frame.
324    # ------------------------------
325    def _toggle_expand(self, checked):
326        self.is_expanded = checked
327        self.toggle_button.setArrowType(Qt.ArrowType.DownArrow if checked else Qt.ArrowType.RightArrow)
328        self.animation.setStartValue(self.content_frame.maximumHeight())
329        self.animation.setEndValue(self.content_frame.sizeHint().height() if checked else 0)
330        self.animation.start()
331
332    # ------------------------------
333    # Save current patient configuration (if editable).
334    # ------------------------------
335    def _save_patient(self):
336        # Save current combo selections
337        for category, combo in self.category_widgets.items():
338            self.files[category] = combo.currentText()
339
340        # Mark as reviewed
341        self.files["need_revision"] = False
342
343        # Call external save handler if provided
344        if self.save_callback:
345            self.save_callback(self.patient_id, self.files)
346
347        # Lock and refresh UI
348        self.locked = True
349        self._apply_style()
350        self._populate_content()
351
352    # ------------------------------
353    # Reapply translations dynamically.
354    # ------------------------------
355    def _translate_ui(self):
356        self.toggle_button.setText(QCoreApplication.translate("Components", "Patient: {patient}").format(patient=self.patient_id))
357        self.subject_name.setText(QCoreApplication.translate("Components", "Patient: {0}").format(self.patient_id))
358
359        if self.locked and hasattr(self, "file_label"):
360            self.file_label.setText(self.chosen_file if self.chosen_file else QCoreApplication.translate("Components", "No file found"))
361        if not self.locked:
362            self.save_btn.setText(QCoreApplication.translate("Components", "Save Patient Configuration"))
363
364        self._populate_content()

QFrame(parent: QWidget|None = None, flags: Qt.WindowType = Qt.WindowFlags())

CollapsiblePatientFrame( context, patient_id, files, patterns, multiple_choice=False, save_callback=None)
31    def __init__(self, context, patient_id, files, patterns, multiple_choice=False, save_callback=None):
32        super().__init__()
33        self.patient_id = patient_id
34        self.workspace_path = context["workspace_path"]  # Root folder of workspace
35        self.patterns = patterns  # File search patterns per category (e.g. {"pet4d": ["*/PET4D*.nii.gz"]})
36        self.files = files  # Dictionary of selected files per category
37        self.multiple_choice = multiple_choice  # Whether multiple files are allowed to be chosen
38        self.is_expanded = False  # State of the collapsible section
39        self.category_widgets = {}  # To store widgets (like combos) per category
40        self.save_callback = save_callback  # Function to call when saving
41        self.locked = not multiple_choice  # If single-choice mode, automatically locked
42
43        self.setFrameShape(QFrame.Shape.StyledPanel)
44        self.setObjectName("collapsiblePatientFrame")
45
46        # Add a subtle drop shadow to the frame for depth
47        shadow = QGraphicsDropShadowEffect()
48        shadow.setBlurRadius(8)
49        shadow.setColor(QColor(0, 0, 0, 30))
50        shadow.setOffset(0, 1)
51        self.setGraphicsEffect(shadow)
52
53        # Build visual layout and connect signals
54        self._build_ui()
55        self._apply_style()
56
57        # Handle translation/localization
58        self._translate_ui()
59        if context and "language_changed" in context:
60            context["language_changed"].connect(self._translate_ui)
patient_id
workspace_path
patterns
files
multiple_choice
is_expanded
category_widgets
save_callback
locked