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

242 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-04 02:09 +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 

10 

11from PyQt6.QtWidgets import ( 

12 QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, 

13 QFrame, QScrollArea, QGroupBox 

14) 

15from PyQt6.QtCore import Qt, pyqtSignal 

16from PyQt6.QtGui import QFont 

17 

18from openhcs.introspection.signature_analyzer import SignatureAnalyzer 

19 

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

21from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme 

22 

23logger = logging.getLogger(__name__) 

24 

25 

26class FunctionPaneWidget(QWidget): 

27 """ 

28 PyQt6 Function Pane Widget. 

29  

30 Displays individual function with editable parameters and control buttons. 

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

32 """ 

33 

34 # Signals 

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

36 function_changed = pyqtSignal(int) # index 

37 add_function = pyqtSignal(int) # index 

38 remove_function = pyqtSignal(int) # index 

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

40 reset_parameters = pyqtSignal(int) # index 

41 

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

43 """ 

44 Initialize the function pane widget. 

45  

46 Args: 

47 func_item: Tuple of (function, kwargs) 

48 index: Function index in the list 

49 service_adapter: PyQt service adapter for dialogs and operations 

50 parent: Parent widget 

51 """ 

52 super().__init__(parent) 

53 

54 # Initialize color scheme 

55 self.color_scheme = color_scheme or PyQt6ColorScheme() 

56 

57 # Core dependencies 

58 self.service_adapter = service_adapter 

59 

60 # Business logic state (extracted from Textual version) 

61 self.func, self.kwargs = func_item 

62 self.index = index 

63 self.show_parameters = True 

64 

65 # Parameter management (extracted from Textual version) 

66 if self.func: 

67 param_info = SignatureAnalyzer.analyze(self.func) 

68 

69 # Store function signature defaults 

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

71 else: 

72 self.param_defaults = {} 

73 

74 # Form manager will be created in create_parameter_form() when UI is built 

75 self.form_manager = None 

76 

77 # Internal kwargs tracking (extracted from Textual version) 

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

79 

80 # UI components 

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

82 

83 # Setup UI 

84 self.setup_ui() 

85 self.setup_connections() 

86 

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

88 

89 def setup_ui(self): 

90 """Setup the user interface.""" 

91 layout = QVBoxLayout(self) 

92 layout.setContentsMargins(5, 5, 5, 5) 

93 layout.setSpacing(5) 

94 

95 # Combined header with title and buttons on same row 

96 header_frame = self.create_combined_header() 

97 layout.addWidget(header_frame) 

98 

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

100 if self.func and self.show_parameters: 

101 parameter_frame = self.create_parameter_form() 

102 layout.addWidget(parameter_frame) 

103 

104 # Set styling 

105 self.setStyleSheet(f""" 

106 FunctionPaneWidget {{ 

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

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

109 border-radius: 5px; 

110 margin: 2px; 

111 }} 

112 """) 

113 

114 def create_combined_header(self) -> QWidget: 

115 """ 

116 Create combined header with title and buttons on the same row. 

117 

118 Returns: 

119 Widget containing title and control buttons 

120 """ 

121 frame = QFrame() 

122 frame.setFrameStyle(QFrame.Shape.Box) 

123 frame.setStyleSheet(f""" 

124 QFrame {{ 

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

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

127 border-radius: 3px; 

128 padding: 5px; 

129 }} 

130 """) 

131 

132 layout = QHBoxLayout(frame) 

133 layout.setSpacing(10) 

134 

135 # Function name with help functionality (left side) 

136 if self.func: 

137 func_name = self.func.__name__ 

138 func_module = self.func.__module__ 

139 

140 # Function name with help 

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

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

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

144 layout.addWidget(name_label) 

145 

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

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

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

149 layout.addWidget(help_indicator) 

150 

151 # Module info 

152 if func_module: 

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

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

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

156 layout.addWidget(module_label) 

157 else: 

158 name_label = QLabel("No Function Selected") 

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

160 layout.addWidget(name_label) 

161 

162 layout.addStretch() 

163 

164 # Control buttons (right side) - using parameter form manager style 

165 from openhcs.pyqt_gui.shared.style_generator import StyleSheetGenerator 

166 style_gen = StyleSheetGenerator(self.color_scheme) 

167 button_styles = style_gen.generate_config_button_styles() 

168 

169 # Button configurations 

170 button_configs = [ 

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

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

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

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

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

176 ] 

177 

178 for name, action, tooltip in button_configs: 

179 button = QPushButton(name) 

180 button.setToolTip(tooltip) 

181 button.setMaximumWidth(60) 

182 

183 # Use reset button style for all buttons (consistent with parameter form manager) 

184 button.setStyleSheet(button_styles["reset"]) 

185 

186 # Connect button to action 

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

188 

189 layout.addWidget(button) 

190 

191 return frame 

192 

193 def create_parameter_form(self) -> QWidget: 

194 """ 

195 Create the parameter form using extracted business logic. 

196  

197 Returns: 

198 Widget containing parameter form 

199 """ 

200 group_box = QGroupBox("Parameters") 

201 group_box.setStyleSheet(f""" 

202 QGroupBox {{ 

203 font-weight: bold; 

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

205 border-radius: 3px; 

206 margin-top: 10px; 

207 padding-top: 10px; 

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

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

210 }} 

211 QGroupBox::title {{ 

212 subcontrol-origin: margin; 

213 left: 10px; 

214 padding: 0 5px 0 5px; 

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

216 }} 

217 """) 

218 

219 layout = QVBoxLayout(group_box) 

220 

221 # Create the ParameterFormManager with help and reset functionality 

222 # Import the enhanced PyQt6 ParameterFormManager 

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

224 

225 # Create form manager with initial_values to load saved kwargs 

226 self.form_manager = PyQtParameterFormManager( 

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

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

229 parent=self, # Pass self as parent widget 

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

231 initial_values=self.kwargs, # Pass saved kwargs to populate form fields 

232 color_scheme=self.color_scheme # Pass color_scheme for consistent theming 

233 ) 

234 

235 # Connect parameter changes 

236 self.form_manager.parameter_changed.connect( 

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

238 ) 

239 

240 layout.addWidget(self.form_manager) 

241 

242 return group_box 

243 

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

245 """ 

246 Create parameter widget based on type. 

247 

248 Args: 

249 param_name: Parameter name 

250 param_type: Parameter type 

251 current_value: Current parameter value 

252 

253 Returns: 

254 Widget for parameter editing or None 

255 """ 

256 from PyQt6.QtWidgets import QLineEdit 

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

258 NoScrollSpinBox, NoScrollDoubleSpinBox 

259 ) 

260 

261 # Boolean parameters 

262 if param_type == bool: 

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

264 widget = NoneAwareCheckBox() 

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

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

267 return widget 

268 

269 # Integer parameters 

270 elif param_type == int: 

271 widget = NoScrollSpinBox() 

272 widget.setRange(-999999, 999999) 

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

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

275 return widget 

276 

277 # Float parameters 

278 elif param_type == float: 

279 widget = NoScrollDoubleSpinBox() 

280 widget.setRange(-999999.0, 999999.0) 

281 widget.setDecimals(6) 

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

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

284 return widget 

285 

286 # Enum parameters 

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

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

289 

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

291 widget = create_enum_widget_unified(param_type, current_value) 

292 

293 widget.currentIndexChanged.connect( 

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

295 ) 

296 return widget 

297 

298 # String and other parameters 

299 else: 

300 widget = QLineEdit() 

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

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

303 return widget 

304 

305 def setup_connections(self): 

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

307 pass # Connections are set up in widget creation 

308 

309 def handle_button_action(self, action: str): 

310 """ 

311 Handle button actions (extracted from Textual version). 

312  

313 Args: 

314 action: Action identifier 

315 """ 

316 if action == "move_up": 

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

318 elif action == "move_down": 

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

320 elif action == "add_func": 

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

322 elif action == "remove_func": 

323 self.remove_function.emit(self.index) 

324 elif action == "reset_all": 

325 self.reset_all_parameters() 

326 

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

328 """ 

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

330 

331 Args: 

332 param_name: Name of the parameter 

333 value: New parameter value 

334 """ 

335 # Update internal kwargs without triggering reactive update 

336 self._internal_kwargs[param_name] = value 

337 

338 # The form manager already has the updated value (it emitted this signal) 

339 # No need to call update_parameter() again - that would be redundant 

340 

341 # Emit parameter changed signal to notify parent (function list editor) 

342 self.parameter_changed.emit(self.index, param_name, value) 

343 

344 logger.debug(f"Parameter changed: {param_name} = {value}") 

345 

346 def reset_all_parameters(self): 

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

348 if not self.form_manager: 

349 return 

350 

351 # Reset all parameters - form manager will use signature defaults from param_defaults 

352 for param_name in list(self.form_manager.parameters.keys()): 

353 self.form_manager.reset_parameter(param_name) 

354 

355 # Update internal kwargs to match the reset values 

356 self._internal_kwargs = self.form_manager.get_current_values() 

357 

358 # Emit parameter changed signals for each reset parameter 

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

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

361 

362 self.reset_parameters.emit(self.index) 

363 

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

365 """ 

366 Update widget value without triggering signals. 

367  

368 Args: 

369 widget: Widget to update 

370 value: New value 

371 """ 

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

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

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

375 NoScrollSpinBox, NoScrollDoubleSpinBox, NoScrollComboBox 

376 ) 

377 

378 # Temporarily block signals to avoid recursion 

379 widget.blockSignals(True) 

380 

381 try: 

382 if isinstance(widget, QCheckBox): 

383 widget.setChecked(bool(value)) 

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

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

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

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

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

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

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

391 widget.setCurrentIndex(i) 

392 break 

393 elif isinstance(widget, QLineEdit): 

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

395 finally: 

396 widget.blockSignals(False) 

397 

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

399 """ 

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

401  

402 Returns: 

403 Current parameter values 

404 """ 

405 return self._internal_kwargs.copy() 

406 

407 def sync_kwargs(self): 

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

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

410 

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

412 """ 

413 Update the function and parameters. 

414  

415 Args: 

416 func_item: New function item tuple 

417 """ 

418 self.func, self.kwargs = func_item 

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

420 

421 # Update parameter defaults 

422 if self.func: 

423 param_info = SignatureAnalyzer.analyze(self.func) 

424 # Store function signature defaults 

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

426 else: 

427 self.param_defaults = {} 

428 

429 # Form manager will be recreated in create_parameter_form() when UI is rebuilt 

430 self.form_manager = None 

431 

432 # Rebuild UI (this will create the form manager in create_parameter_form()) 

433 self.setup_ui() 

434 

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

436 

437 

438class FunctionListWidget(QWidget): 

439 """ 

440 PyQt6 Function List Widget. 

441  

442 Container for multiple FunctionPaneWidgets with list management. 

443 """ 

444 

445 # Signals 

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

447 

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

449 """ 

450 Initialize the function list widget. 

451  

452 Args: 

453 service_adapter: PyQt service adapter 

454 parent: Parent widget 

455 """ 

456 super().__init__(parent) 

457 

458 # Initialize color scheme 

459 self.color_scheme = color_scheme or PyQt6ColorScheme() 

460 

461 self.service_adapter = service_adapter 

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

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

464 

465 # Setup UI 

466 self.setup_ui() 

467 

468 def setup_ui(self): 

469 """Setup the user interface.""" 

470 layout = QVBoxLayout(self) 

471 

472 # Scroll area for function panes 

473 scroll_area = QScrollArea() 

474 scroll_area.setWidgetResizable(True) 

475 scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) 

476 scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) 

477 

478 # Container widget for function panes 

479 self.container_widget = QWidget() 

480 self.container_layout = QVBoxLayout(self.container_widget) 

481 self.container_layout.setSpacing(5) 

482 

483 scroll_area.setWidget(self.container_widget) 

484 layout.addWidget(scroll_area) 

485 

486 # Add function button 

487 add_button = QPushButton("Add Function") 

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

489 layout.addWidget(add_button) 

490 

491 def update_function_list(self): 

492 """Update the function list display.""" 

493 # Clear existing panes - CRITICAL: Manually unregister form managers BEFORE deleteLater() 

494 # This prevents RuntimeError when new widgets try to connect to deleted managers 

495 for pane in self.function_panes: 

496 # Explicitly unregister the form manager before scheduling deletion 

497 if hasattr(pane, 'form_manager') and pane.form_manager is not None: 

498 try: 

499 pane.form_manager.unregister_from_cross_window_updates() 

500 except RuntimeError: 

501 pass # Already deleted 

502 pane.deleteLater() # Schedule for deletion - triggers destroyed signal 

503 self.function_panes.clear() 

504 

505 # Create new panes 

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

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

508 

509 # Connect signals 

510 pane.parameter_changed.connect(self.on_parameter_changed) 

511 pane.add_function.connect(self.add_function_at_index) 

512 pane.remove_function.connect(self.remove_function_at_index) 

513 pane.move_function.connect(self.move_function) 

514 

515 self.function_panes.append(pane) 

516 self.container_layout.addWidget(pane) 

517 

518 self.container_layout.addStretch() 

519 

520 def add_function_at_index(self, index: int): 

521 """Add function at specific index.""" 

522 # Placeholder function 

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

524 self.functions.insert(index, new_func_item) 

525 self.update_function_list() 

526 self.functions_changed.emit(self.functions) 

527 

528 def remove_function_at_index(self, index: int): 

529 """Remove function at specific index.""" 

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

531 self.functions.pop(index) 

532 self.update_function_list() 

533 self.functions_changed.emit(self.functions) 

534 

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

536 """Move function up or down.""" 

537 new_index = index + direction 

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

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

540 self.update_function_list() 

541 self.functions_changed.emit(self.functions) 

542 

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

544 """Handle parameter changes.""" 

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

546 func, kwargs = self.functions[index] 

547 new_kwargs = kwargs.copy() 

548 new_kwargs[param_name] = value 

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

550 self.functions_changed.emit(self.functions)