Coverage for openhcs/pyqt_gui/windows/config_window.py: 0.0%
392 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-04 02:09 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-04 02:09 +0000
1"""
2Configuration Window for PyQt6
4Configuration editing dialog with full feature parity to Textual TUI version.
5Uses hybrid approach: extracted business logic + clean PyQt6 UI.
6"""
8import logging
9import dataclasses
10from typing import Type, Any, Callable, Optional
12from PyQt6.QtWidgets import (
13 QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
14 QScrollArea, QWidget, QSplitter, QTreeWidget, QTreeWidgetItem,
15 QLineEdit, QSpinBox, QDoubleSpinBox, QCheckBox, QComboBox
16)
17from PyQt6.QtCore import Qt, pyqtSignal
18from PyQt6.QtGui import QFont, QColor
20# Infrastructure classes removed - functionality migrated to ParameterFormManager service layer
21from openhcs.pyqt_gui.widgets.shared.parameter_form_manager import ParameterFormManager
22from openhcs.pyqt_gui.shared.style_generator import StyleSheetGenerator
23from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme
24from openhcs.pyqt_gui.windows.base_form_dialog import BaseFormDialog
25from openhcs.core.config import GlobalPipelineConfig
26# ❌ REMOVED: require_config_context decorator - enhanced decorator events system handles context automatically
27from openhcs.core.lazy_placeholder_simplified import LazyDefaultPlaceholderService
31logger = logging.getLogger(__name__)
34# Infrastructure classes removed - functionality migrated to ParameterFormManager service layer
37class ConfigWindow(BaseFormDialog):
38 """
39 PyQt6 Configuration Window.
41 Configuration editing dialog with parameter forms and validation.
42 Preserves all business logic from Textual version with clean PyQt6 UI.
44 Inherits from BaseFormDialog to automatically handle unregistration from
45 cross-window placeholder updates when the dialog closes.
46 """
48 # Signals
49 config_saved = pyqtSignal(object) # saved config
50 config_cancelled = pyqtSignal()
52 def __init__(self, config_class: Type, current_config: Any,
53 on_save_callback: Optional[Callable] = None,
54 color_scheme: Optional[PyQt6ColorScheme] = None, parent=None,
55 scope_id: Optional[str] = None):
56 """
57 Initialize the configuration window.
59 Args:
60 config_class: Configuration class type
61 current_config: Current configuration instance
62 on_save_callback: Function to call when config is saved
63 color_scheme: Color scheme for styling (optional, uses default if None)
64 parent: Parent widget
65 scope_id: Optional scope identifier (e.g., plate_path) to limit cross-window updates to same orchestrator
66 """
67 super().__init__(parent)
69 # Business logic state (extracted from Textual version)
70 self.config_class = config_class
71 self.current_config = current_config
72 self.on_save_callback = on_save_callback
73 self.scope_id = scope_id # Store scope_id for passing to form_manager
75 # Flag to prevent refresh during save operation
76 self._saving = False
78 # Initialize color scheme and style generator
79 self.color_scheme = color_scheme or PyQt6ColorScheme()
80 self.style_generator = StyleSheetGenerator(self.color_scheme)
82 # SIMPLIFIED: Use dual-axis resolution
83 from openhcs.core.lazy_placeholder import LazyDefaultPlaceholderService
85 # Determine placeholder prefix based on actual instance type (not class type)
86 is_lazy_dataclass = LazyDefaultPlaceholderService.has_lazy_resolution(type(current_config))
87 placeholder_prefix = "Pipeline default" if is_lazy_dataclass else "Default"
89 # SIMPLIFIED: Use ParameterFormManager with dual-axis resolution
90 root_field_id = type(current_config).__name__ # e.g., "GlobalPipelineConfig" or "PipelineConfig"
91 global_config_type = GlobalPipelineConfig # Always use GlobalPipelineConfig for dual-axis resolution
93 # CRITICAL FIX: Pipeline Config Editor should NOT use itself as parent context
94 # context_obj=None means inherit from thread-local GlobalPipelineConfig only
95 # The overlay (current form state) will be built by ParameterFormManager
96 # This fixes the circular context bug where reset showed old values instead of global defaults
98 # CRITICAL: Config window manages its own scroll area, so tell form_manager NOT to create one
99 # This prevents double scroll areas which cause navigation bugs
100 self.form_manager = ParameterFormManager.from_dataclass_instance(
101 dataclass_instance=current_config,
102 field_id=root_field_id,
103 placeholder_prefix=placeholder_prefix,
104 color_scheme=self.color_scheme,
105 use_scroll_area=False, # Config window handles scrolling
106 global_config_type=global_config_type,
107 context_obj=None, # Inherit from thread-local GlobalPipelineConfig only
108 scope_id=self.scope_id # Pass scope_id to limit cross-window updates to same orchestrator
109 )
111 # No config_editor needed - everything goes through form_manager
112 self.config_editor = None
114 # Setup UI
115 self.setup_ui()
117 logger.debug(f"Config window initialized for {config_class.__name__}")
119 def setup_ui(self):
120 """Setup the user interface."""
121 self.setWindowTitle(f"Configuration - {self.config_class.__name__}")
122 self.setModal(False) # Non-modal like plate manager and pipeline editor
123 self.setMinimumSize(600, 400)
124 self.resize(800, 600)
126 layout = QVBoxLayout(self)
127 layout.setSpacing(10)
129 # Header with title, help button, and action buttons
130 header_widget = QWidget()
131 header_layout = QHBoxLayout(header_widget)
132 header_layout.setContentsMargins(10, 10, 10, 10)
134 header_label = QLabel(f"Configure {self.config_class.__name__}")
135 header_label.setFont(QFont("Arial", 14, QFont.Weight.Bold))
136 header_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_accent)};")
137 header_layout.addWidget(header_label)
139 # Add help button for the dataclass itself
140 if dataclasses.is_dataclass(self.config_class):
141 from openhcs.pyqt_gui.widgets.shared.clickable_help_components import HelpButton
142 help_btn = HelpButton(help_target=self.config_class, text="Help", color_scheme=self.color_scheme)
143 help_btn.setMaximumWidth(80)
144 header_layout.addWidget(help_btn)
146 header_layout.addStretch()
148 # Add action buttons to header (top right)
149 button_styles = self.style_generator.generate_config_button_styles()
151 # View Code button
152 view_code_button = QPushButton("View Code")
153 view_code_button.setFixedHeight(28)
154 view_code_button.setMinimumWidth(80)
155 view_code_button.clicked.connect(self._view_code)
156 view_code_button.setStyleSheet(button_styles["reset"])
157 header_layout.addWidget(view_code_button)
159 # Reset button
160 reset_button = QPushButton("Reset to Defaults")
161 reset_button.setFixedHeight(28)
162 reset_button.setMinimumWidth(100)
163 reset_button.clicked.connect(self.reset_to_defaults)
164 reset_button.setStyleSheet(button_styles["reset"])
165 header_layout.addWidget(reset_button)
167 # Cancel button
168 cancel_button = QPushButton("Cancel")
169 cancel_button.setFixedHeight(28)
170 cancel_button.setMinimumWidth(70)
171 cancel_button.clicked.connect(self.reject)
172 cancel_button.setStyleSheet(button_styles["cancel"])
173 header_layout.addWidget(cancel_button)
175 # Save button
176 save_button = QPushButton("Save")
177 save_button.setFixedHeight(28)
178 save_button.setMinimumWidth(70)
179 save_button.clicked.connect(self.save_config)
180 save_button.setStyleSheet(button_styles["save"])
181 header_layout.addWidget(save_button)
183 layout.addWidget(header_widget)
185 # Create splitter with tree view on left and form on right
186 splitter = QSplitter(Qt.Orientation.Horizontal)
188 # Left panel - Inheritance hierarchy tree
189 self.tree_widget = self._create_inheritance_tree()
190 splitter.addWidget(self.tree_widget)
192 # Right panel - Parameter form with scroll area
193 # Always use scroll area for consistent navigation behavior
194 self.scroll_area = QScrollArea()
195 self.scroll_area.setWidgetResizable(True)
196 self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
197 self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
198 self.scroll_area.setWidget(self.form_manager)
199 splitter.addWidget(self.scroll_area)
201 # Set splitter proportions (30% tree, 70% form)
202 splitter.setSizes([300, 700])
204 # Add splitter with stretch factor so it expands to fill available space
205 layout.addWidget(splitter, 1) # stretch factor = 1
207 # Apply centralized styling (config window style includes tree styling now)
208 self.setStyleSheet(
209 self.style_generator.generate_config_window_style() + "\n" +
210 self.style_generator.generate_tree_widget_style()
211 )
213 def _create_inheritance_tree(self) -> QTreeWidget:
214 """Create tree widget showing inheritance hierarchy for navigation."""
215 tree = QTreeWidget()
216 tree.setHeaderLabel("Configuration Hierarchy")
217 # Remove width restrictions to allow horizontal dragging
218 tree.setMinimumWidth(200)
220 # Disable expand on double-click (use arrow only)
221 tree.setExpandsOnDoubleClick(False)
223 # Build inheritance hierarchy
224 self._populate_inheritance_tree(tree)
226 # Connect double-click to navigation
227 tree.itemDoubleClicked.connect(self._on_tree_item_double_clicked)
229 return tree
231 def _populate_inheritance_tree(self, tree: QTreeWidget):
232 """Populate the inheritance tree with only dataclasses visible in the UI.
234 Excludes the root node (PipelineConfig/GlobalPipelineConfig) and shows
235 its attributes directly as top-level items.
236 """
237 import dataclasses
239 # Skip creating root item - add children directly to tree as top-level items
240 if dataclasses.is_dataclass(self.config_class):
241 self._add_ui_visible_dataclasses_to_tree(tree, self.config_class, is_root=True)
243 # Leave tree collapsed by default (user can expand as needed)
245 def _get_base_type(self, dataclass_type):
246 """Get base (non-lazy) type for a dataclass - type-based detection."""
247 from openhcs.core.lazy_placeholder_simplified import LazyDefaultPlaceholderService
249 # Type-based lazy detection
250 if LazyDefaultPlaceholderService.has_lazy_resolution(dataclass_type):
251 # Get first non-Lazy base class
252 for base in dataclass_type.__bases__:
253 if base.__name__ != 'object' and not LazyDefaultPlaceholderService.has_lazy_resolution(base):
254 return base
256 return dataclass_type
258 def _add_ui_visible_dataclasses_to_tree(self, parent_item, dataclass_type, is_root=False):
259 """Add only dataclasses that are visible in the UI form.
261 Args:
262 parent_item: Either a QTreeWidgetItem or QTreeWidget (for root level)
263 dataclass_type: The dataclass type to process
264 is_root: True if adding direct children of the root config class
265 """
266 import dataclasses
268 # Get all fields from this dataclass
269 fields = dataclasses.fields(dataclass_type)
271 for field in fields:
272 field_name = field.name
273 field_type = field.type
275 # Only show dataclass fields (these appear as sections in the UI)
276 if dataclasses.is_dataclass(field_type):
277 # Get the base (non-lazy) type for display and inheritance
278 base_type = self._get_base_type(field_type)
279 display_name = base_type.__name__
281 # Check if this field is hidden from UI
282 is_ui_hidden = self._is_field_ui_hidden(dataclass_type, field_name, field_type)
284 # Skip root-level ui_hidden items entirely (don't show them in tree at all)
285 # For nested items, show them but styled differently (for inheritance visibility)
286 if is_root and is_ui_hidden:
287 continue
289 # For root-level items, show only the class name (matching child style)
290 # For nested items, show "field_name (ClassName)"
291 if is_root:
292 label = display_name
293 else:
294 label = f"{field_name} ({display_name})"
296 # Create a child item for this nested dataclass
297 field_item = QTreeWidgetItem([label])
298 field_item.setData(0, Qt.ItemDataRole.UserRole, {
299 'type': 'dataclass',
300 'class': field_type, # Store original type for field lookup
301 'field_name': field_name,
302 'ui_hidden': is_ui_hidden # Store ui_hidden flag
303 })
305 # Style ui_hidden items differently (grayed out, italicized)
306 if is_ui_hidden:
307 font = field_item.font(0)
308 font.setItalic(True)
309 field_item.setFont(0, font)
310 field_item.setForeground(0, QColor(128, 128, 128))
311 field_item.setToolTip(0, "This configuration is not editable in the UI (inherited by other configs)")
313 # Add to parent (either QTreeWidget for root or QTreeWidgetItem for nested)
314 if is_root:
315 parent_item.addTopLevelItem(field_item)
316 else:
317 parent_item.addChild(field_item)
319 # Show inheritance hierarchy using the BASE type (not lazy type)
320 # This automatically skips the lazy→base transition
321 self._add_inheritance_info(field_item, base_type)
323 # Recursively add nested dataclasses using BASE type (not root anymore)
324 self._add_ui_visible_dataclasses_to_tree(field_item, base_type, is_root=False)
326 def _is_field_ui_hidden(self, dataclass_type, field_name: str, field_type) -> bool:
327 """Check if a field should be hidden from the UI.
329 Args:
330 dataclass_type: The parent dataclass containing the field
331 field_name: Name of the field
332 field_type: Type of the field
334 Returns:
335 True if the field should be hidden from UI
336 """
337 import dataclasses
339 # Check field metadata for ui_hidden flag
340 try:
341 field_obj = next(f for f in dataclasses.fields(dataclass_type) if f.name == field_name)
342 if field_obj.metadata.get('ui_hidden', False):
343 return True
344 except (StopIteration, TypeError):
345 pass
347 # Check if the field's type itself has _ui_hidden attribute
348 # IMPORTANT: Check __dict__ directly to avoid inheriting _ui_hidden from parent classes
349 # We only want to hide fields whose type DIRECTLY has _ui_hidden=True
350 base_type = self._get_base_type(field_type)
351 if hasattr(base_type, '__dict__') and '_ui_hidden' in base_type.__dict__ and base_type._ui_hidden:
352 return True
354 return False
356 def _add_inheritance_info(self, parent_item: QTreeWidgetItem, dataclass_type):
357 """Add inheritance information for a dataclass with proper hierarchy."""
358 # Get direct base classes (dataclass_type is already the base/non-lazy type)
359 direct_bases = []
360 for cls in dataclass_type.__bases__:
361 if cls.__name__ == 'object':
362 continue
363 if not hasattr(cls, '__dataclass_fields__'):
364 continue
366 # Always use base type (no lazy wrappers at this point)
367 base_type = self._get_base_type(cls)
368 direct_bases.append(base_type)
370 # Add base classes directly as children (no "Inherits from:" label)
371 for base_class in direct_bases:
372 # Check if this base class is ui_hidden
373 is_ui_hidden = hasattr(base_class, '__dict__') and '_ui_hidden' in base_class.__dict__ and base_class._ui_hidden
375 base_item = QTreeWidgetItem([base_class.__name__])
376 base_item.setData(0, Qt.ItemDataRole.UserRole, {
377 'type': 'inheritance_link',
378 'target_class': base_class,
379 'ui_hidden': is_ui_hidden
380 })
382 # Style ui_hidden items differently (grayed out, italicized)
383 if is_ui_hidden:
384 font = base_item.font(0)
385 font.setItalic(True)
386 base_item.setFont(0, font)
387 base_item.setForeground(0, QColor(128, 128, 128))
388 base_item.setToolTip(0, "This configuration is not editable in the UI (inherited by other configs)")
390 parent_item.addChild(base_item)
392 # Recursively add inheritance for this base class
393 self._add_inheritance_info(base_item, base_class)
395 def _on_tree_item_double_clicked(self, item: QTreeWidgetItem, column: int):
396 """Handle tree item double-clicks for navigation."""
397 data = item.data(0, Qt.ItemDataRole.UserRole)
398 if not data:
399 return
401 # Check if this item is ui_hidden - if so, ignore the double-click
402 if data.get('ui_hidden', False):
403 logger.debug("Ignoring double-click on ui_hidden item")
404 return
406 item_type = data.get('type')
408 if item_type == 'dataclass':
409 # Navigate to the dataclass section in the form
410 field_name = data.get('field_name')
411 if field_name:
412 self._scroll_to_section(field_name)
413 logger.debug(f"Navigating to section: {field_name}")
414 else:
415 class_obj = data.get('class')
416 class_name = getattr(class_obj, '__name__', 'Unknown') if class_obj else 'Unknown'
417 logger.debug(f"Double-clicked on root dataclass: {class_name}")
419 elif item_type == 'inheritance_link':
420 # Navigate to the parent class section in the form
421 target_class = data.get('target_class')
422 if target_class:
423 # Find the field that has this type (or its lazy version)
424 field_name = self._find_field_for_class(target_class)
425 if field_name:
426 self._scroll_to_section(field_name)
427 logger.debug(f"Navigating to inherited section: {field_name} (class: {target_class.__name__})")
428 else:
429 logger.warning(f"Could not find field for class {target_class.__name__}")
431 def _find_field_for_class(self, target_class) -> str:
432 """Find the field name that has the given class type (or its lazy version)."""
433 from openhcs.core.lazy_placeholder_simplified import LazyDefaultPlaceholderService
434 import dataclasses
436 # Get the root dataclass type
437 root_config = self.form_manager.object_instance
438 if not dataclasses.is_dataclass(root_config):
439 return None
441 root_type = type(root_config)
443 # Search through all fields to find one with matching type
444 for field in dataclasses.fields(root_type):
445 field_type = field.type
447 # Check if field type matches target class directly
448 if field_type == target_class:
449 return field.name
451 # Check if field type is a lazy version of target class
452 if LazyDefaultPlaceholderService.has_lazy_resolution(field_type):
453 # Get the base class of the lazy type
454 for base in field_type.__bases__:
455 if base == target_class:
456 return field.name
458 return None
460 def _scroll_to_section(self, field_name: str):
461 """Scroll to a specific section in the form - type-driven, seamless."""
462 logger.info(f"🔍 Scrolling to section: {field_name}")
463 logger.info(f"Available nested managers: {list(self.form_manager.nested_managers.keys())}")
465 # Type-driven: nested_managers dict has exact field name as key
466 if field_name in self.form_manager.nested_managers:
467 nested_manager = self.form_manager.nested_managers[field_name]
469 # Strategy: Find the first parameter widget in this nested manager (like the test does)
470 # This is more reliable than trying to find the GroupBox
471 first_widget = None
473 if hasattr(nested_manager, 'widgets') and nested_manager.widgets:
474 # Get the first widget from the nested manager's widgets dict
475 first_param_name = next(iter(nested_manager.widgets.keys()))
476 first_widget = nested_manager.widgets[first_param_name]
477 logger.info(f"Found first widget: {first_param_name}")
479 if first_widget:
480 # Scroll to the first widget (this will show the section header too)
481 self.scroll_area.ensureWidgetVisible(first_widget, 100, 100)
482 logger.info(f"✅ Scrolled to {field_name} via first widget")
483 else:
484 # Fallback: try to find the GroupBox
485 from PyQt6.QtWidgets import QGroupBox
486 current = nested_manager.parentWidget()
487 while current:
488 if isinstance(current, QGroupBox):
489 self.scroll_area.ensureWidgetVisible(current, 50, 50)
490 logger.info(f"✅ Scrolled to {field_name} via GroupBox")
491 return
492 current = current.parentWidget()
494 logger.warning(f"⚠️ Could not find widget or GroupBox for {field_name}")
495 else:
496 logger.warning(f"❌ Field '{field_name}' not in nested_managers")
504 def update_widget_value(self, widget: QWidget, value: Any):
505 """
506 Update widget value without triggering signals.
508 Args:
509 widget: Widget to update
510 value: New value
511 """
512 # Temporarily block signals to avoid recursion
513 widget.blockSignals(True)
515 try:
516 if isinstance(widget, QCheckBox):
517 widget.setChecked(bool(value))
518 elif isinstance(widget, QSpinBox):
519 widget.setValue(int(value) if value is not None else 0)
520 elif isinstance(widget, QDoubleSpinBox):
521 widget.setValue(float(value) if value is not None else 0.0)
522 elif isinstance(widget, QComboBox):
523 for i in range(widget.count()):
524 if widget.itemData(i) == value:
525 widget.setCurrentIndex(i)
526 break
527 elif isinstance(widget, QLineEdit):
528 widget.setText(str(value) if value is not None else "")
529 finally:
530 widget.blockSignals(False)
532 def reset_to_defaults(self):
533 """Reset all parameters using centralized service with full sophistication."""
534 # Service layer now contains ALL the sophisticated logic previously in infrastructure classes
535 # This includes nested dataclass reset, lazy awareness, and recursive traversal
536 self.form_manager.reset_all_parameters()
538 # Refresh placeholder text to ensure UI shows correct defaults
539 self.form_manager._refresh_all_placeholders()
541 logger.debug("Reset all parameters using enhanced ParameterFormManager service")
543 def save_config(self):
544 """Save the configuration preserving lazy behavior for unset fields."""
545 try:
546 if LazyDefaultPlaceholderService.has_lazy_resolution(self.config_class):
547 # BETTER APPROACH: For lazy dataclasses, only save user-modified values
548 # Get only values that were explicitly set by the user (non-None raw values)
549 user_modified_values = self.form_manager.get_user_modified_values()
551 # Create fresh lazy instance with only user-modified values
552 # This preserves lazy resolution for unmodified fields
553 new_config = self.config_class(**user_modified_values)
554 else:
555 # For non-lazy dataclasses, use all current values
556 current_values = self.form_manager.get_current_values()
557 new_config = self.config_class(**current_values)
559 # CRITICAL: Set flag to prevent refresh_config from recreating the form
560 # The window already has the correct data - it just saved it!
561 self._saving = True
562 logger.info(f"🔍 SAVE_CONFIG: Set _saving=True before callback (id={id(self)})")
563 try:
564 # Emit signal and call callback
565 self.config_saved.emit(new_config)
567 if self.on_save_callback:
568 logger.info(f"🔍 SAVE_CONFIG: Calling on_save_callback (id={id(self)})")
569 self.on_save_callback(new_config)
570 logger.info(f"🔍 SAVE_CONFIG: Returned from on_save_callback (id={id(self)})")
571 finally:
572 self._saving = False
573 logger.info(f"🔍 SAVE_CONFIG: Reset _saving=False (id={id(self)})")
575 self.accept()
577 except Exception as e:
578 logger.error(f"Failed to save configuration: {e}")
579 from PyQt6.QtWidgets import QMessageBox
580 QMessageBox.critical(self, "Save Error", f"Failed to save configuration:\n{e}")
583 def _patch_lazy_constructors(self):
584 """Context manager that patches lazy dataclass constructors to preserve None vs concrete distinction."""
585 from contextlib import contextmanager
586 from openhcs.core.lazy_placeholder import LazyDefaultPlaceholderService
587 from openhcs.config_framework.lazy_factory import _lazy_type_registry
588 import dataclasses
590 @contextmanager
591 def patch_context():
592 original_constructors = {}
594 # FIXED: Dynamically discover all lazy types from registry instead of hardcoding
595 # This ensures we patch ALL lazy types, including future additions
596 for lazy_type in _lazy_type_registry.keys():
597 if LazyDefaultPlaceholderService.has_lazy_resolution(lazy_type):
598 # Store original constructor
599 original_constructors[lazy_type] = lazy_type.__init__
601 # Create patched constructor that uses raw values
602 def create_patched_init(original_init, dataclass_type):
603 def patched_init(self, **kwargs):
604 # Use raw value approach instead of calling original constructor
605 # This prevents lazy resolution during code execution
606 for field in dataclasses.fields(dataclass_type):
607 value = kwargs.get(field.name, None)
608 object.__setattr__(self, field.name, value)
610 # Initialize any required lazy dataclass attributes
611 if hasattr(dataclass_type, '_is_lazy_dataclass'):
612 object.__setattr__(self, '_is_lazy_dataclass', True)
614 return patched_init
616 # Apply the patch
617 lazy_type.__init__ = create_patched_init(original_constructors[lazy_type], lazy_type)
619 try:
620 yield
621 finally:
622 # Restore original constructors
623 for lazy_type, original_init in original_constructors.items():
624 lazy_type.__init__ = original_init
626 return patch_context()
628 def _view_code(self):
629 """Open code editor to view/edit the configuration as Python code."""
630 try:
631 from openhcs.pyqt_gui.services.simple_code_editor import SimpleCodeEditorService
632 from openhcs.debug.pickle_to_python import generate_config_code
633 import os
635 # Get current config from form
636 current_values = self.form_manager.get_current_values()
637 current_config = self.config_class(**current_values)
639 # Generate code using existing function
640 python_code = generate_config_code(current_config, self.config_class, clean_mode=True)
642 # Launch editor
643 editor_service = SimpleCodeEditorService(self)
644 use_external = os.environ.get('OPENHCS_USE_EXTERNAL_EDITOR', '').lower() in ('1', 'true', 'yes')
646 editor_service.edit_code(
647 initial_content=python_code,
648 title=f"View/Edit {self.config_class.__name__}",
649 callback=self._handle_edited_config_code,
650 use_external=use_external,
651 code_type='config',
652 code_data={'config_class': self.config_class, 'clean_mode': True}
653 )
655 except Exception as e:
656 logger.error(f"Failed to view config code: {e}")
657 from PyQt6.QtWidgets import QMessageBox
658 QMessageBox.critical(self, "View Code Error", f"Failed to view code:\n{e}")
660 def _handle_edited_config_code(self, edited_code: str):
661 """Handle edited configuration code from the code editor."""
662 try:
663 # CRITICAL: Parse the code to extract explicitly specified fields
664 # This prevents overwriting None values for unspecified fields
665 explicitly_set_fields = self._extract_explicitly_set_fields(edited_code)
667 namespace = {}
669 # CRITICAL FIX: Use lazy constructor patching
670 with self._patch_lazy_constructors():
671 exec(edited_code, namespace)
673 new_config = namespace.get('config')
674 if not new_config:
675 raise ValueError("No 'config' variable found in edited code")
677 if not isinstance(new_config, self.config_class):
678 raise ValueError(f"Expected {self.config_class.__name__}, got {type(new_config).__name__}")
680 # Update current config
681 self.current_config = new_config
683 # FIXED: Proper context propagation based on config type
684 # ConfigWindow is used for BOTH GlobalPipelineConfig AND PipelineConfig editing
685 from openhcs.config_framework.global_config import set_global_config_for_editing
686 from openhcs.core.config import GlobalPipelineConfig
688 if self.config_class == GlobalPipelineConfig:
689 # For GlobalPipelineConfig: Update thread-local context
690 # This ensures all lazy resolution uses the new config
691 set_global_config_for_editing(GlobalPipelineConfig, new_config)
692 logger.debug("Updated thread-local GlobalPipelineConfig context")
693 # For PipelineConfig: No context update needed here
694 # The orchestrator.apply_pipeline_config() happens in the save callback
695 # Code edits just update the form, actual application happens on Save
697 # Update form values from the new config without rebuilding
698 self._update_form_from_config(new_config, explicitly_set_fields)
700 logger.info("Updated config from edited code")
702 except Exception as e:
703 logger.error(f"Failed to apply edited config code: {e}")
704 from PyQt6.QtWidgets import QMessageBox
705 QMessageBox.critical(self, "Code Edit Error", f"Failed to apply edited code:\n{e}")
707 def _extract_explicitly_set_fields(self, code: str) -> set:
708 """
709 Parse code to extract which fields were explicitly set.
711 Returns a set of field names that appear in the config constructor call.
712 For nested fields, uses dot notation like 'zarr_config.chunk_size'.
713 """
714 import re
716 # Find the config = ClassName(...) pattern
717 # Match the class name and capture everything inside parentheses
718 pattern = rf'config\s*=\s*{self.config_class.__name__}\s*\((.*?)\)\s*$'
719 match = re.search(pattern, code, re.DOTALL | re.MULTILINE)
721 if not match:
722 return set()
724 constructor_args = match.group(1)
726 # Extract field names (simple pattern: field_name=...)
727 # This handles both simple fields and nested dataclass fields
728 field_pattern = r'(\w+)\s*='
729 fields_found = set(re.findall(field_pattern, constructor_args))
731 logger.debug(f"Explicitly set fields from code: {fields_found}")
732 return fields_found
734 def _update_form_from_config(self, new_config, explicitly_set_fields: set):
735 """Update form values from new config without rebuilding the entire form."""
736 from dataclasses import fields, is_dataclass
738 # CRITICAL: Only update fields that were explicitly set in the code
739 # This preserves None values for fields not mentioned in the code
740 for field in fields(new_config):
741 if field.name in explicitly_set_fields:
742 new_value = getattr(new_config, field.name)
743 if field.name in self.form_manager.parameters:
744 # For nested dataclasses, we need to recursively update nested fields
745 if is_dataclass(new_value) and not isinstance(new_value, type):
746 self._update_nested_dataclass(field.name, new_value)
747 else:
748 self.form_manager.update_parameter(field.name, new_value)
750 # Refresh placeholders to reflect the new values
751 self.form_manager._refresh_all_placeholders()
752 self.form_manager._apply_to_nested_managers(lambda name, manager: manager._refresh_all_placeholders())
754 def _update_nested_dataclass(self, field_name: str, new_value):
755 """Recursively update a nested dataclass field and all its children."""
756 from dataclasses import fields, is_dataclass
758 # Update the parent field first
759 self.form_manager.update_parameter(field_name, new_value)
761 # Get the nested manager for this field
762 nested_manager = self.form_manager.nested_managers.get(field_name)
763 if nested_manager:
764 # Update each field in the nested manager
765 for field in fields(new_value):
766 nested_field_value = getattr(new_value, field.name)
767 if field.name in nested_manager.parameters:
768 # Recursively handle nested dataclasses
769 if is_dataclass(nested_field_value) and not isinstance(nested_field_value, type):
770 self._update_nested_dataclass_in_manager(nested_manager, field.name, nested_field_value)
771 else:
772 nested_manager.update_parameter(field.name, nested_field_value)
774 def _update_nested_dataclass_in_manager(self, manager, field_name: str, new_value):
775 """Helper to update nested dataclass within a specific manager."""
776 from dataclasses import fields, is_dataclass
778 manager.update_parameter(field_name, new_value)
780 nested_manager = manager.nested_managers.get(field_name)
781 if nested_manager:
782 for field in fields(new_value):
783 nested_field_value = getattr(new_value, field.name)
784 if field.name in nested_manager.parameters:
785 if is_dataclass(nested_field_value) and not isinstance(nested_field_value, type):
786 self._update_nested_dataclass_in_manager(nested_manager, field.name, nested_field_value)
787 else:
788 nested_manager.update_parameter(field.name, nested_field_value)
790 def reject(self):
791 """Handle dialog rejection (Cancel button)."""
792 self.config_cancelled.emit()
793 super().reject() # BaseFormDialog handles unregistration
795 def _get_form_managers(self):
796 """Return list of form managers to unregister (required by BaseFormDialog)."""
797 if hasattr(self, 'form_manager'):
798 return [self.form_manager]
799 return []