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

260 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-10-01 18:33 +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.pyqt_gui.widgets.shared.parameter_form_manager import ParameterFormManager 

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 print(f"🔍 FUNCTION PANE DEBUG: param_info = {param_info}") 

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

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

75 

76 # Store function signature defaults 

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

78 print(f"🔍 FUNCTION PANE DEBUG: param_defaults = {self.param_defaults}") 

79 

80 # SIMPLIFIED: Use new generic constructor with function as object_instance 

81 self.form_manager = ParameterFormManager( 

82 object_instance=self.func, # Pass function as the object to build form for 

83 field_id=f"func_{index}", # Use function index as field identifier 

84 parent=self, # Pass self as parent widget 

85 context_obj=None # Functions don't need context for placeholder resolution 

86 ) 

87 else: 

88 self.form_manager = None 

89 self.param_defaults = {} 

90 

91 # Internal kwargs tracking (extracted from Textual version) 

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

93 

94 # UI components 

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

96 

97 # Setup UI 

98 self.setup_ui() 

99 self.setup_connections() 

100 

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

102 

103 def setup_ui(self): 

104 """Setup the user interface.""" 

105 layout = QVBoxLayout(self) 

106 layout.setContentsMargins(5, 5, 5, 5) 

107 layout.setSpacing(5) 

108 

109 # Function header 

110 header_frame = self.create_function_header() 

111 layout.addWidget(header_frame) 

112 

113 # Control buttons 

114 button_frame = self.create_button_panel() 

115 layout.addWidget(button_frame) 

116 

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

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

119 parameter_frame = self.create_parameter_form() 

120 layout.addWidget(parameter_frame) 

121 

122 # Set styling 

123 self.setStyleSheet(f""" 

124 FunctionPaneWidget {{ 

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

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

127 border-radius: 5px; 

128 margin: 2px; 

129 }} 

130 """) 

131 

132 def create_function_header(self) -> QWidget: 

133 """ 

134 Create the function header with name and info. 

135  

136 Returns: 

137 Widget containing function header 

138 """ 

139 frame = QFrame() 

140 frame.setFrameStyle(QFrame.Shape.Box) 

141 frame.setStyleSheet(f""" 

142 QFrame {{ 

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

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

145 border-radius: 3px; 

146 padding: 5px; 

147 }} 

148 """) 

149 

150 layout = QHBoxLayout(frame) 

151 

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

153 if self.func: 

154 func_name = self.func.__name__ 

155 func_module = self.func.__module__ 

156 

157 # Function name with help 

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

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

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

161 layout.addWidget(name_label) 

162 

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

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

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

166 layout.addWidget(help_indicator) 

167 

168 # Module info 

169 if func_module: 

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

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

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

173 layout.addWidget(module_label) 

174 else: 

175 name_label = QLabel("No Function Selected") 

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

177 layout.addWidget(name_label) 

178 

179 layout.addStretch() 

180 

181 return frame 

182 

183 def create_button_panel(self) -> QWidget: 

184 """ 

185 Create the control button panel. 

186  

187 Returns: 

188 Widget containing control buttons 

189 """ 

190 frame = QFrame() 

191 frame.setFrameStyle(QFrame.Shape.Box) 

192 frame.setStyleSheet(f""" 

193 QFrame {{ 

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

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

196 border-radius: 3px; 

197 padding: 3px; 

198 }} 

199 """) 

200 

201 layout = QHBoxLayout(frame) 

202 layout.addStretch() # Center the buttons 

203 

204 # Button configurations (extracted from Textual version) 

205 button_configs = [ 

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

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

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

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

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

211 ] 

212 

213 for name, action, tooltip in button_configs: 

214 button = QPushButton(name) 

215 button.setToolTip(tooltip) 

216 button.setMaximumWidth(60) 

217 button.setMaximumHeight(25) 

218 button.setStyleSheet(f""" 

219 QPushButton {{ 

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

221 color: white; 

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

223 border-radius: 2px; 

224 padding: 2px; 

225 font-size: 10px; 

226 }} 

227 QPushButton:hover {{ 

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

229 }} 

230 QPushButton:pressed {{ 

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

232 }} 

233 """) 

234 

235 # Connect button to action 

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

237 

238 layout.addWidget(button) 

239 

240 layout.addStretch() # Center the buttons 

241 

242 return frame 

243 

244 def create_parameter_form(self) -> QWidget: 

245 """ 

246 Create the parameter form using extracted business logic. 

247  

248 Returns: 

249 Widget containing parameter form 

250 """ 

251 group_box = QGroupBox("Parameters") 

252 group_box.setStyleSheet(f""" 

253 QGroupBox {{ 

254 font-weight: bold; 

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

256 border-radius: 3px; 

257 margin-top: 10px; 

258 padding-top: 10px; 

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

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

261 }} 

262 QGroupBox::title {{ 

263 subcontrol-origin: margin; 

264 left: 10px; 

265 padding: 0 5px 0 5px; 

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

267 }} 

268 """) 

269 

270 layout = QVBoxLayout(group_box) 

271 

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

273 if self.form_manager: 

274 # Import the enhanced PyQt6 ParameterFormManager 

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

276 

277 # FIXED: Use new simplified constructor 

278 enhanced_form_manager = PyQtParameterFormManager( 

279 object_instance=self.func, # Pass function as the object to build form for 

280 field_id=f"func_{self.index}", # Use function index as field identifier 

281 parent=self, # Pass self as parent widget 

282 context_obj=None # Functions don't need context for placeholder resolution 

283 ) 

284 

285 # Connect parameter changes 

286 enhanced_form_manager.parameter_changed.connect( 

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

288 ) 

289 

290 layout.addWidget(enhanced_form_manager) 

291 

292 # Store reference for parameter updates 

293 self.enhanced_form_manager = enhanced_form_manager 

294 

295 return group_box 

296 

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

298 """ 

299 Create parameter widget based on type. 

300 

301 Args: 

302 param_name: Parameter name 

303 param_type: Parameter type 

304 current_value: Current parameter value 

305 

306 Returns: 

307 Widget for parameter editing or None 

308 """ 

309 from PyQt6.QtWidgets import QLineEdit, QCheckBox, QComboBox 

310 from openhcs.pyqt_gui.widgets.shared.no_scroll_spinbox import ( 

311 NoScrollSpinBox, NoScrollDoubleSpinBox, NoScrollComboBox 

312 ) 

313 

314 # Boolean parameters 

315 if param_type == bool: 

316 from openhcs.pyqt_gui.widgets.shared.no_scroll_spinbox import NoneAwareCheckBox 

317 widget = NoneAwareCheckBox() 

318 widget.set_value(current_value) # Use set_value to handle None properly 

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

320 return widget 

321 

322 # Integer parameters 

323 elif param_type == int: 

324 widget = NoScrollSpinBox() 

325 widget.setRange(-999999, 999999) 

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

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

328 return widget 

329 

330 # Float parameters 

331 elif param_type == float: 

332 widget = NoScrollDoubleSpinBox() 

333 widget.setRange(-999999.0, 999999.0) 

334 widget.setDecimals(6) 

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

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

337 return widget 

338 

339 # Enum parameters 

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

341 from openhcs.pyqt_gui.widgets.shared.widget_strategies import create_enum_widget_unified 

342 

343 # Use the single source of truth for enum widget creation 

344 widget = create_enum_widget_unified(param_type, current_value) 

345 

346 widget.currentIndexChanged.connect( 

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

348 ) 

349 return widget 

350 

351 # String and other parameters 

352 else: 

353 widget = QLineEdit() 

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

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

356 return widget 

357 

358 def setup_connections(self): 

359 """Setup signal/slot connections.""" 

360 pass # Connections are set up in widget creation 

361 

362 def handle_button_action(self, action: str): 

363 """ 

364 Handle button actions (extracted from Textual version). 

365  

366 Args: 

367 action: Action identifier 

368 """ 

369 if action == "move_up": 

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

371 elif action == "move_down": 

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

373 elif action == "add_func": 

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

375 elif action == "remove_func": 

376 self.remove_function.emit(self.index) 

377 elif action == "reset_all": 

378 self.reset_all_parameters() 

379 

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

381 """ 

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

383  

384 Args: 

385 param_name: Name of the parameter 

386 value: New parameter value 

387 """ 

388 # Update internal kwargs without triggering reactive update 

389 self._internal_kwargs[param_name] = value 

390 

391 # Update form manager 

392 if self.form_manager: 

393 self.form_manager.update_parameter(param_name, value) 

394 final_value = self.form_manager.parameters[param_name] 

395 else: 

396 final_value = value 

397 

398 # Emit parameter changed signal 

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

400 

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

402 

403 def reset_all_parameters(self): 

404 """Reset all parameters to default values using enhanced PyQt6 form manager.""" 

405 

406 # Use the PyQt6 form manager's reset functionality which properly handles widgets 

407 self.enhanced_form_manager.reset_all_parameters() 

408 

409 # Update internal kwargs to match the reset values 

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

411 self._internal_kwargs[param_name] = default_value 

412 # Emit parameter changed signal 

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

414 

415 self.reset_parameters.emit(self.index) 

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

417 

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

419 """ 

420 Update widget value without triggering signals. 

421  

422 Args: 

423 widget: Widget to update 

424 value: New value 

425 """ 

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

427 # Import the no-scroll classes from single source of truth 

428 from openhcs.pyqt_gui.widgets.shared.no_scroll_spinbox import ( 

429 NoScrollSpinBox, NoScrollDoubleSpinBox, NoScrollComboBox 

430 ) 

431 

432 # Temporarily block signals to avoid recursion 

433 widget.blockSignals(True) 

434 

435 try: 

436 if isinstance(widget, QCheckBox): 

437 widget.setChecked(bool(value)) 

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

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

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

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

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

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

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

445 widget.setCurrentIndex(i) 

446 break 

447 elif isinstance(widget, QLineEdit): 

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

449 finally: 

450 widget.blockSignals(False) 

451 

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

453 """ 

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

455  

456 Returns: 

457 Current parameter values 

458 """ 

459 return self._internal_kwargs.copy() 

460 

461 def sync_kwargs(self): 

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

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

464 

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

466 """ 

467 Update the function and parameters. 

468  

469 Args: 

470 func_item: New function item tuple 

471 """ 

472 self.func, self.kwargs = func_item 

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

474 

475 # Recreate form manager 

476 if self.func: 

477 param_info = SignatureAnalyzer.analyze(self.func) 

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

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

480 

481 # Store function signature defaults 

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

483 

484 # SIMPLIFIED: Use new generic constructor with function as object_instance 

485 self.form_manager = ParameterFormManager( 

486 object_instance=self.func, # Pass function as the object to build form for 

487 field_id=f"func_{self.index}", # Use function index as field identifier 

488 parent=self, # Pass self as parent widget 

489 context_obj=None # Functions don't need context for placeholder resolution 

490 ) 

491 else: 

492 self.form_manager = None 

493 self.param_defaults = {} 

494 

495 # Rebuild UI 

496 self.setup_ui() 

497 

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

499 

500 

501class FunctionListWidget(QWidget): 

502 """ 

503 PyQt6 Function List Widget. 

504  

505 Container for multiple FunctionPaneWidgets with list management. 

506 """ 

507 

508 # Signals 

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

510 

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

512 """ 

513 Initialize the function list widget. 

514  

515 Args: 

516 service_adapter: PyQt service adapter 

517 parent: Parent widget 

518 """ 

519 super().__init__(parent) 

520 

521 # Initialize color scheme 

522 self.color_scheme = color_scheme or PyQt6ColorScheme() 

523 

524 self.service_adapter = service_adapter 

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

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

527 

528 # Setup UI 

529 self.setup_ui() 

530 

531 def setup_ui(self): 

532 """Setup the user interface.""" 

533 layout = QVBoxLayout(self) 

534 

535 # Scroll area for function panes 

536 scroll_area = QScrollArea() 

537 scroll_area.setWidgetResizable(True) 

538 scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) 

539 scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) 

540 

541 # Container widget for function panes 

542 self.container_widget = QWidget() 

543 self.container_layout = QVBoxLayout(self.container_widget) 

544 self.container_layout.setSpacing(5) 

545 

546 scroll_area.setWidget(self.container_widget) 

547 layout.addWidget(scroll_area) 

548 

549 # Add function button 

550 add_button = QPushButton("Add Function") 

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

552 layout.addWidget(add_button) 

553 

554 def update_function_list(self): 

555 """Update the function list display.""" 

556 # Clear existing panes 

557 for pane in self.function_panes: 

558 pane.setParent(None) 

559 self.function_panes.clear() 

560 

561 # Create new panes 

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

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

564 

565 # Connect signals 

566 pane.parameter_changed.connect(self.on_parameter_changed) 

567 pane.add_function.connect(self.add_function_at_index) 

568 pane.remove_function.connect(self.remove_function_at_index) 

569 pane.move_function.connect(self.move_function) 

570 

571 self.function_panes.append(pane) 

572 self.container_layout.addWidget(pane) 

573 

574 self.container_layout.addStretch() 

575 

576 def add_function_at_index(self, index: int): 

577 """Add function at specific index.""" 

578 # Placeholder function 

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

580 self.functions.insert(index, new_func_item) 

581 self.update_function_list() 

582 self.functions_changed.emit(self.functions) 

583 

584 def remove_function_at_index(self, index: int): 

585 """Remove function at specific index.""" 

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

587 self.functions.pop(index) 

588 self.update_function_list() 

589 self.functions_changed.emit(self.functions) 

590 

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

592 """Move function up or down.""" 

593 new_index = index + direction 

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

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

596 self.update_function_list() 

597 self.functions_changed.emit(self.functions) 

598 

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

600 """Handle parameter changes.""" 

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

602 func, kwargs = self.functions[index] 

603 new_kwargs = kwargs.copy() 

604 new_kwargs[param_name] = value 

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

606 self.functions_changed.emit(self.functions)