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