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

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()