Coverage for openhcs/pyqt_gui/widgets/shared/parameter_form_manager.py: 0.0%

442 statements  

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

1""" 

2Parameter form manager for PyQt6 GUI. 

3 

4REUSES the Textual TUI parameter form generation logic for consistent UX. 

5This is a PyQt6 adapter that uses the actual working Textual TUI services. 

6""" 

7 

8import dataclasses 

9import logging 

10from typing import Any, Dict, get_origin, get_args, Union, Optional, Type 

11from pathlib import Path 

12from enum import Enum 

13 

14from PyQt6.QtWidgets import ( 

15 QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QSpinBox, 

16 QDoubleSpinBox, QCheckBox, QComboBox, QPushButton, QGroupBox, 

17 QScrollArea, QFrame 

18) 

19from PyQt6.QtGui import QWheelEvent 

20from PyQt6.QtCore import Qt, pyqtSignal 

21 

22from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme 

23 

24 

25class NoneAwareLineEdit(QLineEdit): 

26 """QLineEdit that properly handles None values for lazy dataclass contexts.""" 

27 

28 def get_value(self): 

29 """Get value, returning None for empty text instead of empty string.""" 

30 text = self.text().strip() 

31 return None if text == "" else text 

32 

33 def set_value(self, value): 

34 """Set value, handling None properly.""" 

35 self.setText("" if value is None else str(value)) 

36 

37 

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

39# Import no-scroll widgets from separate module 

40from .no_scroll_spinbox import NoScrollSpinBox, NoScrollDoubleSpinBox, NoScrollComboBox 

41 

42# REUSE the actual working Textual TUI services 

43from openhcs.textual_tui.widgets.shared.signature_analyzer import SignatureAnalyzer, ParameterInfo 

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

45from openhcs.textual_tui.widgets.shared.typed_widget_factory import TypedWidgetFactory 

46 

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

48from openhcs.pyqt_gui.widgets.shared.clickable_help_components import LabelWithHelp, GroupBoxWithHelp 

49 

50# Import simplified abstraction layer 

51from openhcs.ui.shared.parameter_form_abstraction import ( 

52 ParameterFormAbstraction, apply_lazy_default_placeholder 

53) 

54from openhcs.ui.shared.widget_creation_registry import create_pyqt6_registry 

55from openhcs.ui.shared.pyqt6_widget_strategies import PyQt6WidgetEnhancer 

56 

57logger = logging.getLogger(__name__) 

58 

59 

60class ParameterFormManager(QWidget): 

61 """ 

62 PyQt6 adapter for Textual TUI ParameterFormManager. 

63 

64 REUSES the actual working Textual TUI parameter form logic by creating 

65 a PyQt6 UI that mirrors the Textual TUI behavior exactly. 

66 """ 

67 

68 parameter_changed = pyqtSignal(str, object) # param_name, value 

69 

70 def __init__(self, parameters: Dict[str, Any], parameter_types: Dict[str, type], 

71 field_id: str, parameter_info: Dict = None, parent=None, use_scroll_area: bool = True, 

72 function_target=None, color_scheme: Optional[PyQt6ColorScheme] = None, 

73 is_global_config_editing: bool = False, global_config_type: Optional[Type] = None, 

74 placeholder_prefix: str = "Pipeline default"): 

75 super().__init__(parent) 

76 

77 # Initialize color scheme 

78 self.color_scheme = color_scheme or PyQt6ColorScheme() 

79 

80 # Store function target for docstring fallback 

81 self._function_target = function_target 

82 

83 # Initialize simplified abstraction layer 

84 self.form_abstraction = ParameterFormAbstraction( 

85 parameters, parameter_types, field_id, create_pyqt6_registry(), parameter_info 

86 ) 

87 

88 # Create the actual Textual TUI form manager (reuse the working logic for compatibility) 

89 self.textual_form_manager = TextualParameterFormManager( 

90 parameters, parameter_types, field_id, parameter_info, is_global_config_editing=is_global_config_editing 

91 ) 

92 

93 # Store field_id for PyQt6 widget creation 

94 self.field_id = field_id 

95 self.is_global_config_editing = is_global_config_editing 

96 self.global_config_type = global_config_type 

97 self.placeholder_prefix = placeholder_prefix 

98 

99 # Control whether to use scroll area (disable for nested dataclasses) 

100 self.use_scroll_area = use_scroll_area 

101 

102 # Track PyQt6 widgets for value updates 

103 self.widgets = {} 

104 self.nested_managers = {} 

105 

106 # Optional lazy dataclass for placeholder generation in nested static forms 

107 self.lazy_dataclass_for_placeholders = None 

108 

109 self.setup_ui() 

110 

111 def setup_ui(self): 

112 """Setup the parameter form UI using Textual TUI logic.""" 

113 layout = QVBoxLayout(self) 

114 layout.setContentsMargins(0, 0, 0, 0) 

115 

116 # Content widget 

117 content_widget = QWidget() 

118 content_layout = QVBoxLayout(content_widget) 

119 

120 # Build form fields using Textual TUI parameter types and logic 

121 for param_name, param_type in self.textual_form_manager.parameter_types.items(): 

122 current_value = self.textual_form_manager.parameters[param_name] 

123 

124 # Handle Optional[dataclass] types with checkbox wrapper 

125 if self._is_optional_dataclass(param_type): 

126 inner_dataclass_type = self._get_optional_inner_type(param_type) 

127 field_widget = self._create_optional_dataclass_field(param_name, inner_dataclass_type, current_value) 

128 # Handle nested dataclasses (reuse Textual TUI logic) 

129 elif dataclasses.is_dataclass(param_type): 

130 field_widget = self._create_nested_dataclass_field(param_name, param_type, current_value) 

131 else: 

132 field_widget = self._create_regular_parameter_field(param_name, param_type, current_value) 

133 

134 if field_widget: 

135 content_layout.addWidget(field_widget) 

136 

137 # Only use scroll area if requested (not for nested dataclasses) 

138 if self.use_scroll_area: 

139 scroll_area = QScrollArea() 

140 scroll_area.setWidgetResizable(True) 

141 scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) 

142 scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) 

143 scroll_area.setWidget(content_widget) 

144 layout.addWidget(scroll_area) 

145 else: 

146 # Add content widget directly without scroll area 

147 layout.addWidget(content_widget) 

148 

149 def _create_nested_dataclass_field(self, param_name: str, param_type: type, current_value: Any) -> QWidget: 

150 """Create a collapsible group for nested dataclass with help functionality.""" 

151 # Use GroupBoxWithHelp to show dataclass documentation 

152 group_box = GroupBoxWithHelp( 

153 title=f"{param_name.replace('_', ' ').title()}", 

154 help_target=param_type, # Show help for the dataclass type 

155 color_scheme=self.color_scheme 

156 ) 

157 

158 # Use the content layout from GroupBoxWithHelp 

159 layout = group_box.content_layout 

160 

161 # Check if we need to create a lazy version of the nested dataclass 

162 nested_dataclass_for_form = self._create_lazy_nested_dataclass_if_needed(param_name, param_type, current_value) 

163 

164 # Analyze nested dataclass 

165 nested_param_info = SignatureAnalyzer.analyze(param_type) 

166 

167 # Get current values from nested dataclass instance 

168 nested_parameters = {} 

169 nested_parameter_types = {} 

170 

171 for nested_name, nested_info in nested_param_info.items(): 

172 if self.is_global_config_editing: 

173 # Global config editing: use concrete values 

174 if nested_dataclass_for_form: 

175 nested_current_value = getattr(nested_dataclass_for_form, nested_name, nested_info.default_value) 

176 else: 

177 nested_current_value = nested_info.default_value 

178 else: 

179 # Lazy context: check if field has a concrete value, otherwise use None for placeholder behavior 

180 if nested_dataclass_for_form: 

181 # Extract the actual value from the nested dataclass 

182 # For both lazy and regular dataclasses, use getattr to get the resolved value 

183 nested_current_value = getattr(nested_dataclass_for_form, nested_name, None) 

184 

185 # If this is a lazy dataclass and we got a resolved value, check if it's actually stored 

186 if hasattr(nested_dataclass_for_form, '_resolve_field_value') and nested_current_value is not None: 

187 # Check if this field has a concrete stored value vs lazy resolved value 

188 try: 

189 stored_value = object.__getattribute__(nested_dataclass_for_form, nested_name) 

190 # If stored value is None, this field is lazy (use None for placeholder) 

191 # If stored value is not None, this field is concrete (use the value) 

192 nested_current_value = stored_value 

193 except AttributeError: 

194 # Field doesn't exist as stored attribute, so it's lazy (use None for placeholder) 

195 nested_current_value = None 

196 else: 

197 # No nested dataclass instance - use None for placeholder behavior 

198 nested_current_value = None 

199 

200 nested_parameters[nested_name] = nested_current_value 

201 nested_parameter_types[nested_name] = nested_info.param_type 

202 

203 # Create nested form manager without scroll area (dataclasses should show in full) 

204 nested_field_id = f"{self.field_id}_{param_name}" 

205 

206 # For lazy contexts where we need placeholder generation, create a lazy dataclass 

207 lazy_dataclass_for_placeholders = None 

208 if not self._should_use_concrete_nested_values(nested_dataclass_for_form): 

209 # We're in a lazy context - create lazy dataclass for placeholder generation 

210 lazy_dataclass_for_placeholders = self._create_static_lazy_dataclass_for_placeholders(param_type) 

211 # Use special field_id to signal nested forms should not use thread-local resolution 

212 nested_field_id = f"nested_static_{param_name}" 

213 

214 # Create nested form manager without scroll area (dataclasses should show in full) 

215 nested_manager = ParameterFormManager( 

216 nested_parameters, 

217 nested_parameter_types, 

218 nested_field_id, 

219 nested_param_info, 

220 use_scroll_area=False, # Disable scroll area for nested dataclasses 

221 is_global_config_editing=self.is_global_config_editing # Pass through the global config editing flag 

222 ) 

223 

224 # For nested static forms, provide the lazy dataclass for placeholder generation 

225 if lazy_dataclass_for_placeholders: 

226 nested_manager.lazy_dataclass_for_placeholders = lazy_dataclass_for_placeholders 

227 

228 # Store the parent dataclass type for proper lazy resolution detection 

229 nested_manager._parent_dataclass_type = param_type 

230 # Also store the lazy dataclass instance we created for this nested field 

231 nested_manager._lazy_dataclass_instance = nested_dataclass_for_form 

232 

233 # Connect nested parameter changes 

234 nested_manager.parameter_changed.connect( 

235 lambda name, value, parent_name=param_name: self._handle_nested_parameter_change(parent_name, name, value) 

236 ) 

237 

238 self.nested_managers[param_name] = nested_manager 

239 

240 layout.addWidget(nested_manager) 

241 

242 return group_box 

243 

244 def _get_field_path_for_nested_type(self, nested_type: Type) -> Optional[str]: 

245 """ 

246 Automatically determine the field path for a nested dataclass type using type inspection. 

247 

248 This method examines the GlobalPipelineConfig fields and their type annotations 

249 to find which field corresponds to the given nested_type. This eliminates the need 

250 for hardcoded string mappings and automatically works with new nested dataclass fields. 

251 

252 Args: 

253 nested_type: The dataclass type to find the field path for 

254 

255 Returns: 

256 The field path string (e.g., 'path_planning', 'vfs') or None if not found 

257 """ 

258 try: 

259 from openhcs.core.config import GlobalPipelineConfig 

260 from dataclasses import fields 

261 import typing 

262 

263 # Get all fields from GlobalPipelineConfig 

264 global_config_fields = fields(GlobalPipelineConfig) 

265 

266 for field in global_config_fields: 

267 field_type = field.type 

268 

269 # Handle Optional types (Union[Type, None]) 

270 if hasattr(typing, 'get_origin') and typing.get_origin(field_type) is typing.Union: 

271 # Get the non-None type from Optional[Type] 

272 args = typing.get_args(field_type) 

273 if len(args) == 2 and type(None) in args: 

274 field_type = args[0] if args[1] is type(None) else args[1] 

275 

276 # Check if the field type matches our nested type 

277 if field_type == nested_type: 

278 return field.name 

279 

280 

281 

282 return None 

283 

284 except Exception as e: 

285 # Fallback to None if type inspection fails 

286 import logging 

287 logger = logging.getLogger(__name__) 

288 logger.debug(f"Failed to determine field path for {nested_type.__name__}: {e}") 

289 return None 

290 

291 def _should_use_concrete_nested_values(self, current_value: Any) -> bool: 

292 """ 

293 Determine if nested dataclass fields should use concrete values or None for placeholders. 

294 

295 Returns True if: 

296 1. Global config editing (always concrete) 

297 2. Regular concrete dataclass (always concrete) 

298 

299 Returns False if: 

300 1. Lazy dataclass (supports mixed lazy/concrete states per field) 

301 2. None values (show placeholders) 

302 

303 Note: This method now supports mixed states within nested dataclasses. 

304 Individual fields can be lazy (None) or concrete within the same dataclass. 

305 """ 

306 # Global config editing always uses concrete values 

307 if self.is_global_config_editing: 

308 return True 

309 

310 # If current_value is None, use placeholders 

311 if current_value is None: 

312 return False 

313 

314 # If current_value is a concrete dataclass instance, use its values 

315 if hasattr(current_value, '__dataclass_fields__') and not hasattr(current_value, '_resolve_field_value'): 

316 return True 

317 

318 # For lazy dataclasses, always return False to enable mixed lazy/concrete behavior 

319 # Individual field values will be checked separately in the nested form creation 

320 if hasattr(current_value, '_resolve_field_value'): 

321 return False 

322 

323 # Default to placeholder behavior for lazy contexts 

324 return False 

325 

326 def _should_use_concrete_for_placeholder_rendering(self, current_value: Any) -> bool: 

327 """ 

328 Determine if nested dataclass should use concrete values for PLACEHOLDER RENDERING specifically. 

329 

330 This is separate from _should_use_concrete_nested_values which is used for saving/rebuilding. 

331 For placeholder rendering, we want field-level logic in lazy contexts. 

332 """ 

333 # Global config editing always uses concrete values 

334 if self.is_global_config_editing: 

335 return True 

336 

337 # In lazy contexts, ALWAYS return False to enable field-level placeholder logic 

338 # This allows mixed states: some fields can be None (placeholders) while others have values 

339 return False 

340 

341 def _create_lazy_nested_dataclass_if_needed(self, param_name: str, param_type: type, current_value: Any) -> Any: 

342 """ 

343 Create a lazy version of any nested dataclass for consistent lazy loading behavior. 

344 

345 Returns the appropriate nested dataclass instance based on context: 

346 - Concrete contexts: return the actual nested dataclass instance 

347 - Lazy contexts: return None for placeholder behavior or preserve explicit values 

348 """ 

349 import dataclasses 

350 

351 # Only process actual dataclass types 

352 if not dataclasses.is_dataclass(param_type): 

353 return current_value 

354 

355 # Use the new robust logic to determine behavior 

356 if self._should_use_concrete_nested_values(current_value): 

357 return current_value 

358 else: 

359 return None 

360 

361 def _create_static_lazy_dataclass_for_placeholders(self, param_type: type) -> Any: 

362 """ 

363 Create a lazy dataclass that resolves from current global config for placeholder generation. 

364 

365 This is used in nested static forms to provide placeholder behavior that reflects 

366 the current global config values (not static defaults) while avoiding thread-local conflicts. 

367 """ 

368 try: 

369 from openhcs.core.lazy_config import LazyDataclassFactory 

370 from openhcs.core.config import _current_pipeline_config 

371 

372 # Check if we have a current thread-local pipeline config context 

373 if hasattr(_current_pipeline_config, 'value') and _current_pipeline_config.value: 

374 # Use the current global config instance as the defaults source 

375 # This ensures placeholders show current global config values, not static defaults 

376 current_global_config = _current_pipeline_config.value 

377 

378 # Find the specific nested dataclass instance from the global config 

379 nested_dataclass_instance = self._extract_nested_dataclass_from_global_config( 

380 current_global_config, param_type 

381 ) 

382 

383 if nested_dataclass_instance: 

384 # Create lazy version that resolves from the specific nested dataclass instance 

385 lazy_class = LazyDataclassFactory.create_lazy_dataclass( 

386 defaults_source=nested_dataclass_instance, # Use current nested instance 

387 lazy_class_name=f"GlobalContextLazy{param_type.__name__}" 

388 ) 

389 

390 # Create instance for placeholder resolution 

391 return lazy_class() 

392 else: 

393 # Fallback to static resolution if nested instance not found 

394 lazy_class = LazyDataclassFactory.create_lazy_dataclass( 

395 defaults_source=param_type, # Use class defaults as fallback 

396 lazy_class_name=f"StaticLazy{param_type.__name__}" 

397 ) 

398 

399 # Create instance for placeholder resolution 

400 return lazy_class() 

401 else: 

402 # Fallback to static resolution if no thread-local context 

403 lazy_class = LazyDataclassFactory.create_lazy_dataclass( 

404 defaults_source=param_type, # Use class defaults as fallback 

405 lazy_class_name=f"StaticLazy{param_type.__name__}" 

406 ) 

407 

408 # Create instance for placeholder resolution 

409 return lazy_class() 

410 

411 except Exception as e: 

412 # If lazy creation fails, return None 

413 import logging 

414 logger = logging.getLogger(__name__) 

415 logger.debug(f"Failed to create lazy dataclass for {param_type.__name__}: {e}") 

416 return None 

417 

418 def _extract_nested_dataclass_from_global_config(self, global_config: Any, param_type: type) -> Any: 

419 """Extract the specific nested dataclass instance from the global config.""" 

420 try: 

421 import dataclasses 

422 

423 # Get all fields from the global config 

424 if dataclasses.is_dataclass(global_config): 

425 for field in dataclasses.fields(global_config): 

426 field_value = getattr(global_config, field.name) 

427 if isinstance(field_value, param_type): 

428 return field_value 

429 

430 return None 

431 

432 except Exception as e: 

433 import logging 

434 logger = logging.getLogger(__name__) 

435 logger.debug(f"Failed to extract nested dataclass {param_type.__name__} from global config: {e}") 

436 return None 

437 

438 def _apply_placeholder_with_lazy_context(self, widget: Any, param_name: str, current_value: Any) -> None: 

439 """Apply placeholder using lazy dataclass context when available.""" 

440 from openhcs.ui.shared.parameter_form_abstraction import apply_lazy_default_placeholder 

441 

442 # If we have a lazy dataclass for placeholders (nested static forms), use it directly 

443 if hasattr(self, 'lazy_dataclass_for_placeholders') and self.lazy_dataclass_for_placeholders: 

444 self._apply_placeholder_from_lazy_dataclass(widget, param_name, current_value, self.lazy_dataclass_for_placeholders) 

445 # For nested static forms, create lazy dataclass on-demand 

446 elif self.field_id.startswith("nested_static_"): 

447 # Extract the dataclass type from the field_id and create lazy dataclass 

448 lazy_dataclass = self._create_lazy_dataclass_for_nested_static_form() 

449 if lazy_dataclass: 

450 self._apply_placeholder_from_lazy_dataclass(widget, param_name, current_value, lazy_dataclass) 

451 else: 

452 # Fallback to standard placeholder application 

453 apply_lazy_default_placeholder(widget, param_name, current_value, 

454 self.form_abstraction.parameter_types, 'pyqt6', 

455 is_global_config_editing=self.is_global_config_editing, 

456 global_config_type=self.global_config_type, 

457 placeholder_prefix=self.placeholder_prefix) 

458 else: 

459 # Use the standard placeholder application 

460 apply_lazy_default_placeholder(widget, param_name, current_value, 

461 self.form_abstraction.parameter_types, 'pyqt6', 

462 is_global_config_editing=self.is_global_config_editing, 

463 global_config_type=self.global_config_type, 

464 placeholder_prefix=self.placeholder_prefix) 

465 

466 def _apply_placeholder_from_lazy_dataclass(self, widget: Any, param_name: str, current_value: Any, lazy_dataclass: Any) -> None: 

467 """Apply placeholder using a specific lazy dataclass instance.""" 

468 if current_value is not None: 

469 return 

470 

471 try: 

472 from openhcs.core.config import LazyDefaultPlaceholderService 

473 

474 # Get the lazy dataclass type 

475 lazy_dataclass_type = type(lazy_dataclass) 

476 

477 # Generate placeholder using the lazy dataclass 

478 placeholder_text = LazyDefaultPlaceholderService.get_lazy_resolved_placeholder( 

479 lazy_dataclass_type, param_name 

480 ) 

481 

482 if placeholder_text: 

483 from openhcs.ui.shared.pyqt6_widget_strategies import PyQt6WidgetEnhancer 

484 PyQt6WidgetEnhancer.apply_placeholder_text(widget, placeholder_text) 

485 

486 except Exception: 

487 pass 

488 

489 def _create_lazy_dataclass_for_nested_static_form(self) -> Any: 

490 """Create lazy dataclass for nested static form based on parameter types.""" 

491 try: 

492 # For nested static forms, we need to determine the dataclass type from the parameter types 

493 # The parameter types should all belong to the same dataclass 

494 import dataclasses 

495 from openhcs.core import config 

496 

497 # Get all parameter names 

498 param_names = set(self.form_abstraction.parameter_types.keys()) 

499 

500 # Find the dataclass that matches these parameter names 

501 for name, obj in vars(config).items(): 

502 if (dataclasses.is_dataclass(obj) and 

503 hasattr(obj, '__dataclass_fields__')): 

504 dataclass_fields = {field.name for field in dataclasses.fields(obj)} 

505 if param_names == dataclass_fields: 

506 # Found the matching dataclass, create lazy version 

507 return self._create_static_lazy_dataclass_for_placeholders(obj) 

508 

509 return None 

510 

511 except Exception as e: 

512 import logging 

513 logger = logging.getLogger(__name__) 

514 logger.debug(f"Failed to create lazy dataclass for nested static form: {e}") 

515 return None 

516 

517 def _is_optional_dataclass(self, param_type: type) -> bool: 

518 """Check if parameter type is Optional[dataclass].""" 

519 if get_origin(param_type) is Union: 

520 args = get_args(param_type) 

521 if len(args) == 2 and type(None) in args: 

522 inner_type = next(arg for arg in args if arg is not type(None)) 

523 return dataclasses.is_dataclass(inner_type) 

524 return False 

525 

526 def _get_optional_inner_type(self, param_type: type) -> type: 

527 """Extract the inner type from Optional[T].""" 

528 if get_origin(param_type) is Union: 

529 args = get_args(param_type) 

530 if len(args) == 2 and type(None) in args: 

531 return next(arg for arg in args if arg is not type(None)) 

532 return param_type 

533 

534 def _create_optional_dataclass_field(self, param_name: str, dataclass_type: type, current_value: Any) -> QWidget: 

535 """Create a checkbox + dataclass widget for Optional[dataclass] parameters.""" 

536 from PyQt6.QtWidgets import QWidget, QVBoxLayout, QCheckBox 

537 

538 container = QWidget() 

539 layout = QVBoxLayout(container) 

540 layout.setContentsMargins(0, 0, 0, 0) 

541 layout.setSpacing(5) 

542 

543 # Checkbox and dataclass widget 

544 checkbox = QCheckBox(f"Enable {param_name.replace('_', ' ').title()}") 

545 checkbox.setChecked(current_value is not None) 

546 dataclass_widget = self._create_nested_dataclass_field(param_name, dataclass_type, current_value) 

547 dataclass_widget.setEnabled(current_value is not None) 

548 

549 # Toggle logic 

550 def toggle_dataclass(checked: bool): 

551 dataclass_widget.setEnabled(checked) 

552 value = (dataclass_type() if checked and current_value is None 

553 else self.nested_managers[param_name].get_current_values() 

554 and dataclass_type(**self.nested_managers[param_name].get_current_values()) 

555 if checked and param_name in self.nested_managers else None) 

556 self.textual_form_manager.update_parameter(param_name, value) 

557 self.parameter_changed.emit(param_name, value) 

558 

559 checkbox.stateChanged.connect(toggle_dataclass) 

560 

561 layout.addWidget(checkbox) 

562 layout.addWidget(dataclass_widget) 

563 

564 # Store reference 

565 if not hasattr(self, 'optional_checkboxes'): 

566 self.optional_checkboxes = {} 

567 self.optional_checkboxes[param_name] = checkbox 

568 

569 return container 

570 

571 def _create_regular_parameter_field(self, param_name: str, param_type: type, current_value: Any) -> QWidget: 

572 """Create a field for regular (non-dataclass) parameter.""" 

573 container = QFrame() 

574 layout = QHBoxLayout(container) 

575 layout.setContentsMargins(5, 2, 5, 2) 

576 

577 # Parameter label with help (reuses Textual TUI parameter info) 

578 param_info = self.textual_form_manager.parameter_info.get(param_name) if hasattr(self.textual_form_manager, 'parameter_info') else None 

579 param_description = param_info.description if param_info else f"Parameter: {param_name}" 

580 

581 label_with_help = LabelWithHelp( 

582 text=f"{param_name.replace('_', ' ').title()}:", 

583 param_name=param_name, 

584 param_description=param_description, 

585 param_type=param_type, 

586 color_scheme=self.color_scheme 

587 ) 

588 label_with_help.setMinimumWidth(150) 

589 layout.addWidget(label_with_help) 

590 

591 # Create widget using registry and apply placeholder 

592 widget = self.form_abstraction.create_widget_for_parameter(param_name, param_type, current_value) 

593 if widget: 

594 self._apply_placeholder_with_lazy_context(widget, param_name, current_value) 

595 PyQt6WidgetEnhancer.connect_change_signal(widget, param_name, self._emit_parameter_change) 

596 

597 self.widgets[param_name] = widget 

598 layout.addWidget(widget) 

599 

600 # Add reset button 

601 reset_btn = QPushButton("Reset") 

602 reset_btn.setMaximumWidth(60) 

603 reset_btn.clicked.connect(lambda: self._reset_parameter(param_name)) 

604 layout.addWidget(reset_btn) 

605 

606 return container 

607 

608 # _create_typed_widget method removed - functionality moved inline 

609 

610 

611 

612 def _emit_parameter_change(self, param_name: str, value: Any): 

613 """Emit parameter change signal.""" 

614 # For nested fields, also update the nested manager to keep it in sync 

615 parent_nested_name = self._find_parent_nested_manager(param_name) 

616 

617 # Debug: Check why nested manager isn't being found 

618 if param_name == 'output_dir_suffix': 

619 logger.info(f"*** NESTED DEBUG *** param_name={param_name}, parent_nested_name={parent_nested_name}") 

620 if hasattr(self, 'nested_managers'): 

621 logger.info(f"*** NESTED DEBUG *** Available nested managers: {list(self.nested_managers.keys())}") 

622 for name, manager in self.nested_managers.items(): 

623 param_types = manager.textual_form_manager.parameter_types.keys() 

624 logger.info(f"*** NESTED DEBUG *** {name} contains: {list(param_types)}") 

625 else: 

626 logger.info(f"*** NESTED DEBUG *** No nested_managers attribute") 

627 

628 if parent_nested_name and hasattr(self, 'nested_managers'): 

629 logger.info(f"*** NESTED UPDATE *** Updating nested manager {parent_nested_name}.{param_name} = {value}") 

630 nested_manager = self.nested_managers[parent_nested_name] 

631 nested_manager.textual_form_manager.update_parameter(param_name, value) 

632 

633 # Update the Textual TUI form manager (which holds the actual parameters) 

634 self.textual_form_manager.update_parameter(param_name, value) 

635 self.parameter_changed.emit(param_name, value) 

636 

637 def _handle_nested_parameter_change(self, parent_name: str, nested_name: str, value: Any): 

638 """Handle parameter change in nested dataclass.""" 

639 if parent_name in self.nested_managers: 

640 # Update nested manager's parameters 

641 nested_manager = self.nested_managers[parent_name] 

642 nested_manager.textual_form_manager.update_parameter(nested_name, value) 

643 

644 # Rebuild nested dataclass instance 

645 nested_type = self.textual_form_manager.parameter_types[parent_name] 

646 

647 # Resolve Union types (like Optional[DataClass]) to the actual dataclass type 

648 if self._is_optional_dataclass(nested_type): 

649 nested_type = self._get_optional_inner_type(nested_type) 

650 

651 # Get current values from nested manager 

652 nested_values = nested_manager.get_current_values() 

653 

654 # Get the original nested dataclass instance to preserve unchanged values 

655 original_instance = self.textual_form_manager.parameters.get(parent_name) 

656 

657 # Create new instance using nested_values as-is (respecting explicit None values) 

658 # Don't preserve original values for None fields - None means user explicitly cleared the field 

659 new_instance = nested_type(**nested_values) 

660 

661 # Update parent parameter in textual form manager 

662 self.textual_form_manager.update_parameter(parent_name, new_instance) 

663 

664 # Emit change for parent parameter 

665 self.parameter_changed.emit(parent_name, new_instance) 

666 

667 def _reset_parameter(self, param_name: str): 

668 """Reset parameter to appropriate default value based on lazy vs concrete dataclass context.""" 

669 if not (hasattr(self.textual_form_manager, 'parameter_info') and param_name in self.textual_form_manager.parameter_info): 

670 return 

671 

672 # For nested fields, reset the parent nested manager first to prevent old values 

673 parent_nested_name = self._find_parent_nested_manager(param_name) 

674 logger.info(f"*** RESET DEBUG *** param_name={param_name}, parent_nested_name={parent_nested_name}") 

675 if parent_nested_name and hasattr(self, 'nested_managers'): 

676 logger.info(f"*** RESET FIX *** Resetting parent nested manager {parent_nested_name} for field {param_name}") 

677 nested_manager = self.nested_managers[parent_nested_name] 

678 nested_manager.reset_all_parameters() 

679 else: 

680 logger.info(f"*** RESET DEBUG *** No parent nested manager found or no nested_managers attribute") 

681 

682 # Determine the correct reset value based on context 

683 reset_value = self._get_reset_value_for_parameter(param_name) 

684 

685 # Update textual form manager 

686 self.textual_form_manager.update_parameter(param_name, reset_value) 

687 

688 # Update widget with context-aware behavior 

689 if param_name in self.widgets: 

690 widget = self.widgets[param_name] 

691 self._update_widget_value_with_context(widget, reset_value, param_name) 

692 

693 self.parameter_changed.emit(param_name, reset_value) 

694 

695 def _find_parent_nested_manager(self, param_name: str) -> str: 

696 """Find which nested manager contains the given parameter.""" 

697 if hasattr(self, 'nested_managers'): 

698 for nested_name, nested_manager in self.nested_managers.items(): 

699 if param_name in nested_manager.textual_form_manager.parameter_types: 

700 return nested_name 

701 return None 

702 

703 def reset_all_parameters(self): 

704 """Reset all parameters using individual field reset logic for consistency.""" 

705 # Reset each parameter individually using the same logic as individual reset buttons 

706 # This ensures consistent behavior between individual resets and reset all 

707 for param_name in self.textual_form_manager.parameter_types.keys(): 

708 self._reset_parameter(param_name) 

709 

710 # Also reset all nested form parameters 

711 if hasattr(self, 'nested_managers'): 

712 for nested_name, nested_manager in self.nested_managers.items(): 

713 nested_manager.reset_all_parameters() 

714 

715 def reset_parameter_by_path(self, parameter_path: str): 

716 """Reset a parameter by its full path (supports nested parameters). 

717 

718 Args: 

719 parameter_path: Either a simple parameter name (e.g., 'num_workers') 

720 or a nested path (e.g., 'path_planning.output_dir_suffix') 

721 """ 

722 if '.' in parameter_path: 

723 # Handle nested parameter 

724 parts = parameter_path.split('.', 1) 

725 nested_name = parts[0] 

726 nested_param = parts[1] 

727 

728 if hasattr(self, 'nested_managers') and nested_name in self.nested_managers: 

729 nested_manager = self.nested_managers[nested_name] 

730 if '.' in nested_param: 

731 # Further nesting 

732 nested_manager.reset_parameter_by_path(nested_param) 

733 else: 

734 # Direct nested parameter 

735 nested_manager._reset_parameter(nested_param) 

736 

737 # Rebuild the parent dataclass instance with the updated nested values 

738 self._rebuild_nested_dataclass_from_manager(nested_name) 

739 else: 

740 logger.warning(f"Nested manager '{nested_name}' not found for parameter path '{parameter_path}'") 

741 else: 

742 # Handle top-level parameter 

743 self._reset_parameter(parameter_path) 

744 

745 def _get_reset_value_for_parameter(self, param_name: str) -> Any: 

746 """ 

747 Get the appropriate reset value for a parameter based on lazy vs concrete dataclass context. 

748 

749 For concrete dataclasses (like GlobalPipelineConfig): 

750 - Reset to static class defaults 

751 

752 For lazy dataclasses (like PipelineConfig for orchestrator configs): 

753 - Reset to None to preserve placeholder behavior and inheritance hierarchy 

754 """ 

755 param_info = self.textual_form_manager.parameter_info[param_name] 

756 param_type = param_info.param_type 

757 

758 # For global config editing, always use static defaults 

759 if self.is_global_config_editing: 

760 return param_info.default_value 

761 

762 # For nested dataclass fields, check if we should use concrete values 

763 if hasattr(param_type, '__dataclass_fields__'): 

764 # This is a dataclass field - determine if it should be concrete or None 

765 current_value = self.textual_form_manager.parameters.get(param_name) 

766 if self._should_use_concrete_nested_values(current_value): 

767 # Use static default for concrete nested dataclass 

768 return param_info.default_value 

769 else: 

770 # Use None for lazy nested dataclass to preserve placeholder behavior 

771 return None 

772 

773 # For non-dataclass fields in lazy context, use None to preserve placeholder behavior 

774 # This allows the field to inherit from the parent config hierarchy 

775 if not self.is_global_config_editing: 

776 return None 

777 

778 # Fallback to static default 

779 return param_info.default_value 

780 

781 def _update_widget_value_with_context(self, widget: QWidget, value: Any, param_name: str): 

782 """Update widget value with context-aware placeholder handling.""" 

783 # For static contexts (global config editing), set actual values and clear placeholder styling 

784 if self.is_global_config_editing or value is not None: 

785 # Clear any existing placeholder state 

786 self._clear_placeholder_state(widget) 

787 # Set the actual value 

788 self._update_widget_value_direct(widget, value) 

789 else: 

790 # For lazy contexts with None values, apply placeholder styling directly 

791 # Don't call _update_widget_value_direct with None as it breaks combobox selection 

792 # and doesn't properly handle placeholder text for string fields 

793 self._reapply_placeholder_if_needed(widget, param_name) 

794 

795 def _clear_placeholder_state(self, widget: QWidget): 

796 """Clear placeholder state from a widget.""" 

797 if widget.property("is_placeholder_state"): 

798 widget.setStyleSheet("") 

799 widget.setProperty("is_placeholder_state", False) 

800 # Clean tooltip 

801 current_tooltip = widget.toolTip() 

802 if "Pipeline default:" in current_tooltip: 

803 widget.setToolTip("") 

804 

805 def _update_widget_value_direct(self, widget: QWidget, value: Any): 

806 """Update widget value without triggering signals or applying placeholder styling.""" 

807 # Handle EnhancedPathWidget FIRST (duck typing) 

808 if hasattr(widget, 'set_path'): 

809 widget.set_path(value) 

810 return 

811 

812 if isinstance(widget, QCheckBox): 

813 widget.blockSignals(True) 

814 widget.setChecked(bool(value) if value is not None else False) 

815 widget.blockSignals(False) 

816 elif isinstance(widget, (QSpinBox, QDoubleSpinBox)): 

817 widget.blockSignals(True) 

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

819 widget.blockSignals(False) 

820 elif isinstance(widget, NoneAwareLineEdit): 

821 widget.blockSignals(True) 

822 widget.set_value(value) 

823 widget.blockSignals(False) 

824 elif isinstance(widget, QLineEdit): 

825 widget.blockSignals(True) 

826 # Handle literal "None" string - should display as empty 

827 if isinstance(value, str) and value == "None": 

828 widget.setText("") 

829 else: 

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

831 widget.blockSignals(False) 

832 elif isinstance(widget, QComboBox): 

833 widget.blockSignals(True) 

834 index = widget.findData(value) 

835 if index >= 0: 

836 widget.setCurrentIndex(index) 

837 widget.blockSignals(False) 

838 

839 def _update_widget_value(self, widget: QWidget, value: Any): 

840 """Update widget value without triggering signals (legacy method for compatibility).""" 

841 self._update_widget_value_direct(widget, value) 

842 

843 def _reapply_placeholder_if_needed(self, widget: QWidget, param_name: str = None): 

844 """Re-apply placeholder styling to a widget when its value is set to None.""" 

845 # If param_name not provided, find it by searching widgets 

846 if param_name is None: 

847 for name, w in self.widgets.items(): 

848 if w is widget: 

849 param_name = name 

850 break 

851 

852 if param_name is None: 

853 return 

854 

855 # Re-apply placeholder using the same logic as initial widget creation 

856 self._apply_placeholder_with_lazy_context(widget, param_name, None) 

857 

858 def update_parameter(self, param_name: str, value: Any): 

859 """Update parameter value programmatically with recursive nested parameter support.""" 

860 # Handle nested parameters with dot notation (e.g., 'path_planning.output_dir_suffix') 

861 if '.' in param_name: 

862 parts = param_name.split('.', 1) 

863 parent_name = parts[0] 

864 remaining_path = parts[1] 

865 

866 # Update nested manager if it exists 

867 if hasattr(self, 'nested_managers') and parent_name in self.nested_managers: 

868 nested_manager = self.nested_managers[parent_name] 

869 

870 # Recursively handle the remaining path (supports unlimited nesting levels) 

871 nested_manager.update_parameter(remaining_path, value) 

872 

873 # Now rebuild the parent dataclass from the nested manager's current values 

874 self._rebuild_nested_dataclass_from_manager(parent_name) 

875 return 

876 

877 # Handle regular parameters 

878 self.textual_form_manager.update_parameter(param_name, value) 

879 if param_name in self.widgets: 

880 self._update_widget_value(self.widgets[param_name], value) 

881 

882 def get_current_values(self) -> Dict[str, Any]: 

883 """Get current parameter values (mirrors Textual TUI).""" 

884 return self.textual_form_manager.parameters.copy() 

885 

886 def _rebuild_nested_dataclass_from_manager(self, parent_name: str): 

887 """Rebuild the nested dataclass instance from the nested manager's current values.""" 

888 if not (hasattr(self, 'nested_managers') and parent_name in self.nested_managers): 

889 return 

890 

891 nested_manager = self.nested_managers[parent_name] 

892 nested_values = nested_manager.get_current_values() 

893 nested_type = self.textual_form_manager.parameter_types[parent_name] 

894 

895 # Resolve Union types (like Optional[DataClass]) to the actual dataclass type 

896 if self._is_optional_dataclass(nested_type): 

897 nested_type = self._get_optional_inner_type(nested_type) 

898 

899 # Get the original nested dataclass instance to preserve unchanged values 

900 original_instance = self.textual_form_manager.parameters.get(parent_name) 

901 

902 # SIMPLIFIED APPROACH: In lazy contexts, don't create concrete dataclasses for mixed states 

903 # This preserves the nested manager's None values for placeholder behavior 

904 

905 if self.is_global_config_editing: 

906 # Global config editing: always create concrete dataclass with all values 

907 merged_values = {} 

908 for field_name, field_value in nested_values.items(): 

909 if field_value is not None: 

910 merged_values[field_name] = field_value 

911 else: 

912 # Use default value for None fields in global config editing 

913 from dataclasses import fields 

914 for field in fields(nested_type): 

915 if field.name == field_name: 

916 merged_values[field_name] = field.default if field.default != field.default_factory else field.default_factory() 

917 break 

918 new_instance = nested_type(**merged_values) 

919 else: 

920 # Lazy context: always create lazy dataclass instance with mixed concrete/lazy fields 

921 # Even if all values are None (especially after reset), we want lazy resolution 

922 from openhcs.core.lazy_config import LazyDataclassFactory 

923 

924 # Determine the correct field path using type inspection 

925 field_path = self._get_field_path_for_nested_type(nested_type) 

926 

927 lazy_nested_type = LazyDataclassFactory.make_lazy_thread_local( 

928 base_class=nested_type, 

929 field_path=field_path, # Use correct field path for nested resolution 

930 lazy_class_name=f"Mixed{nested_type.__name__}" 

931 ) 

932 

933 # Create instance with mixed concrete/lazy field values 

934 # Pass ALL fields to constructor: concrete values for edited fields, None for lazy fields 

935 # The lazy __getattribute__ will resolve None values via _resolve_field_value 

936 new_instance = lazy_nested_type(**nested_values) 

937 

938 # Update parent parameter in textual form manager 

939 self.textual_form_manager.update_parameter(parent_name, new_instance) 

940 

941 # Emit change for parent parameter 

942 self.parameter_changed.emit(parent_name, new_instance) 

943 

944 # Old placeholder methods removed - now using centralized abstraction layer