Coverage for openhcs/pyqt_gui/windows/function_selector_window.py: 0.0%

226 statements  

« prev     ^ index     » next       coverage.py v7.10.3, created at 2025-08-14 05:57 +0000

1""" 

2Function Selector Window for PyQt6 

3 

4Function selection dialog with search and filtering capabilities. 

5Uses hybrid approach: extracted business logic + clean PyQt6 UI. 

6""" 

7 

8import logging 

9from typing import Optional, Callable, List, Dict, Any 

10import inspect 

11 

12from PyQt6.QtWidgets import ( 

13 QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, 

14 QTreeWidget, QTreeWidgetItem, QLineEdit, QTextEdit, QFrame, 

15 QSplitter, QGroupBox, QWidget 

16) 

17from PyQt6.QtCore import Qt, pyqtSignal 

18from PyQt6.QtGui import QFont 

19 

20from openhcs.textual_tui.services.function_registry_service import FunctionRegistryService 

21from openhcs.pyqt_gui.shared.style_generator import StyleSheetGenerator 

22from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme 

23 

24logger = logging.getLogger(__name__) 

25 

26 

27class FunctionSelectorWindow(QDialog): 

28 """ 

29 PyQt6 Function Selector Window. 

30  

31 Function selection dialog with search, filtering, and preview capabilities. 

32 Preserves all business logic from Textual version with clean PyQt6 UI. 

33 """ 

34 

35 # Signals 

36 function_selected = pyqtSignal(object) # Selected function 

37 selection_cancelled = pyqtSignal() 

38 

39 def __init__(self, on_result_callback: Optional[Callable] = None, 

40 color_scheme: Optional[PyQt6ColorScheme] = None, parent=None): 

41 """ 

42 Initialize the function selector window. 

43 

44 Args: 

45 on_result_callback: Callback for function selection result 

46 color_scheme: Color scheme for styling (optional, uses default if None) 

47 parent: Parent widget 

48 """ 

49 super().__init__(parent) 

50 

51 # Business logic state (extracted from Textual version) 

52 self.on_result_callback = on_result_callback 

53 self.function_registry = FunctionRegistryService() 

54 

55 # Initialize color scheme and style generator 

56 self.color_scheme = color_scheme or PyQt6ColorScheme() 

57 self.style_generator = StyleSheetGenerator(self.color_scheme) 

58 

59 # Current state 

60 self.available_functions: Dict[str, Any] = {} 

61 self.filtered_functions: Dict[str, Any] = {} 

62 self.selected_function = None 

63 self.search_text = "" 

64 

65 # UI components 

66 self.search_edit: Optional[QLineEdit] = None 

67 self.function_tree: Optional[QTreeWidget] = None 

68 self.preview_text: Optional[QTextEdit] = None 

69 

70 # Setup UI 

71 self.setup_ui() 

72 self.setup_connections() 

73 self.load_functions() 

74 

75 logger.debug("Function selector window initialized") 

76 

77 def setup_ui(self): 

78 """Setup the user interface.""" 

79 self.setWindowTitle("Select Function") 

80 self.setModal(True) 

81 self.setMinimumSize(800, 600) 

82 self.resize(1000, 700) 

83 

84 layout = QVBoxLayout(self) 

85 layout.setSpacing(10) 

86 

87 # Header 

88 header_label = QLabel("Function Library") 

89 header_label.setFont(QFont("Arial", 14, QFont.Weight.Bold)) 

90 header_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_accent)}; padding: 10px;") 

91 layout.addWidget(header_label) 

92 

93 # Search section 

94 search_frame = self.create_search_section() 

95 layout.addWidget(search_frame) 

96 

97 # Main content area 

98 content_splitter = self.create_content_area() 

99 layout.addWidget(content_splitter) 

100 

101 # Button panel 

102 button_panel = self.create_button_panel() 

103 layout.addWidget(button_panel) 

104 

105 # Apply centralized styling 

106 self.setStyleSheet(self.style_generator.generate_dialog_style() + "\n" + 

107 self.style_generator.generate_tree_widget_style()) 

108 

109 def create_search_section(self) -> QWidget: 

110 """ 

111 Create the search section. 

112  

113 Returns: 

114 Widget containing search controls 

115 """ 

116 frame = QFrame() 

117 frame.setFrameStyle(QFrame.Shape.Box) 

118 frame.setStyleSheet(f""" 

119 QFrame {{ 

120 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)}; 

121 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)}; 

122 border-radius: 3px; 

123 padding: 8px; 

124 }} 

125 """) 

126 

127 layout = QHBoxLayout(frame) 

128 

129 # Search label 

130 search_label = QLabel("Search:") 

131 search_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_secondary)}; font-weight: bold;") 

132 layout.addWidget(search_label) 

133 

134 # Search edit 

135 self.search_edit = QLineEdit() 

136 self.search_edit.setPlaceholderText("Search functions by name, module, or description...") 

137 self.search_edit.textChanged.connect(self.on_search_changed) 

138 layout.addWidget(self.search_edit) 

139 

140 # Clear button 

141 clear_button = QPushButton("Clear") 

142 clear_button.setMaximumWidth(60) 

143 clear_button.clicked.connect(self.clear_search) 

144 clear_button.setStyleSheet(f""" 

145 QPushButton {{ 

146 background-color: {self.color_scheme.to_hex(self.color_scheme.input_bg)}; 

147 color: white; 

148 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_light)}; 

149 border-radius: 3px; 

150 padding: 5px; 

151 }} 

152 QPushButton:hover {{ 

153 background-color: {self.color_scheme.to_hex(self.color_scheme.button_hover_bg)}; 

154 }} 

155 """) 

156 layout.addWidget(clear_button) 

157 

158 return frame 

159 

160 def create_content_area(self) -> QWidget: 

161 """ 

162 Create the main content area. 

163  

164 Returns: 

165 Widget containing function tree and preview 

166 """ 

167 splitter = QSplitter(Qt.Orientation.Horizontal) 

168 

169 # Function tree 

170 function_group = QGroupBox("Available Functions") 

171 function_group.setStyleSheet(f""" 

172 QGroupBox {{ 

173 font-weight: bold; 

174 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)}; 

175 border-radius: 5px; 

176 margin-top: 10px; 

177 padding-top: 10px; 

178 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)}; 

179 color: {self.color_scheme.to_hex(self.color_scheme.text_primary)}; 

180 }} 

181 QGroupBox::title {{ 

182 subcontrol-origin: margin; 

183 left: 10px; 

184 padding: 0 5px 0 5px; 

185 color: {self.color_scheme.to_hex(self.color_scheme.text_accent)}; 

186 }} 

187 """) 

188 

189 function_layout = QVBoxLayout(function_group) 

190 

191 self.function_tree = QTreeWidget() 

192 self.function_tree.setHeaderLabels(["Function", "Module"]) 

193 self.function_tree.setRootIsDecorated(True) 

194 self.function_tree.setSortingEnabled(True) 

195 self.function_tree.sortByColumn(0, Qt.SortOrder.AscendingOrder) 

196 function_layout.addWidget(self.function_tree) 

197 

198 splitter.addWidget(function_group) 

199 

200 # Preview panel 

201 preview_group = QGroupBox("Function Preview") 

202 preview_group.setStyleSheet(f""" 

203 QGroupBox {{ 

204 font-weight: bold; 

205 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)}; 

206 border-radius: 5px; 

207 margin-top: 10px; 

208 padding-top: 10px; 

209 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)}; 

210 color: {self.color_scheme.to_hex(self.color_scheme.text_primary)}; 

211 }} 

212 QGroupBox::title {{ 

213 subcontrol-origin: margin; 

214 left: 10px; 

215 padding: 0 5px 0 5px; 

216 color: {self.color_scheme.to_hex(self.color_scheme.text_accent)}; 

217 }} 

218 """) 

219 

220 preview_layout = QVBoxLayout(preview_group) 

221 

222 self.preview_text = QTextEdit() 

223 self.preview_text.setReadOnly(True) 

224 self.preview_text.setFont(QFont("Courier New", 10)) 

225 self.preview_text.setPlaceholderText("Select a function to see its details...") 

226 preview_layout.addWidget(self.preview_text) 

227 

228 splitter.addWidget(preview_group) 

229 

230 # Set splitter proportions 

231 splitter.setSizes([500, 400]) 

232 

233 return splitter 

234 

235 def create_button_panel(self) -> QWidget: 

236 """ 

237 Create the button panel. 

238  

239 Returns: 

240 Widget containing action buttons 

241 """ 

242 panel = QFrame() 

243 panel.setFrameStyle(QFrame.Shape.Box) 

244 panel.setStyleSheet(f""" 

245 QFrame {{ 

246 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)}; 

247 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)}; 

248 border-radius: 3px; 

249 padding: 10px; 

250 }} 

251 """) 

252 

253 layout = QHBoxLayout(panel) 

254 

255 # Function count label 

256 self.count_label = QLabel("") 

257 self.count_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_disabled)}; font-style: italic;") 

258 layout.addWidget(self.count_label) 

259 

260 layout.addStretch() 

261 

262 # Cancel button 

263 cancel_button = QPushButton("Cancel") 

264 cancel_button.setMinimumWidth(80) 

265 cancel_button.clicked.connect(self.cancel_selection) 

266 cancel_button.setStyleSheet(f""" 

267 QPushButton {{ 

268 background-color: {self.color_scheme.to_hex(self.color_scheme.status_error)}; 

269 color: white; 

270 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.status_error)}; 

271 border-radius: 3px; 

272 padding: 8px; 

273 }} 

274 QPushButton:hover {{ 

275 background-color: {self.color_scheme.to_hex(self.color_scheme.status_error)}; 

276 }} 

277 """) 

278 layout.addWidget(cancel_button) 

279 

280 # Select button 

281 self.select_button = QPushButton("Select") 

282 self.select_button.setMinimumWidth(80) 

283 self.select_button.setEnabled(False) 

284 self.select_button.clicked.connect(self.confirm_selection) 

285 self.select_button.setStyleSheet(f""" 

286 QPushButton {{ 

287 background-color: {self.color_scheme.to_hex(self.color_scheme.selection_bg)}; 

288 color: white; 

289 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.selection_bg)}; 

290 border-radius: 3px; 

291 padding: 8px; 

292 }} 

293 QPushButton:hover {{ 

294 background-color: {self.color_scheme.to_hex(self.color_scheme.selection_bg)}; 

295 }} 

296 QPushButton:disabled {{ 

297 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)}; 

298 color: {self.color_scheme.to_hex(self.color_scheme.border_light)}; 

299 }} 

300 """) 

301 layout.addWidget(self.select_button) 

302 

303 return panel 

304 

305 def setup_connections(self): 

306 """Setup signal/slot connections.""" 

307 if self.function_tree: 

308 self.function_tree.itemSelectionChanged.connect(self.on_selection_changed) 

309 self.function_tree.itemDoubleClicked.connect(self.on_item_double_clicked) 

310 

311 def load_functions(self): 

312 """Load available functions from the registry.""" 

313 try: 

314 # Get functions from registry (extracted from Textual version) 

315 self.available_functions = self.function_registry.get_all_functions() 

316 self.filtered_functions = self.available_functions.copy() 

317 

318 self.update_function_tree() 

319 self.update_count_label() 

320 

321 logger.debug(f"Loaded {len(self.available_functions)} functions") 

322 

323 except Exception as e: 

324 logger.error(f"Failed to load functions: {e}") 

325 self.available_functions = {} 

326 self.filtered_functions = {} 

327 

328 def update_function_tree(self): 

329 """Update the function tree display.""" 

330 self.function_tree.clear() 

331 

332 # Group functions by module 

333 modules = {} 

334 for func_name, func_info in self.filtered_functions.items(): 

335 module_name = getattr(func_info.get('function'), '__module__', 'Unknown') 

336 if module_name not in modules: 

337 modules[module_name] = [] 

338 modules[module_name].append((func_name, func_info)) 

339 

340 # Create tree items 

341 for module_name, functions in sorted(modules.items()): 

342 module_item = QTreeWidgetItem(self.function_tree) 

343 module_item.setText(0, module_name) 

344 module_item.setText(1, f"({len(functions)} functions)") 

345 module_item.setExpanded(True) 

346 

347 # Add function items 

348 for func_name, func_info in sorted(functions): 

349 func_item = QTreeWidgetItem(module_item) 

350 func_item.setText(0, func_name) 

351 func_item.setText(1, module_name) 

352 func_item.setData(0, Qt.ItemDataRole.UserRole, func_info) 

353 

354 # Add icon or styling based on function type 

355 func_item.setToolTip(0, func_info['function'].__doc__ or "No description available") 

356 

357 def update_count_label(self): 

358 """Update the function count label.""" 

359 total = len(self.available_functions) 

360 filtered = len(self.filtered_functions) 

361 

362 if filtered == total: 

363 self.count_label.setText(f"{total} functions") 

364 else: 

365 self.count_label.setText(f"{filtered} of {total} functions") 

366 

367 def on_search_changed(self, text: str): 

368 """Handle search text changes.""" 

369 self.search_text = text.lower().strip() 

370 self.filter_functions() 

371 

372 def filter_functions(self): 

373 """Filter functions based on search text.""" 

374 if not self.search_text: 

375 self.filtered_functions = self.available_functions.copy() 

376 else: 

377 self.filtered_functions = {} 

378 

379 for func_name, func_info in self.available_functions.items(): 

380 # Search in function name 

381 if self.search_text in func_name.lower(): 

382 self.filtered_functions[func_name] = func_info 

383 continue 

384 

385 # Search in module name 

386 module_name = getattr(func_info.get('function'), '__module__', '') 

387 if self.search_text in module_name.lower(): 

388 self.filtered_functions[func_name] = func_info 

389 continue 

390 

391 # Search in docstring 

392 docstring = getattr(func_info.get('function'), '__doc__', '') or '' 

393 if self.search_text in docstring.lower(): 

394 self.filtered_functions[func_name] = func_info 

395 continue 

396 

397 self.update_function_tree() 

398 self.update_count_label() 

399 

400 def clear_search(self): 

401 """Clear the search text.""" 

402 self.search_edit.clear() 

403 

404 def on_selection_changed(self): 

405 """Handle function tree selection changes.""" 

406 selected_items = self.function_tree.selectedItems() 

407 

408 if selected_items: 

409 item = selected_items[0] 

410 func_info = item.data(0, Qt.ItemDataRole.UserRole) 

411 

412 if func_info: # Function item selected 

413 self.selected_function = func_info['function'] 

414 self.update_preview(func_info) 

415 self.select_button.setEnabled(True) 

416 else: # Module item selected 

417 self.selected_function = None 

418 self.preview_text.clear() 

419 self.select_button.setEnabled(False) 

420 else: 

421 self.selected_function = None 

422 self.preview_text.clear() 

423 self.select_button.setEnabled(False) 

424 

425 def on_item_double_clicked(self, item: QTreeWidgetItem, column: int): 

426 """Handle double-click on function items.""" 

427 func_info = item.data(0, Qt.ItemDataRole.UserRole) 

428 if func_info: # Function item double-clicked 

429 self.selected_function = func_info['function'] 

430 self.confirm_selection() 

431 

432 def update_preview(self, func_info: Dict[str, Any]): 

433 """ 

434 Update the function preview. 

435  

436 Args: 

437 func_info: Function information dictionary 

438 """ 

439 func = func_info['function'] 

440 

441 preview_text = f"Function: {func.__name__}\n" 

442 preview_text += f"Module: {func.__module__}\n\n" 

443 

444 # Function signature 

445 try: 

446 sig = inspect.signature(func) 

447 preview_text += f"Signature:\n{func.__name__}{sig}\n\n" 

448 except Exception: 

449 preview_text += "Signature: Not available\n\n" 

450 

451 # Docstring 

452 docstring = func.__doc__ 

453 if docstring: 

454 preview_text += f"Description:\n{docstring.strip()}\n\n" 

455 else: 

456 preview_text += "Description: No documentation available\n\n" 

457 

458 # Additional info 

459 if func.__file__: 

460 preview_text += f"Source: {func.__file__}\n" 

461 

462 self.preview_text.setPlainText(preview_text) 

463 

464 def confirm_selection(self): 

465 """Confirm the function selection.""" 

466 if not self.selected_function: 

467 from PyQt6.QtWidgets import QMessageBox 

468 QMessageBox.warning(self, "No Selection", "Please select a function.") 

469 return 

470 

471 # Emit signal and call callback 

472 self.function_selected.emit(self.selected_function) 

473 

474 if self.on_result_callback: 

475 self.on_result_callback(self.selected_function) 

476 

477 self.accept() 

478 logger.debug(f"Function selected: {self.selected_function.__name__}") 

479 

480 def cancel_selection(self): 

481 """Cancel the function selection.""" 

482 self.selection_cancelled.emit() 

483 self.reject() 

484 logger.debug("Function selection cancelled") 

485 

486 

487# Convenience function for opening function selector 

488def open_function_selector_window(on_result_callback: Optional[Callable] = None, parent=None): 

489 """ 

490 Open function selector window. 

491  

492 Args: 

493 on_result_callback: Callback for function selection result 

494 parent: Parent widget 

495  

496 Returns: 

497 Dialog result 

498 """ 

499 selector = FunctionSelectorWindow(on_result_callback, parent) 

500 return selector.exec()