Coverage for openhcs/pyqt_gui/windows/config_window.py: 0.0%
288 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-01 18:33 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-01 18:33 +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, QFrame, QSplitter, QTreeWidget, QTreeWidgetItem,
15 QLineEdit, QSpinBox, QDoubleSpinBox, QCheckBox, QComboBox
16)
17from PyQt6.QtCore import Qt, pyqtSignal
18from PyQt6.QtGui import QFont
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.core.config import GlobalPipelineConfig
25# ❌ REMOVED: require_config_context decorator - enhanced decorator events system handles context automatically
26from openhcs.core.lazy_placeholder_simplified import LazyDefaultPlaceholderService
27from openhcs.config_framework.context_manager import config_context
31logger = logging.getLogger(__name__)
34# Infrastructure classes removed - functionality migrated to ParameterFormManager service layer
37class ConfigWindow(QDialog):
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.
43 """
45 # Signals
46 config_saved = pyqtSignal(object) # saved config
47 config_cancelled = pyqtSignal()
49 def __init__(self, config_class: Type, current_config: Any,
50 on_save_callback: Optional[Callable] = None,
51 color_scheme: Optional[PyQt6ColorScheme] = None, parent=None,):
52 """
53 Initialize the configuration window.
55 Args:
56 config_class: Configuration class type
57 current_config: Current configuration instance
58 on_save_callback: Function to call when config is saved
59 color_scheme: Color scheme for styling (optional, uses default if None)
60 parent: Parent widget
61 orchestrator: Optional orchestrator reference for context persistence
62 """
63 super().__init__(parent)
65 # Business logic state (extracted from Textual version)
66 self.config_class = config_class
67 self.current_config = current_config
68 self.on_save_callback = on_save_callback
71 # Initialize color scheme and style generator
72 self.color_scheme = color_scheme or PyQt6ColorScheme()
73 self.style_generator = StyleSheetGenerator(self.color_scheme)
75 # SIMPLIFIED: Use dual-axis resolution
76 from openhcs.core.lazy_placeholder import LazyDefaultPlaceholderService
78 # Determine placeholder prefix based on actual instance type (not class type)
79 is_lazy_dataclass = LazyDefaultPlaceholderService.has_lazy_resolution(type(current_config))
80 placeholder_prefix = "Pipeline default" if is_lazy_dataclass else "Default"
82 # SIMPLIFIED: Use ParameterFormManager with dual-axis resolution
83 root_field_id = type(current_config).__name__ # e.g., "GlobalPipelineConfig" or "PipelineConfig"
84 global_config_type = GlobalPipelineConfig # Always use GlobalPipelineConfig for dual-axis resolution
86 # CRITICAL FIX: Pipeline Config Editor should NOT use itself as parent context
87 # context_obj=None means inherit from thread-local GlobalPipelineConfig only
88 # The overlay (current form state) will be built by ParameterFormManager
89 # This fixes the circular context bug where reset showed old values instead of global defaults
91 self.form_manager = ParameterFormManager.from_dataclass_instance(
92 dataclass_instance=current_config,
93 field_id=root_field_id,
94 placeholder_prefix=placeholder_prefix,
95 color_scheme=self.color_scheme,
96 use_scroll_area=True,
97 global_config_type=global_config_type,
98 context_obj=None # Inherit from thread-local GlobalPipelineConfig only
99 )
101 # No config_editor needed - everything goes through form_manager
102 self.config_editor = None
104 # Setup UI
105 self.setup_ui()
107 logger.debug(f"Config window initialized for {config_class.__name__}")
109 def _should_use_scroll_area(self) -> bool:
110 """Determine if scroll area should be used based on config complexity."""
111 # For simple dataclasses with few fields, don't use scroll area
112 # This ensures dataclass fields show in full as requested
113 if dataclasses.is_dataclass(self.config_class):
114 field_count = len(dataclasses.fields(self.config_class))
115 # Use scroll area for configs with more than 8 fields (PipelineConfig has ~12 fields)
116 return field_count > 8
118 # For non-dataclass configs, use scroll area
119 return True
121 def setup_ui(self):
122 """Setup the user interface."""
123 self.setWindowTitle(f"Configuration - {self.config_class.__name__}")
124 self.setModal(False) # Non-modal like plate manager and pipeline editor
125 self.setMinimumSize(600, 400)
126 self.resize(800, 600)
128 layout = QVBoxLayout(self)
129 layout.setSpacing(10)
131 # Header with help functionality for dataclass
132 header_widget = QWidget()
133 header_layout = QHBoxLayout(header_widget)
134 header_layout.setContentsMargins(10, 10, 10, 10)
136 header_label = QLabel(f"Configure {self.config_class.__name__}")
137 header_label.setFont(QFont("Arial", 14, QFont.Weight.Bold))
138 header_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_accent)};")
139 header_layout.addWidget(header_label)
141 # Add help button for the dataclass itself
142 if dataclasses.is_dataclass(self.config_class):
143 from openhcs.pyqt_gui.widgets.shared.clickable_help_components import HelpButton
144 help_btn = HelpButton(help_target=self.config_class, text="Help", color_scheme=self.color_scheme)
145 help_btn.setMaximumWidth(80)
146 header_layout.addWidget(help_btn)
148 header_layout.addStretch()
149 layout.addWidget(header_widget)
151 # Create splitter with tree view on left and form on right
152 splitter = QSplitter(Qt.Orientation.Horizontal)
154 # Left panel - Inheritance hierarchy tree
155 self.tree_widget = self._create_inheritance_tree()
156 splitter.addWidget(self.tree_widget)
158 # Right panel - Parameter form
159 if self._should_use_scroll_area():
160 self.scroll_area = QScrollArea()
161 self.scroll_area.setWidgetResizable(True)
162 self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
163 self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
164 self.scroll_area.setWidget(self.form_manager)
165 splitter.addWidget(self.scroll_area)
166 else:
167 # For simple dataclasses, show form directly without scrolling
168 splitter.addWidget(self.form_manager)
170 # Set splitter proportions (30% tree, 70% form)
171 splitter.setSizes([300, 700])
173 # Add splitter with stretch factor so it expands to fill available space
174 layout.addWidget(splitter, 1) # stretch factor = 1
176 # Button panel
177 button_panel = self.create_button_panel()
178 layout.addWidget(button_panel)
180 # Apply centralized styling
181 self.setStyleSheet(self.style_generator.generate_config_window_style())
183 def _create_inheritance_tree(self) -> QTreeWidget:
184 """Create tree widget showing inheritance hierarchy for navigation."""
185 tree = QTreeWidget()
186 tree.setHeaderLabel("Configuration Hierarchy")
187 # Remove width restrictions to allow horizontal dragging
188 tree.setMinimumWidth(200)
190 # Style the tree with original appearance
191 tree.setStyleSheet(f"""
192 QTreeWidget {{
193 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
194 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
195 border-radius: 3px;
196 color: {self.color_scheme.to_hex(self.color_scheme.text_primary)};
197 font-size: 12px;
198 }}
199 QTreeWidget::item {{
200 padding: 4px;
201 border-bottom: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
202 }}
203 QTreeWidget::item:selected {{
204 background-color: {self.color_scheme.to_hex(self.color_scheme.selection_bg)};
205 color: white;
206 }}
207 QTreeWidget::item:hover {{
208 background-color: {self.color_scheme.to_hex(self.color_scheme.hover_bg)};
209 }}
210 """)
212 # Build inheritance hierarchy
213 self._populate_inheritance_tree(tree)
215 # Connect double-click to navigation
216 tree.itemDoubleClicked.connect(self._on_tree_item_double_clicked)
218 return tree
220 def _populate_inheritance_tree(self, tree: QTreeWidget):
221 """Populate the inheritance tree with only dataclasses visible in the UI."""
222 import dataclasses
224 # Create root item for the main config class
225 config_name = self.config_class.__name__ if self.config_class else "Configuration"
226 root_item = QTreeWidgetItem([config_name])
227 root_item.setData(0, Qt.ItemDataRole.UserRole, {'type': 'dataclass', 'class': self.config_class})
228 tree.addTopLevelItem(root_item)
230 # Only show dataclasses that are visible in the UI (have form sections)
231 if dataclasses.is_dataclass(self.config_class):
232 self._add_ui_visible_dataclasses_to_tree(root_item, self.config_class)
234 # Expand the tree
235 tree.expandAll()
237 def _add_ui_visible_dataclasses_to_tree(self, parent_item: QTreeWidgetItem, dataclass_type):
238 """Add only dataclasses that are visible in the UI form."""
239 import dataclasses
241 # Get all fields from this dataclass
242 fields = dataclasses.fields(dataclass_type)
244 for field in fields:
245 field_name = field.name
246 field_type = field.type
248 # Only show dataclass fields (these appear as sections in the UI)
249 if dataclasses.is_dataclass(field_type):
250 # Create a child item for this nested dataclass
251 field_item = QTreeWidgetItem([f"{field_name} ({field_type.__name__})"])
252 field_item.setData(0, Qt.ItemDataRole.UserRole, {
253 'type': 'dataclass',
254 'class': field_type,
255 'field_name': field_name
256 })
257 parent_item.addChild(field_item)
259 # Show inheritance hierarchy for this dataclass
260 self._add_inheritance_info(field_item, field_type)
262 # Recursively add nested dataclasses
263 self._add_ui_visible_dataclasses_to_tree(field_item, field_type)
265 def _add_inheritance_info(self, parent_item: QTreeWidgetItem, dataclass_type):
266 """Add inheritance information for a dataclass with proper hierarchy, skipping lazy classes."""
267 # Get direct base classes, skipping lazy versions
268 direct_bases = []
269 for cls in dataclass_type.__bases__:
270 if (cls.__name__ != 'object' and
271 hasattr(cls, '__dataclass_fields__') and
272 not cls.__name__.startswith('Lazy')): # Skip lazy dataclass wrappers
273 direct_bases.append(cls)
275 # Add base classes directly as children (no "Inherits from:" label)
276 for base_class in direct_bases:
277 base_item = QTreeWidgetItem([base_class.__name__])
278 base_item.setData(0, Qt.ItemDataRole.UserRole, {
279 'type': 'inheritance_link',
280 'target_class': base_class
281 })
282 parent_item.addChild(base_item)
284 # Recursively add inheritance for this base class
285 self._add_inheritance_info(base_item, base_class)
287 def _on_tree_item_double_clicked(self, item: QTreeWidgetItem, column: int):
288 """Handle tree item double-clicks for navigation."""
289 data = item.data(0, Qt.ItemDataRole.UserRole)
290 if not data:
291 return
293 item_type = data.get('type')
295 if item_type == 'dataclass':
296 # Navigate to the dataclass section in the form
297 field_name = data.get('field_name')
298 if field_name:
299 self._scroll_to_section(field_name)
300 logger.debug(f"Navigating to section: {field_name}")
301 else:
302 class_obj = data.get('class')
303 class_name = getattr(class_obj, '__name__', 'Unknown') if class_obj else 'Unknown'
304 logger.debug(f"Double-clicked on root dataclass: {class_name}")
306 elif item_type == 'inheritance_link':
307 # Find and navigate to the target class in the tree
308 target_class = data.get('target_class')
309 if target_class:
310 self._navigate_to_class_in_tree(target_class)
311 logger.debug(f"Navigating to inherited class: {target_class.__name__}")
313 def _navigate_to_class_in_tree(self, target_class):
314 """Find and highlight a class in the tree."""
315 # Search through all items in the tree to find the target class
316 root = self.tree_widget.invisibleRootItem()
317 self._search_and_highlight_class(root, target_class)
319 def _search_and_highlight_class(self, parent_item, target_class):
320 """Recursively search for and highlight a class in the tree."""
321 for i in range(parent_item.childCount()):
322 child = parent_item.child(i)
323 data = child.data(0, Qt.ItemDataRole.UserRole)
325 if data and data.get('type') == 'dataclass':
326 if data.get('class') == target_class:
327 # Found the target - select and scroll to it
328 self.tree_widget.setCurrentItem(child)
329 self.tree_widget.scrollToItem(child)
330 return True
332 # Recursively search children
333 if self._search_and_highlight_class(child, target_class):
334 return True
336 return False
338 def _scroll_to_section(self, field_name: str):
339 """Scroll to a specific section in the form."""
340 try:
341 # Check if we have a scroll area
342 if hasattr(self, 'scroll_area') and self.scroll_area:
343 # Find the group box for this field name
344 form_widget = self.scroll_area.widget()
345 if form_widget:
346 group_box = self._find_group_box_by_name(form_widget, field_name)
347 if group_box:
348 # Scroll to the group box with small margins for better visibility
349 self.scroll_area.ensureWidgetVisible(group_box, 20, 20)
350 logger.debug(f"Scrolled to section: {field_name}")
351 return
353 # Fallback: try to scroll to form manager directly if no scroll area
354 if hasattr(self.form_manager, 'nested_managers') and field_name in self.form_manager.nested_managers:
355 nested_manager = self.form_manager.nested_managers[field_name]
356 if hasattr(self, 'scroll_area') and self.scroll_area:
357 self.scroll_area.ensureWidgetVisible(nested_manager, 20, 20)
358 logger.debug(f"Scrolled to nested manager: {field_name}")
359 return
361 logger.debug(f"Could not find section to scroll to: {field_name}")
362 except Exception as e:
363 logger.warning(f"Error scrolling to section {field_name}: {e}")
365 def _find_group_box_by_name(self, parent_widget, field_name: str):
366 """Recursively find a group box by field name."""
367 from PyQt6.QtWidgets import QGroupBox
369 # Look for QGroupBox widgets with matching titles
370 group_boxes = parent_widget.findChildren(QGroupBox)
372 for group_box in group_boxes:
373 title = group_box.title()
374 # Check if field name matches the title (case insensitive)
375 if (field_name.lower() in title.lower() or
376 title.lower().replace(' ', '_') == field_name.lower() or
377 field_name.lower().replace('_', ' ') in title.lower()):
378 logger.debug(f"Found matching group box: '{title}' for field '{field_name}'")
379 return group_box
381 # Also check object names as fallback
382 for child in parent_widget.findChildren(QWidget):
383 if hasattr(child, 'objectName') and child.objectName():
384 if field_name.lower() in child.objectName().lower():
385 logger.debug(f"Found matching widget by object name: '{child.objectName()}' for field '{field_name}'")
386 return child
388 logger.debug(f"No matching section found for field: {field_name}")
389 # Debug: print all group box titles to help troubleshoot
390 all_titles = [gb.title() for gb in group_boxes]
391 logger.debug(f"Available group box titles: {all_titles}")
393 return None
401 def create_button_panel(self) -> QWidget:
402 """
403 Create the button panel.
405 Returns:
406 Widget containing action buttons
407 """
408 panel = QFrame()
409 panel.setFrameStyle(QFrame.Shape.Box)
410 panel.setStyleSheet(f"""
411 QFrame {{
412 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
413 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
414 border-radius: 3px;
415 padding: 10px;
416 }}
417 """)
419 layout = QHBoxLayout(panel)
420 layout.addStretch()
422 # Reset button
423 reset_button = QPushButton("Reset to Defaults")
424 reset_button.setMinimumWidth(120)
425 reset_button.clicked.connect(self.reset_to_defaults)
426 button_styles = self.style_generator.generate_config_button_styles()
427 reset_button.setStyleSheet(button_styles["reset"])
428 layout.addWidget(reset_button)
430 layout.addSpacing(10)
432 # Cancel button
433 cancel_button = QPushButton("Cancel")
434 cancel_button.setMinimumWidth(80)
435 cancel_button.clicked.connect(self.reject)
436 cancel_button.setStyleSheet(button_styles["cancel"])
437 layout.addWidget(cancel_button)
439 # Save button
440 save_button = QPushButton("Save")
441 save_button.setMinimumWidth(80)
442 save_button.clicked.connect(self.save_config)
443 save_button.setStyleSheet(button_styles["save"])
444 layout.addWidget(save_button)
446 return panel
452 def update_widget_value(self, widget: QWidget, value: Any):
453 """
454 Update widget value without triggering signals.
456 Args:
457 widget: Widget to update
458 value: New value
459 """
460 # Temporarily block signals to avoid recursion
461 widget.blockSignals(True)
463 try:
464 if isinstance(widget, QCheckBox):
465 widget.setChecked(bool(value))
466 elif isinstance(widget, QSpinBox):
467 widget.setValue(int(value) if value is not None else 0)
468 elif isinstance(widget, QDoubleSpinBox):
469 widget.setValue(float(value) if value is not None else 0.0)
470 elif isinstance(widget, QComboBox):
471 for i in range(widget.count()):
472 if widget.itemData(i) == value:
473 widget.setCurrentIndex(i)
474 break
475 elif isinstance(widget, QLineEdit):
476 widget.setText(str(value) if value is not None else "")
477 finally:
478 widget.blockSignals(False)
480 def reset_to_defaults(self):
481 """Reset all parameters using centralized service with full sophistication."""
482 # Service layer now contains ALL the sophisticated logic previously in infrastructure classes
483 # This includes nested dataclass reset, lazy awareness, and recursive traversal
484 self.form_manager.reset_all_parameters()
486 # Refresh placeholder text to ensure UI shows correct defaults
487 self.form_manager._refresh_all_placeholders()
489 logger.debug("Reset all parameters using enhanced ParameterFormManager service")
491 def refresh_config(self, new_config):
492 """Refresh the config window with new configuration data.
494 This is called when the underlying configuration changes (e.g., from tier 3 edits)
495 to keep the UI in sync with the actual data.
497 Args:
498 new_config: New configuration instance to display
499 """
500 try:
501 # Import required services
502 from openhcs.core.lazy_placeholder import LazyDefaultPlaceholderService
504 # Update the current config
505 self.current_config = new_config
507 # Determine placeholder prefix based on actual instance type (same logic as __init__)
508 is_lazy_dataclass = LazyDefaultPlaceholderService.has_lazy_resolution(type(new_config))
509 placeholder_prefix = "Pipeline default" if is_lazy_dataclass else "Default"
511 # SIMPLIFIED: Create new form manager with dual-axis resolution
512 root_field_id = type(new_config).__name__ # e.g., "GlobalPipelineConfig" or "PipelineConfig"
514 # FIXED: Use the dataclass instance itself for context consistently
515 new_form_manager = ParameterFormManager.from_dataclass_instance(
516 dataclass_instance=new_config,
517 field_id=root_field_id,
518 placeholder_prefix=placeholder_prefix,
519 color_scheme=self.color_scheme,
520 use_scroll_area=True,
521 global_config_type=GlobalPipelineConfig
522 )
524 # Find and replace the form widget in the layout
525 # Layout structure: [0] header, [1] form/scroll_area, [2] buttons
526 layout = self.layout()
527 if layout.count() >= 2:
528 # Get the form container (might be scroll area or direct form)
529 form_container_item = layout.itemAt(1)
530 if form_container_item:
531 old_container = form_container_item.widget()
533 # Remove old container from layout
534 layout.removeItem(form_container_item)
536 # Properly delete old container and its contents
537 if old_container:
538 old_container.deleteLater()
540 # Add new form container at the same position
541 if self._should_use_scroll_area():
542 # Create new scroll area with new form
543 from PyQt6.QtWidgets import QScrollArea
544 from PyQt6.QtCore import Qt
545 scroll_area = QScrollArea()
546 scroll_area.setWidgetResizable(True)
547 scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
548 scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
549 scroll_area.setWidget(new_form_manager)
550 layout.insertWidget(1, scroll_area)
551 else:
552 # Add form directly
553 layout.insertWidget(1, new_form_manager)
555 # Update the form manager reference
556 self.form_manager = new_form_manager
558 logger.debug(f"Config window refreshed with new {type(new_config).__name__}")
559 else:
560 logger.error("Could not find form container in layout")
561 else:
562 logger.error(f"Layout has insufficient items: {layout.count()}")
564 except Exception as e:
565 logger.error(f"Failed to refresh config window: {e}")
566 import traceback
567 logger.error(f"Traceback: {traceback.format_exc()}")
569 def save_config(self):
570 """Save the configuration preserving lazy behavior for unset fields."""
571 try:
572 if LazyDefaultPlaceholderService.has_lazy_resolution(self.config_class):
573 # BETTER APPROACH: For lazy dataclasses, only save user-modified values
574 # Get only values that were explicitly set by the user (non-None raw values)
575 user_modified_values = self.form_manager.get_user_modified_values()
577 # Create fresh lazy instance with only user-modified values
578 # This preserves lazy resolution for unmodified fields
579 new_config = self.config_class(**user_modified_values)
580 else:
581 # For non-lazy dataclasses, use all current values
582 current_values = self.form_manager.get_current_values()
583 new_config = self.config_class(**current_values)
585 # Emit signal and call callback
586 self.config_saved.emit(new_config)
588 if self.on_save_callback:
589 self.on_save_callback(new_config)
591 self.accept()
593 except Exception as e:
594 logger.error(f"Failed to save configuration: {e}")
595 from PyQt6.QtWidgets import QMessageBox
596 QMessageBox.critical(self, "Save Error", f"Failed to save configuration:\n{e}")
600 def reject(self):
601 """Handle dialog rejection (Cancel button)."""
602 self.config_cancelled.emit()
603 self._cleanup_signal_connections()
604 super().reject()
606 def accept(self):
607 """Handle dialog acceptance (Save button)."""
608 self._cleanup_signal_connections()
609 super().accept()
611 def closeEvent(self, event):
612 """Handle window close event."""
613 self._cleanup_signal_connections()
614 super().closeEvent(event)
616 def _cleanup_signal_connections(self):
617 """Clean up signal connections to prevent memory leaks."""
618 # The signal connection is handled by the plate manager
619 # We just need to mark that this window is closing
620 logger.debug("Config window closing, signal connections will be cleaned up")