Coverage for main/components/collapsible_patient_frame.py: 99%
187 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-03 15:51 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-03 15:51 +0000
1import glob
2import os
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)
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()
19 def mousePressEvent(self, event):
20 super().mousePressEvent(event)
21 self.clicked.emit() # Emit custom signal when frame is clicked
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
42 self.setFrameShape(QFrame.Shape.StyledPanel)
43 self.setObjectName("collapsiblePatientFrame")
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)
52 # Build visual layout and connect signals
53 self._build_ui()
54 self._apply_style()
56 # Handle translation/localization
57 self._translate_ui()
58 if context and "language_changed" in context:
59 context["language_changed"].connect(self._translate_ui)
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 """)
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)
131 # --- HEADER SECTION ---
132 frame_header = ClickableFrame(self)
133 frame_header_layout = QHBoxLayout(frame_header)
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)
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))
151 # Connect toggle behavior
152 self.toggle_button.clicked.connect(self._toggle_expand)
153 frame_header.clicked.connect(self._on_header_clicked)
155 frame_header_layout.addWidget(self.toggle_button)
156 layout.addWidget(frame_header)
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)
166 self._populate_content()
167 layout.addWidget(self.content_frame)
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)
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()
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)
193 category_label = QLabel(category.replace("_", " ").title())
194 category_label.setStyleSheet("font-size: 13px; font-weight: bold;")
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]
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)
209 # If category is PET4D, also show JSON info
210 if category == "pet4d":
211 self._add_pet4d_json_display(category_layout, self.chosen_file)
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)
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)
233 combo.currentIndexChanged.connect(self._update_pet4d_json_display)
234 self._update_pet4d_json_display()
236 self.content_layout.addWidget(category_container)
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)
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)
259 save_layout.addStretch()
260 save_layout.addWidget(self.save_btn)
261 save_layout.addStretch()
262 self.content_layout.addWidget(save_container)
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
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")
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"] = ""
285 label.setWordWrap(True)
286 parent_layout.addWidget(label)
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
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
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")
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"] = ""
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)
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()
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()
339 # Mark as reviewed
340 self.files["need_revision"] = False
342 # Call external save handler if provided
343 if self.save_callback:
344 self.save_callback(self.patient_id, self.files)
346 # Lock and refresh UI
347 self.locked = True
348 self._apply_style()
349 self._populate_content()
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))
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"))
363 self._populate_content()