Coverage for main/components/file_role_dialog.py: 98%
176 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 os
2import re
4from PyQt6.QtCore import QCoreApplication
5from PyQt6.QtWidgets import (
6 QDialog, QVBoxLayout, QWidget, QLabel, QRadioButton,
7 QButtonGroup, QFrame, QGroupBox, QComboBox, QDialogButtonBox
8)
11class FileRoleDialog(QDialog):
12 """
13 A multistep dialog for assigning a file's logical role within a workspace.
15 Structure:
16 1. Main or Derivatives
17 2. Subject selection (e.g., sub-001)
18 3. Role selection (e.g., anat, ses-01, ses-02)
20 Dynamically adapts based on the provided arguments:
21 - `main`, `subj`, and `role` can be pre-filled to skip steps.
22 """
24 def __init__(self, workspace_path=None, subj=None, role=None, main=None, parent=None):
25 super().__init__(parent)
27 # Store initial state and references
28 self.subj = subj
29 self.role = role
30 self.main = main
31 self.workspace_path = workspace_path
33 # Dialog setup
34 self.setWindowTitle(QCoreApplication.translate("Components", "File role"))
35 layout = QVBoxLayout(self)
37 # ----------------------------------------------------------------------
38 # LEVEL 1 — MAIN / DERIVATIVES SELECTION
39 # ----------------------------------------------------------------------
40 if main is None and subj is None:
41 # Widget container for level 1
42 self.level1_widget = QWidget()
43 level1_layout = QVBoxLayout(self.level1_widget)
45 # Label for level 1
46 self.pos_label = QLabel(QCoreApplication.translate("Components", "Position:"))
47 level1_layout.addWidget(self.pos_label)
49 # Two radio options: main or derivatives
50 self.opt_main = QRadioButton(QCoreApplication.translate("Components", "main subject files"))
51 self.opt_derivatives = QRadioButton("derivatives")
52 level1_layout.addWidget(self.opt_main)
53 level1_layout.addWidget(self.opt_derivatives)
55 # Group radio buttons to make them mutually exclusive
56 self.button_first_group = QButtonGroup(self)
57 self.button_first_group.addButton(self.opt_main)
58 self.button_first_group.addButton(self.opt_derivatives)
60 layout.addWidget(self.level1_widget)
62 # When toggled, show/hide derivative options
63 self.button_first_group.buttonToggled.connect(self.first_level_toggled)
65 # --- Additional frame for choosing derivative type (hidden by default)
66 self.derivative_extra_frame = QFrame()
67 derivative_extra_layout = QVBoxLayout(self.derivative_extra_frame)
68 self.derivative_extra_label = QLabel(QCoreApplication.translate("Components", "What derivative:"))
69 derivative_extra_layout.addWidget(self.derivative_extra_label)
71 # Create sub-options for derivatives
72 self.derivative_extra_button_group = QButtonGroup(self)
73 self.skull_strip_btn = QRadioButton("skullstrips")
74 derivative_extra_layout.addWidget(self.skull_strip_btn)
75 self.derivative_extra_button_group.addButton(self.skull_strip_btn)
77 self.manual_mask_btn = QRadioButton("manual_masks")
78 derivative_extra_layout.addWidget(self.manual_mask_btn)
79 self.derivative_extra_button_group.addButton(self.manual_mask_btn)
81 self.deep_learning_mask = QRadioButton("deep_learning_seg")
82 derivative_extra_layout.addWidget(self.deep_learning_mask)
83 self.derivative_extra_button_group.addButton(self.deep_learning_mask)
85 # Initially hidden (only shown when “derivatives” is selected)
86 self.derivative_extra_frame.hide()
87 layout.addWidget(self.derivative_extra_frame)
89 elif main == "derivatives":
90 # When `main` is pre-set to “derivatives”, show only derivative subtype
91 self.derivative_extra_frame = QFrame(self)
92 derivative_extra_layout = QVBoxLayout(self.derivative_extra_frame)
93 self.derivative_extra_label = QLabel(QCoreApplication.translate("Components", "What derivative:"))
94 derivative_extra_layout.addWidget(self.derivative_extra_label)
96 self.derivative_extra_button_group = QButtonGroup(self)
97 self.skull_strip_btn = QRadioButton("skullstrips")
98 derivative_extra_layout.addWidget(self.skull_strip_btn)
99 self.derivative_extra_button_group.addButton(self.skull_strip_btn)
101 self.manual_mask_btn = QRadioButton("manual_masks")
102 derivative_extra_layout.addWidget(self.manual_mask_btn)
103 self.derivative_extra_button_group.addButton(self.manual_mask_btn)
105 self.deep_learning_mask = QRadioButton("deep_learning_seg")
106 derivative_extra_layout.addWidget(self.deep_learning_mask)
107 self.derivative_extra_button_group.addButton(self.deep_learning_mask)
109 layout.addWidget(self.derivative_extra_frame)
110 self.button_first_group = None # no top-level main/derivatives group in this case
112 else:
113 # If `main` is not relevant, skip both
114 self.button_first_group = None
115 self.derivative_extra_button_group = None
117 # ----------------------------------------------------------------------
118 # LEVEL 2 — SUBJECT SELECTION
119 # ----------------------------------------------------------------------
120 if subj is None:
121 # Group box for subject selection
122 self.level2_widget = QGroupBox(QCoreApplication.translate("Components", "Subject"))
123 level2_layout = QVBoxLayout(self.level2_widget)
125 # Gather all available subjects (folders starting with "sub-")
126 subjects = [os.path.basename(p) for p in self._find_patient_dirs()]
128 # Use a dropdown instead of multiple radio buttons
129 self.subj_combo = QComboBox()
130 self.subj_combo.addItems(subjects)
131 level2_layout.addWidget(self.subj_combo)
133 # Placeholder for backward compatibility
134 self.subj_buttons = []
135 self.button_second_group = None
137 layout.addWidget(self.level2_widget)
138 else:
139 self.button_second_group = None
141 # ----------------------------------------------------------------------
142 # LEVEL 3 — ROLE / SESSION SELECTION
143 # ----------------------------------------------------------------------
144 if role is None:
145 self.level3_widget = QWidget()
146 level3_layout = QVBoxLayout(self.level3_widget)
147 self.role_label = QLabel(QCoreApplication.translate("Components", "Role:"))
148 level3_layout.addWidget(self.role_label)
150 # Possible role choices
151 self.button_third_group = QButtonGroup(self)
152 self.anat_button = QRadioButton("anat")
153 self.button_third_group.addButton(self.anat_button)
154 level3_layout.addWidget(self.anat_button)
156 self.ses_1_button = QRadioButton("ses-01")
157 self.button_third_group.addButton(self.ses_1_button)
158 level3_layout.addWidget(self.ses_1_button)
160 self.ses_2_button = QRadioButton("ses-02")
161 self.button_third_group.addButton(self.ses_2_button)
162 level3_layout.addWidget(self.ses_2_button)
164 layout.addWidget(self.level3_widget)
165 else:
166 self.button_third_group = None
168 # ----------------------------------------------------------------------
169 # DIALOG BUTTONS (OK / CANCEL)
170 # ----------------------------------------------------------------------
171 buttons = QDialogButtonBox(
172 QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
173 )
174 buttons.accepted.connect(self.accept)
175 buttons.rejected.connect(self.reject)
176 layout.addWidget(buttons)
178 # Keep reference to OK button so we can enable/disable it
179 self.ok_button = buttons.button(QDialogButtonBox.StandardButton.Ok)
180 self.ok_button.setEnabled(False) # initially disabled
182 # ----------------------------------------------------------------------
183 # SIGNAL CONNECTIONS FOR VALIDATION
184 # ----------------------------------------------------------------------
185 if self.button_first_group:
186 self.button_first_group.buttonToggled.connect(self.update_ok_button)
188 if self.derivative_extra_button_group:
189 self.derivative_extra_button_group.buttonToggled.connect(self.update_ok_button)
191 if hasattr(self, "subj_combo"):
192 self.subj_combo.currentIndexChanged.connect(self.update_ok_button)
194 if self.button_third_group:
195 self.button_third_group.buttonToggled.connect(self.update_ok_button)
197 # ======================================================================
198 # SUPPORT METHODS
199 # ======================================================================
201 def filter_subjects(self, text):
202 """Live filter for subjects (used if implemented with QListWidget)."""
203 if hasattr(self, "subj_list"):
204 for i in range(self.subj_list.count()):
205 item = self.subj_list.item(i)
206 item.setHidden(text.lower() not in item.text().lower())
208 def get_selections(self):
209 """Return all selected values from each level as a dictionary."""
210 selections = {}
212 # --- Level 1: Main / Derivatives ---
213 if self.button_first_group:
214 btn = self.button_first_group.checkedButton()
215 selections["main"] = btn.text() if btn else None
217 if self.derivative_extra_button_group:
218 btn = self.derivative_extra_button_group.checkedButton()
219 selections["derivative"] = btn.text() if btn else None
221 # --- Level 2: Subject ---
222 if self.button_second_group:
223 btn = self.button_second_group.checkedButton()
224 selections["subj"] = btn.text() if btn else None
225 elif hasattr(self, "subj_combo"):
226 selections["subj"] = self.subj_combo.currentText()
228 # --- Level 3: Role ---
229 if self.button_third_group:
230 btn = self.button_third_group.checkedButton()
231 selections["role"] = btn.text() if btn else None
233 return selections
235 def get_relative_path(self):
236 """
237 Build a relative path string (BIDS-like) from user selections.
238 Example:
239 derivatives/manual_masks/sub-001/ses-01/pet
240 """
241 parts = []
242 selections = self.get_selections()
244 main = selections.get("main")
245 subj = selections.get("subj")
246 role = selections.get("role")
247 derivative = selections.get("derivative")
249 # Handle derivatives hierarchy
250 if main == "derivatives":
251 parts.append("derivatives")
252 if derivative:
253 parts.append(derivative)
255 # Add subject
256 if subj:
257 parts.append(subj)
259 # Add role (special handling for session)
260 if role:
261 if re.match(r"^ses-\d+$", role):
262 parts.append(role)
263 parts.append("pet") # convention for PET session directories
264 else:
265 parts.append(role)
267 return os.path.join(*parts) if parts else None
269 def _find_patient_dirs(self):
270 """Return list of patient directories (sub-xxx), excluding 'derivatives'."""
271 patient_dirs = []
273 for root, dirs, files in os.walk(self.workspace_path):
274 # Exclude derivatives from search
275 if "derivatives" in dirs:
276 dirs.remove("derivatives")
278 # Collect dirs starting with 'sub-'
279 for dir_name in dirs:
280 if dir_name.startswith("sub-"):
281 full_path = os.path.join(root, dir_name)
282 patient_dirs.append(full_path)
284 return patient_dirs
286 # ----------------------------------------------------------------------
287 # UI DYNAMICS
288 # ----------------------------------------------------------------------
289 def first_level_toggled(self, button, checked):
290 """Show/hide derivative subtype section depending on selection."""
291 if not checked:
292 return
293 if button == self.opt_main:
294 self.derivative_extra_frame.hide()
295 self.adjustSize()
296 if button == self.opt_derivatives:
297 self.derivative_extra_frame.show()
298 self.adjustSize()
300 def update_ok_button(self):
301 """Enable OK only when all required selections are made."""
302 selections = self.get_selections()
303 enable = True
305 # Require main selection
306 if not selections.get("main"):
307 enable = False
309 # Require derivative type if derivatives is selected
310 if selections.get("main") == "derivatives" and not selections.get("derivative"):
311 enable = False
313 # Require subject selection
314 if not selections.get("subj"):
315 enable = False
317 # Require role selection
318 if not selections.get("role"):
319 enable = False
321 # Update OK button state
322 self.ok_button.setEnabled(enable)