Coverage for openhcs/pyqt_gui/widgets/function_pane.py: 0.0%

276 statements  

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

1""" 

2Function Pane Widget for PyQt6 

3 

4Individual function display with parameter editing capabilities. 

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

6""" 

7 

8import logging 

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

10from pathlib import Path 

11 

12from PyQt6.QtWidgets import ( 

13 QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, 

14 QFrame, QScrollArea, QGroupBox, QFormLayout 

15) 

16from PyQt6.QtCore import Qt, pyqtSignal 

17from PyQt6.QtGui import QFont 

18 

19from openhcs.textual_tui.services.pattern_data_manager import PatternDataManager 

20from openhcs.textual_tui.widgets.shared.parameter_form_manager import ParameterFormManager as TextualParameterFormManager 

21from openhcs.textual_tui.widgets.shared.signature_analyzer import SignatureAnalyzer 

22 

23# Import PyQt6 help components (using same pattern as Textual TUI) 

24from openhcs.pyqt_gui.widgets.shared.clickable_help_components import HelpIndicator 

25from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme 

26 

27logger = logging.getLogger(__name__) 

28 

29 

30class FunctionPaneWidget(QWidget): 

31 """ 

32 PyQt6 Function Pane Widget. 

33  

34 Displays individual function with editable parameters and control buttons. 

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

36 """ 

37 

38 # Signals 

39 parameter_changed = pyqtSignal(int, str, object) # index, param_name, value 

40 function_changed = pyqtSignal(int) # index 

41 add_function = pyqtSignal(int) # index 

42 remove_function = pyqtSignal(int) # index 

43 move_function = pyqtSignal(int, int) # index, direction 

44 reset_parameters = pyqtSignal(int) # index 

45 

46 def __init__(self, func_item: Tuple[Callable, Dict], index: int, service_adapter, color_scheme: Optional[PyQt6ColorScheme] = None, parent=None): 

47 """ 

48 Initialize the function pane widget. 

49  

50 Args: 

51 func_item: Tuple of (function, kwargs) 

52 index: Function index in the list 

53 service_adapter: PyQt service adapter for dialogs and operations 

54 parent: Parent widget 

55 """ 

56 super().__init__(parent) 

57 

58 # Initialize color scheme 

59 self.color_scheme = color_scheme or PyQt6ColorScheme() 

60 

61 # Core dependencies 

62 self.service_adapter = service_adapter 

63 

64 # Business logic state (extracted from Textual version) 

65 self.func, self.kwargs = func_item 

66 self.index = index 

67 self.show_parameters = True 

68 

69 # Parameter management (extracted from Textual version) 

70 if self.func: 

71 param_info = SignatureAnalyzer.analyze(self.func) 

72 parameters = {name: self.kwargs.get(name, info.default_value) for name, info in param_info.items()} 

73 parameter_types = {name: info.param_type for name, info in param_info.items()} 

74 

75 self.form_manager = TextualParameterFormManager(parameters, parameter_types, f"func_{index}", param_info) 

76 self.param_defaults = {name: info.default_value for name, info in param_info.items()} 

77 else: 

78 self.form_manager = None 

79 self.param_defaults = {} 

80 

81 # Internal kwargs tracking (extracted from Textual version) 

82 self._internal_kwargs = self.kwargs.copy() 

83 

84 # UI components 

85 self.parameter_widgets: Dict[str, QWidget] = {} 

86 

87 # Setup UI 

88 self.setup_ui() 

89 self.setup_connections() 

90 

91 logger.debug(f"Function pane widget initialized for index {index}") 

92 

93 def setup_ui(self): 

94 """Setup the user interface.""" 

95 layout = QVBoxLayout(self) 

96 layout.setContentsMargins(5, 5, 5, 5) 

97 layout.setSpacing(5) 

98 

99 # Function header 

100 header_frame = self.create_function_header() 

101 layout.addWidget(header_frame) 

102 

103 # Control buttons 

104 button_frame = self.create_button_panel() 

105 layout.addWidget(button_frame) 

106 

107 # Parameter form (if function exists and parameters shown) 

108 if self.func and self.show_parameters and self.form_manager: 

109 parameter_frame = self.create_parameter_form() 

110 layout.addWidget(parameter_frame) 

111 

112 # Set styling 

113 self.setStyleSheet(f""" 

114 FunctionPaneWidget {{ 

115 background-color: {self.color_scheme.to_hex(self.color_scheme.window_bg)}; 

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

117 border-radius: 5px; 

118 margin: 2px; 

119 }} 

120 """) 

121 

122 def create_function_header(self) -> QWidget: 

123 """ 

124 Create the function header with name and info. 

125  

126 Returns: 

127 Widget containing function header 

128 """ 

129 frame = QFrame() 

130 frame.setFrameStyle(QFrame.Shape.Box) 

131 frame.setStyleSheet(f""" 

132 QFrame {{ 

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

134 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.separator_color)}; 

135 border-radius: 3px; 

136 padding: 5px; 

137 }} 

138 """) 

139 

140 layout = QHBoxLayout(frame) 

141 

142 # Function name with help functionality (reuses Textual TUI help logic) 

143 if self.func: 

144 func_name = self.func.__name__ 

145 func_module = self.func.__module__ 

146 

147 # Function name with help 

148 name_label = QLabel(f"🔧 {func_name}") 

149 name_label.setFont(QFont("Arial", 10, QFont.Weight.Bold)) 

150 name_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_accent)};") 

151 layout.addWidget(name_label) 

152 

153 # Help indicator for function (import locally to avoid circular imports) 

154 from openhcs.pyqt_gui.widgets.shared.clickable_help_components import HelpIndicator 

155 help_indicator = HelpIndicator(help_target=self.func, color_scheme=self.color_scheme) 

156 layout.addWidget(help_indicator) 

157 

158 # Module info 

159 if func_module: 

160 module_label = QLabel(f"({func_module})") 

161 module_label.setFont(QFont("Arial", 8)) 

162 module_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_disabled)};") 

163 layout.addWidget(module_label) 

164 else: 

165 name_label = QLabel("No Function Selected") 

166 name_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.status_error)};") 

167 layout.addWidget(name_label) 

168 

169 layout.addStretch() 

170 

171 return frame 

172 

173 def create_button_panel(self) -> QWidget: 

174 """ 

175 Create the control button panel. 

176  

177 Returns: 

178 Widget containing control buttons 

179 """ 

180 frame = QFrame() 

181 frame.setFrameStyle(QFrame.Shape.Box) 

182 frame.setStyleSheet(f""" 

183 QFrame {{ 

184 background-color: {self.color_scheme.to_hex(self.color_scheme.window_bg)}; 

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

186 border-radius: 3px; 

187 padding: 3px; 

188 }} 

189 """) 

190 

191 layout = QHBoxLayout(frame) 

192 layout.addStretch() # Center the buttons 

193 

194 # Button configurations (extracted from Textual version) 

195 button_configs = [ 

196 ("↑", "move_up", "Move function up"), 

197 ("↓", "move_down", "Move function down"), 

198 ("Add", "add_func", "Add new function"), 

199 ("Delete", "remove_func", "Delete this function"), 

200 ("Reset", "reset_all", "Reset all parameters"), 

201 ] 

202 

203 for name, action, tooltip in button_configs: 

204 button = QPushButton(name) 

205 button.setToolTip(tooltip) 

206 button.setMaximumWidth(60) 

207 button.setMaximumHeight(25) 

208 button.setStyleSheet(f""" 

209 QPushButton {{ 

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

211 color: white; 

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

213 border-radius: 2px; 

214 padding: 2px; 

215 font-size: 10px; 

216 }} 

217 QPushButton:hover {{ 

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

219 }} 

220 QPushButton:pressed {{ 

221 background-color: {self.color_scheme.to_hex(self.color_scheme.button_pressed_bg)}; 

222 }} 

223 """) 

224 

225 # Connect button to action 

226 button.clicked.connect(lambda checked, a=action: self.handle_button_action(a)) 

227 

228 layout.addWidget(button) 

229 

230 layout.addStretch() # Center the buttons 

231 

232 return frame 

233 

234 def create_parameter_form(self) -> QWidget: 

235 """ 

236 Create the parameter form using extracted business logic. 

237  

238 Returns: 

239 Widget containing parameter form 

240 """ 

241 group_box = QGroupBox("Parameters") 

242 group_box.setStyleSheet(f""" 

243 QGroupBox {{ 

244 font-weight: bold; 

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

246 border-radius: 3px; 

247 margin-top: 10px; 

248 padding-top: 10px; 

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

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

251 }} 

252 QGroupBox::title {{ 

253 subcontrol-origin: margin; 

254 left: 10px; 

255 padding: 0 5px 0 5px; 

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

257 }} 

258 """) 

259 

260 layout = QVBoxLayout(group_box) 

261 

262 # Use the enhanced ParameterFormManager that has help and reset functionality 

263 if self.form_manager: 

264 # Import the enhanced PyQt6 ParameterFormManager 

265 from openhcs.pyqt_gui.widgets.shared.parameter_form_manager import ParameterFormManager as PyQtParameterFormManager 

266 

267 # Create enhanced parameter form manager with help and reset buttons 

268 enhanced_form_manager = PyQtParameterFormManager( 

269 parameters=self.form_manager.parameters, 

270 parameter_types=self.form_manager.parameter_types, 

271 field_id=f"func_{self.index}", 

272 parameter_info=self.form_manager.parameter_info, 

273 use_scroll_area=False, # Don't use scroll area in function panes 

274 function_target=self.func, # Pass function for docstring fallback 

275 color_scheme=self.color_scheme 

276 ) 

277 

278 # Connect parameter changes 

279 enhanced_form_manager.parameter_changed.connect( 

280 lambda param_name, value: self.handle_parameter_change(param_name, value) 

281 ) 

282 

283 layout.addWidget(enhanced_form_manager) 

284 

285 # Store reference for parameter updates 

286 self.enhanced_form_manager = enhanced_form_manager 

287 

288 return group_box 

289 

290 def create_parameter_widget(self, param_name: str, param_type: type, current_value: Any) -> Optional[QWidget]: 

291 """ 

292 Create parameter widget based on type (simplified TypedWidgetFactory). 

293  

294 Args: 

295 param_name: Parameter name 

296 param_type: Parameter type 

297 current_value: Current parameter value 

298  

299 Returns: 

300 Widget for parameter editing or None 

301 """ 

302 from PyQt6.QtWidgets import QLineEdit, QCheckBox, QSpinBox, QDoubleSpinBox, QComboBox 

303 from PyQt6.QtGui import QWheelEvent 

304 

305 # No-scroll widget classes to prevent accidental value changes 

306 class NoScrollSpinBox(QSpinBox): 

307 def wheelEvent(self, event: QWheelEvent): 

308 event.ignore() 

309 

310 class NoScrollDoubleSpinBox(QDoubleSpinBox): 

311 def wheelEvent(self, event: QWheelEvent): 

312 event.ignore() 

313 

314 class NoScrollComboBox(QComboBox): 

315 def wheelEvent(self, event: QWheelEvent): 

316 event.ignore() 

317 

318 # Boolean parameters 

319 if param_type == bool: 

320 widget = QCheckBox() 

321 widget.setChecked(bool(current_value)) 

322 widget.toggled.connect(lambda checked: self.handle_parameter_change(param_name, checked)) 

323 return widget 

324 

325 # Integer parameters 

326 elif param_type == int: 

327 widget = NoScrollSpinBox() 

328 widget.setRange(-999999, 999999) 

329 widget.setValue(int(current_value) if current_value is not None else 0) 

330 widget.valueChanged.connect(lambda value: self.handle_parameter_change(param_name, value)) 

331 return widget 

332 

333 # Float parameters 

334 elif param_type == float: 

335 widget = NoScrollDoubleSpinBox() 

336 widget.setRange(-999999.0, 999999.0) 

337 widget.setDecimals(6) 

338 widget.setValue(float(current_value) if current_value is not None else 0.0) 

339 widget.valueChanged.connect(lambda value: self.handle_parameter_change(param_name, value)) 

340 return widget 

341 

342 # Enum parameters 

343 elif any(base.__name__ == 'Enum' for base in param_type.__bases__): 

344 widget = NoScrollComboBox() 

345 for enum_value in param_type: 

346 widget.addItem(str(enum_value.value), enum_value) 

347 

348 # Set current value 

349 if current_value is not None: 

350 for i in range(widget.count()): 

351 if widget.itemData(i) == current_value: 

352 widget.setCurrentIndex(i) 

353 break 

354 

355 widget.currentIndexChanged.connect( 

356 lambda index: self.handle_parameter_change(param_name, widget.itemData(index)) 

357 ) 

358 return widget 

359 

360 # String and other parameters 

361 else: 

362 widget = QLineEdit() 

363 widget.setText(str(current_value) if current_value is not None else "") 

364 widget.textChanged.connect(lambda text: self.handle_parameter_change(param_name, text)) 

365 return widget 

366 

367 def setup_connections(self): 

368 """Setup signal/slot connections.""" 

369 pass # Connections are set up in widget creation 

370 

371 def handle_button_action(self, action: str): 

372 """ 

373 Handle button actions (extracted from Textual version). 

374  

375 Args: 

376 action: Action identifier 

377 """ 

378 if action == "move_up": 

379 self.move_function.emit(self.index, -1) 

380 elif action == "move_down": 

381 self.move_function.emit(self.index, 1) 

382 elif action == "add_func": 

383 self.add_function.emit(self.index + 1) 

384 elif action == "remove_func": 

385 self.remove_function.emit(self.index) 

386 elif action == "reset_all": 

387 self.reset_all_parameters() 

388 

389 def handle_parameter_change(self, param_name: str, value: Any): 

390 """ 

391 Handle parameter value changes (extracted from Textual version). 

392  

393 Args: 

394 param_name: Name of the parameter 

395 value: New parameter value 

396 """ 

397 # Update internal kwargs without triggering reactive update 

398 self._internal_kwargs[param_name] = value 

399 

400 # Update form manager 

401 if self.form_manager: 

402 self.form_manager.update_parameter(param_name, value) 

403 final_value = self.form_manager.parameters[param_name] 

404 else: 

405 final_value = value 

406 

407 # Emit parameter changed signal 

408 self.parameter_changed.emit(self.index, param_name, final_value) 

409 

410 logger.debug(f"Parameter changed: {param_name} = {final_value}") 

411 

412 def reset_all_parameters(self): 

413 """Reset all parameters to default values (extracted from Textual version).""" 

414 

415 for param_name, default_value in self.param_defaults.items(): 

416 # Update internal kwargs 

417 self._internal_kwargs[param_name] = default_value 

418 

419 # Update form manager 

420 if self.form_manager: 

421 self.form_manager.reset_parameter(param_name, default_value) 

422 

423 # Update UI widget 

424 if param_name in self.parameter_widgets: 

425 widget = self.parameter_widgets[param_name] 

426 self.update_widget_value(widget, default_value) 

427 

428 # Emit parameter changed signal 

429 self.parameter_changed.emit(self.index, param_name, default_value) 

430 

431 self.reset_parameters.emit(self.index) 

432 logger.debug(f"Reset all parameters for function {self.index}") 

433 

434 def update_widget_value(self, widget: QWidget, value: Any): 

435 """ 

436 Update widget value without triggering signals. 

437  

438 Args: 

439 widget: Widget to update 

440 value: New value 

441 """ 

442 from PyQt6.QtWidgets import QLineEdit, QCheckBox, QSpinBox, QDoubleSpinBox, QComboBox 

443 # Import the no-scroll classes from the same module scope 

444 from openhcs.pyqt_gui.shared.typed_widget_factory import NoScrollSpinBox, NoScrollDoubleSpinBox, NoScrollComboBox 

445 

446 # Temporarily block signals to avoid recursion 

447 widget.blockSignals(True) 

448 

449 try: 

450 if isinstance(widget, QCheckBox): 

451 widget.setChecked(bool(value)) 

452 elif isinstance(widget, (QSpinBox, NoScrollSpinBox)): 

453 widget.setValue(int(value) if value is not None else 0) 

454 elif isinstance(widget, (QDoubleSpinBox, NoScrollDoubleSpinBox)): 

455 widget.setValue(float(value) if value is not None else 0.0) 

456 elif isinstance(widget, (QComboBox, NoScrollComboBox)): 

457 for i in range(widget.count()): 

458 if widget.itemData(i) == value: 

459 widget.setCurrentIndex(i) 

460 break 

461 elif isinstance(widget, QLineEdit): 

462 widget.setText(str(value) if value is not None else "") 

463 finally: 

464 widget.blockSignals(False) 

465 

466 def get_current_kwargs(self) -> Dict[str, Any]: 

467 """ 

468 Get current kwargs values (extracted from Textual version). 

469  

470 Returns: 

471 Current parameter values 

472 """ 

473 return self._internal_kwargs.copy() 

474 

475 def sync_kwargs(self): 

476 """Sync internal kwargs to main kwargs (extracted from Textual version).""" 

477 self.kwargs = self._internal_kwargs.copy() 

478 

479 def update_function(self, func_item: Tuple[Callable, Dict]): 

480 """ 

481 Update the function and parameters. 

482  

483 Args: 

484 func_item: New function item tuple 

485 """ 

486 self.func, self.kwargs = func_item 

487 self._internal_kwargs = self.kwargs.copy() 

488 

489 # Recreate form manager 

490 if self.func: 

491 param_info = SignatureAnalyzer.analyze(self.func) 

492 parameters = {name: self.kwargs.get(name, info.default_value) for name, info in param_info.items()} 

493 parameter_types = {name: info.param_type for name, info in param_info.items()} 

494 

495 self.form_manager = TextualParameterFormManager(parameters, parameter_types, f"func_{self.index}", param_info) 

496 self.param_defaults = {name: info.default_value for name, info in param_info.items()} 

497 else: 

498 self.form_manager = None 

499 self.param_defaults = {} 

500 

501 # Rebuild UI 

502 self.setup_ui() 

503 

504 logger.debug(f"Updated function for index {self.index}") 

505 

506 

507class FunctionListWidget(QWidget): 

508 """ 

509 PyQt6 Function List Widget. 

510  

511 Container for multiple FunctionPaneWidgets with list management. 

512 """ 

513 

514 # Signals 

515 functions_changed = pyqtSignal(list) # List of function items 

516 

517 def __init__(self, service_adapter, color_scheme: Optional[PyQt6ColorScheme] = None, parent=None): 

518 """ 

519 Initialize the function list widget. 

520  

521 Args: 

522 service_adapter: PyQt service adapter 

523 parent: Parent widget 

524 """ 

525 super().__init__(parent) 

526 

527 # Initialize color scheme 

528 self.color_scheme = color_scheme or PyQt6ColorScheme() 

529 

530 self.service_adapter = service_adapter 

531 self.functions: List[Tuple[Callable, Dict]] = [] 

532 self.function_panes: List[FunctionPaneWidget] = [] 

533 

534 # Setup UI 

535 self.setup_ui() 

536 

537 def setup_ui(self): 

538 """Setup the user interface.""" 

539 layout = QVBoxLayout(self) 

540 

541 # Scroll area for function panes 

542 scroll_area = QScrollArea() 

543 scroll_area.setWidgetResizable(True) 

544 scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) 

545 scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) 

546 

547 # Container widget for function panes 

548 self.container_widget = QWidget() 

549 self.container_layout = QVBoxLayout(self.container_widget) 

550 self.container_layout.setSpacing(5) 

551 

552 scroll_area.setWidget(self.container_widget) 

553 layout.addWidget(scroll_area) 

554 

555 # Add function button 

556 add_button = QPushButton("Add Function") 

557 add_button.clicked.connect(lambda: self.add_function_at_index(len(self.functions))) 

558 layout.addWidget(add_button) 

559 

560 def update_function_list(self): 

561 """Update the function list display.""" 

562 # Clear existing panes 

563 for pane in self.function_panes: 

564 pane.setParent(None) 

565 self.function_panes.clear() 

566 

567 # Create new panes 

568 for i, func_item in enumerate(self.functions): 

569 pane = FunctionPaneWidget(func_item, i, self.service_adapter, color_scheme=self.color_scheme) 

570 

571 # Connect signals 

572 pane.parameter_changed.connect(self.on_parameter_changed) 

573 pane.add_function.connect(self.add_function_at_index) 

574 pane.remove_function.connect(self.remove_function_at_index) 

575 pane.move_function.connect(self.move_function) 

576 

577 self.function_panes.append(pane) 

578 self.container_layout.addWidget(pane) 

579 

580 self.container_layout.addStretch() 

581 

582 def add_function_at_index(self, index: int): 

583 """Add function at specific index.""" 

584 # Placeholder function 

585 new_func_item = (lambda x: x, {}) 

586 self.functions.insert(index, new_func_item) 

587 self.update_function_list() 

588 self.functions_changed.emit(self.functions) 

589 

590 def remove_function_at_index(self, index: int): 

591 """Remove function at specific index.""" 

592 if 0 <= index < len(self.functions): 

593 self.functions.pop(index) 

594 self.update_function_list() 

595 self.functions_changed.emit(self.functions) 

596 

597 def move_function(self, index: int, direction: int): 

598 """Move function up or down.""" 

599 new_index = index + direction 

600 if 0 <= new_index < len(self.functions): 

601 self.functions[index], self.functions[new_index] = self.functions[new_index], self.functions[index] 

602 self.update_function_list() 

603 self.functions_changed.emit(self.functions) 

604 

605 def on_parameter_changed(self, index: int, param_name: str, value: Any): 

606 """Handle parameter changes.""" 

607 if 0 <= index < len(self.functions): 

608 func, kwargs = self.functions[index] 

609 new_kwargs = kwargs.copy() 

610 new_kwargs[param_name] = value 

611 self.functions[index] = (func, new_kwargs) 

612 self.functions_changed.emit(self.functions)