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

1import os 

2import re 

3 

4from PyQt6.QtCore import QCoreApplication 

5from PyQt6.QtWidgets import ( 

6 QDialog, QVBoxLayout, QWidget, QLabel, QRadioButton, 

7 QButtonGroup, QFrame, QGroupBox, QComboBox, QDialogButtonBox 

8) 

9 

10 

11class FileRoleDialog(QDialog): 

12 """ 

13 A multistep dialog for assigning a file's logical role within a workspace. 

14 

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) 

19 

20 Dynamically adapts based on the provided arguments: 

21 - `main`, `subj`, and `role` can be pre-filled to skip steps. 

22 """ 

23 

24 def __init__(self, workspace_path=None, subj=None, role=None, main=None, parent=None): 

25 super().__init__(parent) 

26 

27 # Store initial state and references 

28 self.subj = subj 

29 self.role = role 

30 self.main = main 

31 self.workspace_path = workspace_path 

32 

33 # Dialog setup 

34 self.setWindowTitle(QCoreApplication.translate("Components", "File role")) 

35 layout = QVBoxLayout(self) 

36 

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) 

44 

45 # Label for level 1 

46 self.pos_label = QLabel(QCoreApplication.translate("Components", "Position:")) 

47 level1_layout.addWidget(self.pos_label) 

48 

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) 

54 

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) 

59 

60 layout.addWidget(self.level1_widget) 

61 

62 # When toggled, show/hide derivative options 

63 self.button_first_group.buttonToggled.connect(self.first_level_toggled) 

64 

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) 

70 

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) 

76 

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) 

80 

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) 

84 

85 # Initially hidden (only shown when “derivatives” is selected) 

86 self.derivative_extra_frame.hide() 

87 layout.addWidget(self.derivative_extra_frame) 

88 

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) 

95 

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) 

100 

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) 

104 

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) 

108 

109 layout.addWidget(self.derivative_extra_frame) 

110 self.button_first_group = None # no top-level main/derivatives group in this case 

111 

112 else: 

113 # If `main` is not relevant, skip both 

114 self.button_first_group = None 

115 self.derivative_extra_button_group = None 

116 

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) 

124 

125 # Gather all available subjects (folders starting with "sub-") 

126 subjects = [os.path.basename(p) for p in self._find_patient_dirs()] 

127 

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) 

132 

133 # Placeholder for backward compatibility 

134 self.subj_buttons = [] 

135 self.button_second_group = None 

136 

137 layout.addWidget(self.level2_widget) 

138 else: 

139 self.button_second_group = None 

140 

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) 

149 

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) 

155 

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) 

159 

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) 

163 

164 layout.addWidget(self.level3_widget) 

165 else: 

166 self.button_third_group = None 

167 

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) 

177 

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 

181 

182 # ---------------------------------------------------------------------- 

183 # SIGNAL CONNECTIONS FOR VALIDATION 

184 # ---------------------------------------------------------------------- 

185 if self.button_first_group: 

186 self.button_first_group.buttonToggled.connect(self.update_ok_button) 

187 

188 if self.derivative_extra_button_group: 

189 self.derivative_extra_button_group.buttonToggled.connect(self.update_ok_button) 

190 

191 if hasattr(self, "subj_combo"): 

192 self.subj_combo.currentIndexChanged.connect(self.update_ok_button) 

193 

194 if self.button_third_group: 

195 self.button_third_group.buttonToggled.connect(self.update_ok_button) 

196 

197 # ====================================================================== 

198 # SUPPORT METHODS 

199 # ====================================================================== 

200 

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

207 

208 def get_selections(self): 

209 """Return all selected values from each level as a dictionary.""" 

210 selections = {} 

211 

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 

216 

217 if self.derivative_extra_button_group: 

218 btn = self.derivative_extra_button_group.checkedButton() 

219 selections["derivative"] = btn.text() if btn else None 

220 

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

227 

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 

232 

233 return selections 

234 

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

243 

244 main = selections.get("main") 

245 subj = selections.get("subj") 

246 role = selections.get("role") 

247 derivative = selections.get("derivative") 

248 

249 # Handle derivatives hierarchy 

250 if main == "derivatives": 

251 parts.append("derivatives") 

252 if derivative: 

253 parts.append(derivative) 

254 

255 # Add subject 

256 if subj: 

257 parts.append(subj) 

258 

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) 

266 

267 return os.path.join(*parts) if parts else None 

268 

269 def _find_patient_dirs(self): 

270 """Return list of patient directories (sub-xxx), excluding 'derivatives'.""" 

271 patient_dirs = [] 

272 

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

277 

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) 

283 

284 return patient_dirs 

285 

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

299 

300 def update_ok_button(self): 

301 """Enable OK only when all required selections are made.""" 

302 selections = self.get_selections() 

303 enable = True 

304 

305 # Require main selection 

306 if not selections.get("main"): 

307 enable = False 

308 

309 # Require derivative type if derivatives is selected 

310 if selections.get("main") == "derivatives" and not selections.get("derivative"): 

311 enable = False 

312 

313 # Require subject selection 

314 if not selections.get("subj"): 

315 enable = False 

316 

317 # Require role selection 

318 if not selections.get("role"): 

319 enable = False 

320 

321 # Update OK button state 

322 self.ok_button.setEnabled(enable)