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
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 05:57 +0000
1"""
2Parameter form manager for PyQt6 GUI.
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"""
8import dataclasses
9import logging
10from typing import Any, Dict, get_origin, get_args, Union, Optional, Type
11from pathlib import Path
12from enum import Enum
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
22from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme
25class NoneAwareLineEdit(QLineEdit):
26 """QLineEdit that properly handles None values for lazy dataclass contexts."""
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
33 def set_value(self, value):
34 """Set value, handling None properly."""
35 self.setText("" if value is None else str(value))
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
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
47# Import PyQt6 help components (using same pattern as Textual TUI)
48from openhcs.pyqt_gui.widgets.shared.clickable_help_components import LabelWithHelp, GroupBoxWithHelp
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
57logger = logging.getLogger(__name__)
60class ParameterFormManager(QWidget):
61 """
62 PyQt6 adapter for Textual TUI ParameterFormManager.
64 REUSES the actual working Textual TUI parameter form logic by creating
65 a PyQt6 UI that mirrors the Textual TUI behavior exactly.
66 """
68 parameter_changed = pyqtSignal(str, object) # param_name, value
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)
77 # Initialize color scheme
78 self.color_scheme = color_scheme or PyQt6ColorScheme()
80 # Store function target for docstring fallback
81 self._function_target = function_target
83 # Initialize simplified abstraction layer
84 self.form_abstraction = ParameterFormAbstraction(
85 parameters, parameter_types, field_id, create_pyqt6_registry(), parameter_info
86 )
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 )
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
99 # Control whether to use scroll area (disable for nested dataclasses)
100 self.use_scroll_area = use_scroll_area
102 # Track PyQt6 widgets for value updates
103 self.widgets = {}
104 self.nested_managers = {}
106 # Optional lazy dataclass for placeholder generation in nested static forms
107 self.lazy_dataclass_for_placeholders = None
109 self.setup_ui()
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)
116 # Content widget
117 content_widget = QWidget()
118 content_layout = QVBoxLayout(content_widget)
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]
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)
134 if field_widget:
135 content_layout.addWidget(field_widget)
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)
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 )
158 # Use the content layout from GroupBoxWithHelp
159 layout = group_box.content_layout
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)
164 # Analyze nested dataclass
165 nested_param_info = SignatureAnalyzer.analyze(param_type)
167 # Get current values from nested dataclass instance
168 nested_parameters = {}
169 nested_parameter_types = {}
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)
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
200 nested_parameters[nested_name] = nested_current_value
201 nested_parameter_types[nested_name] = nested_info.param_type
203 # Create nested form manager without scroll area (dataclasses should show in full)
204 nested_field_id = f"{self.field_id}_{param_name}"
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}"
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 )
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
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
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 )
238 self.nested_managers[param_name] = nested_manager
240 layout.addWidget(nested_manager)
242 return group_box
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.
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.
252 Args:
253 nested_type: The dataclass type to find the field path for
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
263 # Get all fields from GlobalPipelineConfig
264 global_config_fields = fields(GlobalPipelineConfig)
266 for field in global_config_fields:
267 field_type = field.type
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]
276 # Check if the field type matches our nested type
277 if field_type == nested_type:
278 return field.name
282 return None
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
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.
295 Returns True if:
296 1. Global config editing (always concrete)
297 2. Regular concrete dataclass (always concrete)
299 Returns False if:
300 1. Lazy dataclass (supports mixed lazy/concrete states per field)
301 2. None values (show placeholders)
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
310 # If current_value is None, use placeholders
311 if current_value is None:
312 return False
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
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
323 # Default to placeholder behavior for lazy contexts
324 return False
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.
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
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
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.
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
351 # Only process actual dataclass types
352 if not dataclasses.is_dataclass(param_type):
353 return current_value
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
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.
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
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
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 )
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 )
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 )
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 )
408 # Create instance for placeholder resolution
409 return lazy_class()
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
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
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
430 return None
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
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
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)
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
471 try:
472 from openhcs.core.config import LazyDefaultPlaceholderService
474 # Get the lazy dataclass type
475 lazy_dataclass_type = type(lazy_dataclass)
477 # Generate placeholder using the lazy dataclass
478 placeholder_text = LazyDefaultPlaceholderService.get_lazy_resolved_placeholder(
479 lazy_dataclass_type, param_name
480 )
482 if placeholder_text:
483 from openhcs.ui.shared.pyqt6_widget_strategies import PyQt6WidgetEnhancer
484 PyQt6WidgetEnhancer.apply_placeholder_text(widget, placeholder_text)
486 except Exception:
487 pass
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
497 # Get all parameter names
498 param_names = set(self.form_abstraction.parameter_types.keys())
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)
509 return None
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
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
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
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
538 container = QWidget()
539 layout = QVBoxLayout(container)
540 layout.setContentsMargins(0, 0, 0, 0)
541 layout.setSpacing(5)
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)
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)
559 checkbox.stateChanged.connect(toggle_dataclass)
561 layout.addWidget(checkbox)
562 layout.addWidget(dataclass_widget)
564 # Store reference
565 if not hasattr(self, 'optional_checkboxes'):
566 self.optional_checkboxes = {}
567 self.optional_checkboxes[param_name] = checkbox
569 return container
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)
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}"
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)
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)
597 self.widgets[param_name] = widget
598 layout.addWidget(widget)
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)
606 return container
608 # _create_typed_widget method removed - functionality moved inline
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)
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")
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)
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)
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)
644 # Rebuild nested dataclass instance
645 nested_type = self.textual_form_manager.parameter_types[parent_name]
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)
651 # Get current values from nested manager
652 nested_values = nested_manager.get_current_values()
654 # Get the original nested dataclass instance to preserve unchanged values
655 original_instance = self.textual_form_manager.parameters.get(parent_name)
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)
661 # Update parent parameter in textual form manager
662 self.textual_form_manager.update_parameter(parent_name, new_instance)
664 # Emit change for parent parameter
665 self.parameter_changed.emit(parent_name, new_instance)
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
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")
682 # Determine the correct reset value based on context
683 reset_value = self._get_reset_value_for_parameter(param_name)
685 # Update textual form manager
686 self.textual_form_manager.update_parameter(param_name, reset_value)
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)
693 self.parameter_changed.emit(param_name, reset_value)
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
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)
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()
715 def reset_parameter_by_path(self, parameter_path: str):
716 """Reset a parameter by its full path (supports nested parameters).
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]
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)
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)
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.
749 For concrete dataclasses (like GlobalPipelineConfig):
750 - Reset to static class defaults
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
758 # For global config editing, always use static defaults
759 if self.is_global_config_editing:
760 return param_info.default_value
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
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
778 # Fallback to static default
779 return param_info.default_value
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)
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("")
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
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)
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)
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
852 if param_name is None:
853 return
855 # Re-apply placeholder using the same logic as initial widget creation
856 self._apply_placeholder_with_lazy_context(widget, param_name, None)
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]
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]
870 # Recursively handle the remaining path (supports unlimited nesting levels)
871 nested_manager.update_parameter(remaining_path, value)
873 # Now rebuild the parent dataclass from the nested manager's current values
874 self._rebuild_nested_dataclass_from_manager(parent_name)
875 return
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)
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()
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
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]
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)
899 # Get the original nested dataclass instance to preserve unchanged values
900 original_instance = self.textual_form_manager.parameters.get(parent_name)
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
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
924 # Determine the correct field path using type inspection
925 field_path = self._get_field_path_for_nested_type(nested_type)
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 )
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)
938 # Update parent parameter in textual form manager
939 self.textual_form_manager.update_parameter(parent_name, new_instance)
941 # Emit change for parent parameter
942 self.parameter_changed.emit(parent_name, new_instance)
944 # Old placeholder methods removed - now using centralized abstraction layer