Coverage for openhcs/pyqt_gui/windows/config_window.py: 0.0%
314 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"""
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 dataclasses import fields
11from typing import Type, Any, Callable, Optional, Dict, Protocol, Union
12from functools import partial
13from abc import ABC, abstractmethod
15from PyQt6.QtWidgets import (
16 QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
17 QScrollArea, QWidget, QFormLayout, QGroupBox, QFrame,
18 QLineEdit, QSpinBox, QDoubleSpinBox, QCheckBox, QComboBox
19)
20from PyQt6.QtCore import Qt, pyqtSignal
21from PyQt6.QtGui import QFont
23from openhcs.textual_tui.widgets.shared.signature_analyzer import SignatureAnalyzer
24from openhcs.pyqt_gui.widgets.shared.parameter_form_manager import ParameterFormManager
25from openhcs.pyqt_gui.shared.style_generator import StyleSheetGenerator
26from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme
28# Import PyQt6 help components
29from openhcs.pyqt_gui.widgets.shared.clickable_help_components import GroupBoxWithHelp, LabelWithHelp
31logger = logging.getLogger(__name__)
34# ========== FUNCTIONAL ABSTRACTIONS FOR CONFIG RESET ==========
36class FormManagerProtocol(Protocol):
37 """Protocol defining the interface for form managers."""
38 def update_parameter(self, param_name: str, value: Any) -> None: ...
39 def get_current_values(self) -> Dict[str, Any]: ...
42class DataclassIntrospector:
43 """Pure functional dataclass introspection and analysis."""
45 @staticmethod
46 def is_lazy_dataclass(instance: Any) -> bool:
47 """Check if an instance is a lazy dataclass."""
48 return hasattr(instance, '_resolve_field_value')
50 @staticmethod
51 def get_static_defaults(config_class: Type) -> Dict[str, Any]:
52 """Get static default values from dataclass definition."""
53 return {
54 field.name: field.default if field.default is not dataclasses.MISSING
55 else field.default_factory() if field.default_factory is not dataclasses.MISSING
56 else None
57 for field in fields(config_class)
58 }
60 @staticmethod
61 def get_lazy_reset_values(config_class: Type) -> Dict[str, Any]:
62 """Get reset values for lazy dataclass (all None for lazy loading)."""
63 return {field.name: None for field in fields(config_class)}
65 @staticmethod
66 def extract_field_values(dataclass_instance: Any) -> Dict[str, Any]:
67 """Extract field values from a dataclass instance."""
68 return {
69 field.name: getattr(dataclass_instance, field.name)
70 for field in fields(dataclass_instance)
71 }
74class ResetStrategy(ABC):
75 """Abstract base class for reset strategies."""
77 @abstractmethod
78 def generate_reset_values(self, config_class: Type, current_config: Any) -> Dict[str, Any]:
79 """Generate the values to reset to."""
80 pass
83class LazyAwareResetStrategy(ResetStrategy):
84 """Strategy that respects lazy dataclass architecture."""
86 def generate_reset_values(self, config_class: Type, current_config: Any) -> Dict[str, Any]:
87 if DataclassIntrospector.is_lazy_dataclass(current_config):
88 # For lazy dataclasses, we need to resolve to actual static defaults
89 # instead of trying to create a new lazy instance with None values
91 # Get the base class that the lazy dataclass is based on
92 base_class = self._get_base_class_from_lazy(config_class)
94 # Create a fresh instance of the base class to get static defaults
95 static_defaults_instance = base_class()
97 # Extract the field values from the static defaults
98 resolved_values = {}
99 for field in fields(config_class):
100 resolved_values[field.name] = getattr(static_defaults_instance, field.name)
102 return resolved_values
103 else:
104 # Regular dataclass: reset to static default values
105 return DataclassIntrospector.get_static_defaults(config_class)
107 def _get_base_class_from_lazy(self, lazy_class: Type) -> Type:
108 """Extract the base class from a lazy dataclass."""
109 # For PipelineConfig, the base class is GlobalPipelineConfig
110 # We can determine this from the to_base_config method
111 if hasattr(lazy_class, 'to_base_config'):
112 # Create a dummy instance to inspect the to_base_config method
113 dummy_instance = lazy_class()
114 base_instance = dummy_instance.to_base_config()
115 return type(base_instance)
117 # Fallback: assume the lazy class name pattern and import the base class
118 from openhcs.core.config import GlobalPipelineConfig
119 return GlobalPipelineConfig
122class FormManagerUpdater:
123 """Pure functional form manager update operations."""
125 @staticmethod
126 def apply_values_to_form_manager(
127 form_manager: FormManagerProtocol,
128 values: Dict[str, Any],
129 modified_values_tracker: Optional[Dict[str, Any]] = None
130 ) -> None:
131 """Apply values to form manager and optionally track modifications."""
132 for param_name, value in values.items():
133 form_manager.update_parameter(param_name, value)
134 if modified_values_tracker is not None:
135 modified_values_tracker[param_name] = value
137 @staticmethod
138 def apply_nested_reset_recursively(
139 form_manager: Any,
140 config_class: Type,
141 current_config: Any
142 ) -> None:
143 """Apply reset values to nested form managers recursively."""
144 if not hasattr(form_manager, 'nested_managers'):
145 return
147 for nested_param_name, nested_manager in form_manager.nested_managers.items():
148 # Get the nested dataclass type and current instance
149 nested_field = next(
150 (f for f in fields(config_class) if f.name == nested_param_name),
151 None
152 )
154 if nested_field and dataclasses.is_dataclass(nested_field.type):
155 nested_config_class = nested_field.type
156 nested_current_config = getattr(current_config, nested_param_name, None) if current_config else None
158 # Generate reset values for nested dataclass with mixed state support
159 if nested_current_config and DataclassIntrospector.is_lazy_dataclass(nested_current_config):
160 # Lazy dataclass: support mixed states - preserve individual field lazy behavior
161 nested_reset_values = {}
162 for field in fields(nested_config_class):
163 # For lazy dataclasses, always reset to None to preserve lazy behavior
164 # This allows individual fields to maintain placeholder behavior
165 nested_reset_values[field.name] = None
166 else:
167 # Regular concrete dataclass: reset to static defaults
168 nested_reset_values = DataclassIntrospector.get_static_defaults(nested_config_class)
170 # Apply reset values to nested manager
171 FormManagerUpdater.apply_values_to_form_manager(nested_manager, nested_reset_values)
173 # Recurse for deeper nesting
174 FormManagerUpdater.apply_nested_reset_recursively(
175 nested_manager, nested_config_class, nested_current_config
176 )
177 else:
178 # Fallback: reset using parameter info
179 FormManagerUpdater._reset_manager_to_parameter_defaults(nested_manager)
181 @staticmethod
182 def _reset_manager_to_parameter_defaults(manager: Any) -> None:
183 """Reset a manager to its parameter defaults."""
184 if (hasattr(manager, 'textual_form_manager') and
185 hasattr(manager.textual_form_manager, 'parameter_info')):
186 default_values = {
187 param_name: param_info.default_value
188 for param_name, param_info in manager.textual_form_manager.parameter_info.items()
189 }
190 FormManagerUpdater.apply_values_to_form_manager(manager, default_values)
193class ResetOperation:
194 """Immutable reset operation that respects lazy dataclass architecture."""
196 def __init__(self, strategy: ResetStrategy, config_class: Type, current_config: Any):
197 self.strategy = strategy
198 self.config_class = config_class
199 self.current_config = current_config
200 self._reset_values = None
202 @property
203 def reset_values(self) -> Dict[str, Any]:
204 """Lazy computation of reset values."""
205 if self._reset_values is None:
206 self._reset_values = self.strategy.generate_reset_values(
207 self.config_class, self.current_config
208 )
209 return self._reset_values
211 def apply_to_form_manager(
212 self,
213 form_manager: FormManagerProtocol,
214 modified_values_tracker: Optional[Dict[str, Any]] = None
215 ) -> None:
216 """Apply this reset operation to a form manager."""
217 # Apply top-level reset values
218 FormManagerUpdater.apply_values_to_form_manager(
219 form_manager, self.reset_values, modified_values_tracker
220 )
222 # Apply nested reset values recursively
223 FormManagerUpdater.apply_nested_reset_recursively(
224 form_manager, self.config_class, self.current_config
225 )
227 @classmethod
228 def create_lazy_aware_reset(cls, config_class: Type, current_config: Any) -> 'ResetOperation':
229 """Factory method for lazy-aware reset operations."""
230 return cls(LazyAwareResetStrategy(), config_class, current_config)
232 @classmethod
233 def create_custom_reset(cls, strategy: ResetStrategy, config_class: Type, current_config: Any) -> 'ResetOperation':
234 """Factory method for custom reset operations."""
235 return cls(strategy, config_class, current_config)
238class ConfigWindow(QDialog):
239 """
240 PyQt6 Configuration Window.
242 Configuration editing dialog with parameter forms and validation.
243 Preserves all business logic from Textual version with clean PyQt6 UI.
244 """
246 # Signals
247 config_saved = pyqtSignal(object) # saved config
248 config_cancelled = pyqtSignal()
250 def __init__(self, config_class: Type, current_config: Any,
251 on_save_callback: Optional[Callable] = None,
252 color_scheme: Optional[PyQt6ColorScheme] = None, parent=None,
253 is_global_config_editing: bool = False):
254 """
255 Initialize the configuration window.
257 Args:
258 config_class: Configuration class type
259 current_config: Current configuration instance
260 on_save_callback: Function to call when config is saved
261 color_scheme: Color scheme for styling (optional, uses default if None)
262 parent: Parent widget
263 """
264 super().__init__(parent)
266 # Business logic state (extracted from Textual version)
267 self.config_class = config_class
268 self.current_config = current_config
269 self.on_save_callback = on_save_callback
271 # Initialize color scheme and style generator
272 self.color_scheme = color_scheme or PyQt6ColorScheme()
273 self.style_generator = StyleSheetGenerator(self.color_scheme)
275 # Create config form using shared parameter form manager (mirrors Textual TUI)
276 param_info = SignatureAnalyzer.analyze(config_class)
278 # Get current parameter values from config instance
279 parameters = {}
280 parameter_types = {}
282 logger.info("=== CONFIG WINDOW PARAMETER LOADING ===")
283 for name, info in param_info.items():
284 # For lazy dataclasses, always preserve None values for consistent placeholder behavior
285 if hasattr(current_config, '_resolve_field_value'):
286 # This is a lazy dataclass - use object.__getattribute__ to preserve None values
287 # This ensures ALL fields show placeholder behavior regardless of Optional status
288 current_value = object.__getattribute__(current_config, name) if hasattr(current_config, name) else info.default_value
289 logger.info(f"Lazy field {name}: stored={current_value}, default={info.default_value}")
290 else:
291 # Regular dataclass - use normal getattr
292 current_value = getattr(current_config, name, info.default_value)
293 logger.info(f"Regular field {name}: value={current_value}")
294 parameters[name] = current_value
295 parameter_types[name] = info.param_type
296 logger.info(f"Final parameter value for {name}: {parameters[name]}")
298 # Store parameter info
299 self.parameter_info = param_info
301 # Create parameter form manager (reuses Textual TUI logic)
302 # Determine global config type and placeholder prefix
303 global_config_type = config_class if is_global_config_editing else None
304 placeholder_prefix = "Default" if is_global_config_editing else "Pipeline default"
306 self.form_manager = ParameterFormManager(
307 parameters, parameter_types, "config", param_info,
308 color_scheme=self.color_scheme,
309 is_global_config_editing=is_global_config_editing,
310 global_config_type=global_config_type,
311 placeholder_prefix=placeholder_prefix
312 )
314 # Setup UI
315 self.setup_ui()
316 self.setup_connections()
318 logger.debug(f"Config window initialized for {config_class.__name__}")
320 def _should_use_scroll_area(self) -> bool:
321 """Determine if scroll area should be used based on config complexity."""
322 # For simple dataclasses with few fields, don't use scroll area
323 # This ensures dataclass fields show in full as requested
324 if dataclasses.is_dataclass(self.config_class):
325 field_count = len(dataclasses.fields(self.config_class))
326 # Use scroll area only for complex configs with many fields
327 return field_count > 15
329 # For non-dataclass configs, use scroll area
330 return True
332 def setup_ui(self):
333 """Setup the user interface."""
334 self.setWindowTitle(f"Configuration - {self.config_class.__name__}")
335 self.setModal(False) # Non-modal like plate manager and pipeline editor
336 self.setMinimumSize(600, 400)
337 self.resize(800, 600)
339 layout = QVBoxLayout(self)
340 layout.setSpacing(10)
342 # Header with help functionality for dataclass
343 header_widget = QWidget()
344 header_layout = QHBoxLayout(header_widget)
345 header_layout.setContentsMargins(10, 10, 10, 10)
347 header_label = QLabel(f"Configure {self.config_class.__name__}")
348 header_label.setFont(QFont("Arial", 14, QFont.Weight.Bold))
349 header_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_accent)};")
350 header_layout.addWidget(header_label)
352 # Add help button for the dataclass itself
353 if dataclasses.is_dataclass(self.config_class):
354 from openhcs.pyqt_gui.widgets.shared.clickable_help_components import HelpButton
355 help_btn = HelpButton(help_target=self.config_class, text="Help", color_scheme=self.color_scheme)
356 help_btn.setMaximumWidth(80)
357 header_layout.addWidget(help_btn)
359 header_layout.addStretch()
360 layout.addWidget(header_widget)
362 # Parameter form - use scroll area only for complex configs, not simple dataclasses
363 if self._should_use_scroll_area():
364 scroll_area = QScrollArea()
365 scroll_area.setWidgetResizable(True)
366 scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
367 scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
368 scroll_area.setWidget(self.form_manager)
369 layout.addWidget(scroll_area)
370 else:
371 # For simple dataclasses, show form directly without scrolling
372 layout.addWidget(self.form_manager)
374 # Button panel
375 button_panel = self.create_button_panel()
376 layout.addWidget(button_panel)
378 # Apply centralized styling
379 self.setStyleSheet(self.style_generator.generate_config_window_style())
381 def create_parameter_form(self) -> QWidget:
382 """
383 Create the parameter form using extracted business logic.
385 Returns:
386 Widget containing parameter form
387 """
388 form_widget = QWidget()
389 main_layout = QVBoxLayout(form_widget)
391 # Group parameters by category (simplified grouping)
392 basic_params = {}
393 advanced_params = {}
395 for param_name, param_info in self.parameter_info.items():
396 # Simple categorization based on parameter name
397 if any(keyword in param_name.lower() for keyword in ['debug', 'verbose', 'advanced', 'experimental']):
398 advanced_params[param_name] = param_info
399 else:
400 basic_params[param_name] = param_info
402 # Create basic parameters group
403 if basic_params:
404 basic_group = self.create_parameter_group("Basic Settings", basic_params)
405 main_layout.addWidget(basic_group)
407 # Create advanced parameters group
408 if advanced_params:
409 advanced_group = self.create_parameter_group("Advanced Settings", advanced_params)
410 main_layout.addWidget(advanced_group)
412 main_layout.addStretch()
413 return form_widget
415 def create_parameter_group(self, group_name: str, parameters: Dict) -> QGroupBox:
416 """
417 Create a parameter group.
419 Args:
420 group_name: Name of the parameter group
421 parameters: Dictionary of parameters
423 Returns:
424 QGroupBox containing the parameters
425 """
426 group_box = QGroupBox(group_name)
427 layout = QFormLayout(group_box)
429 for param_name, param_info in parameters.items():
430 # Get current value - preserve None values for lazy dataclasses
431 if hasattr(self.current_config, '_resolve_field_value'):
432 current_value = object.__getattribute__(self.current_config, param_name) if hasattr(self.current_config, param_name) else param_info.default_value
433 else:
434 current_value = getattr(self.current_config, param_name, param_info.default_value)
436 # Create parameter widget
437 widget = self.create_parameter_widget(param_name, param_info.param_type, current_value)
438 if widget:
439 # Parameter label with help functionality
440 label_text = param_name.replace('_', ' ').title()
441 param_description = param_info.description
443 # Use LabelWithHelp for parameter help
444 label_with_help = LabelWithHelp(
445 text=label_text,
446 param_name=param_name,
447 param_description=param_description,
448 param_type=param_info.param_type,
449 color_scheme=self.color_scheme
450 )
451 label_with_help.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_secondary)}; font-weight: normal;")
453 # Add to form
454 layout.addRow(label_with_help, widget)
455 self.parameter_widgets[param_name] = widget
457 return group_box
459 def create_parameter_widget(self, param_name: str, param_type: type, current_value: Any) -> Optional[QWidget]:
460 """
461 Create parameter widget based on type.
463 Args:
464 param_name: Parameter name
465 param_type: Parameter type
466 current_value: Current parameter value
468 Returns:
469 Widget for parameter editing or None
470 """
471 try:
472 # Boolean parameters
473 if param_type == bool:
474 widget = QCheckBox()
475 widget.setChecked(bool(current_value))
476 widget.toggled.connect(lambda checked: self.handle_parameter_change(param_name, checked))
477 return widget
479 # Integer parameters
480 elif param_type == int:
481 widget = QSpinBox()
482 widget.setRange(-999999, 999999)
483 widget.setValue(int(current_value) if current_value is not None else 0)
484 widget.valueChanged.connect(lambda value: self.handle_parameter_change(param_name, value))
485 return widget
487 # Float parameters
488 elif param_type == float:
489 widget = QDoubleSpinBox()
490 widget.setRange(-999999.0, 999999.0)
491 widget.setDecimals(6)
492 widget.setValue(float(current_value) if current_value is not None else 0.0)
493 widget.valueChanged.connect(lambda value: self.handle_parameter_change(param_name, value))
494 return widget
496 # Enum parameters
497 elif any(base.__name__ == 'Enum' for base in param_type.__bases__):
498 widget = QComboBox()
499 for enum_value in param_type:
500 widget.addItem(str(enum_value.value), enum_value)
502 # Set current value
503 if current_value is not None:
504 for i in range(widget.count()):
505 if widget.itemData(i) == current_value:
506 widget.setCurrentIndex(i)
507 break
509 widget.currentIndexChanged.connect(
510 lambda index: self.handle_parameter_change(param_name, widget.itemData(index))
511 )
512 return widget
514 # String and other parameters
515 else:
516 widget = QLineEdit()
517 widget.setText(str(current_value) if current_value is not None else "")
518 widget.textChanged.connect(lambda text: self.handle_parameter_change(param_name, text))
519 return widget
521 except Exception as e:
522 logger.warning(f"Failed to create widget for parameter {param_name}: {e}")
523 return None
525 def create_button_panel(self) -> QWidget:
526 """
527 Create the button panel.
529 Returns:
530 Widget containing action buttons
531 """
532 panel = QFrame()
533 panel.setFrameStyle(QFrame.Shape.Box)
534 panel.setStyleSheet(f"""
535 QFrame {{
536 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
537 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
538 border-radius: 3px;
539 padding: 10px;
540 }}
541 """)
543 layout = QHBoxLayout(panel)
544 layout.addStretch()
546 # Reset button
547 reset_button = QPushButton("Reset to Defaults")
548 reset_button.setMinimumWidth(120)
549 reset_button.clicked.connect(self.reset_to_defaults)
550 button_styles = self.style_generator.generate_config_button_styles()
551 reset_button.setStyleSheet(button_styles["reset"])
552 layout.addWidget(reset_button)
554 layout.addSpacing(10)
556 # Cancel button
557 cancel_button = QPushButton("Cancel")
558 cancel_button.setMinimumWidth(80)
559 cancel_button.clicked.connect(self.reject)
560 cancel_button.setStyleSheet(button_styles["cancel"])
561 layout.addWidget(cancel_button)
563 # Save button
564 save_button = QPushButton("Save")
565 save_button.setMinimumWidth(80)
566 save_button.clicked.connect(self.save_config)
567 save_button.setStyleSheet(button_styles["save"])
568 layout.addWidget(save_button)
570 return panel
572 def setup_connections(self):
573 """Setup signal/slot connections."""
574 self.config_saved.connect(self.on_config_saved)
575 self.config_cancelled.connect(self.on_config_cancelled)
577 # Connect form manager parameter changes
578 self.form_manager.parameter_changed.connect(self._handle_parameter_change)
580 def _handle_parameter_change(self, param_name: str, value):
581 """Handle parameter change from form manager (mirrors Textual TUI)."""
582 # No need to track modifications - form manager maintains state correctly
583 pass
585 def load_current_values(self):
586 """Load current configuration values into widgets."""
587 # The form manager already loads current values during initialization
588 # This method is kept for compatibility but doesn't need to do anything
589 # since the form manager handles widget initialization with current values
590 pass
592 def handle_parameter_change(self, param_name: str, value: Any):
593 """
594 Handle parameter value changes.
596 Args:
597 param_name: Name of the parameter
598 value: New parameter value
599 """
600 # Form manager handles state correctly - no tracking needed
601 pass
603 def update_widget_value(self, widget: QWidget, value: Any):
604 """
605 Update widget value without triggering signals.
607 Args:
608 widget: Widget to update
609 value: New value
610 """
611 # Temporarily block signals to avoid recursion
612 widget.blockSignals(True)
614 try:
615 if isinstance(widget, QCheckBox):
616 widget.setChecked(bool(value))
617 elif isinstance(widget, QSpinBox):
618 widget.setValue(int(value) if value is not None else 0)
619 elif isinstance(widget, QDoubleSpinBox):
620 widget.setValue(float(value) if value is not None else 0.0)
621 elif isinstance(widget, QComboBox):
622 for i in range(widget.count()):
623 if widget.itemData(i) == value:
624 widget.setCurrentIndex(i)
625 break
626 elif isinstance(widget, QLineEdit):
627 widget.setText(str(value) if value is not None else "")
628 finally:
629 widget.blockSignals(False)
631 def reset_to_defaults(self):
632 """Reset all parameters using individual field reset logic for consistency."""
633 # Use the same logic as individual reset buttons to ensure consistency
634 # This delegates to the form manager's lazy-aware reset logic
635 if hasattr(self.form_manager, 'reset_all_parameters'):
636 # For form managers that support lazy-aware reset_all_parameters
637 self.form_manager.reset_all_parameters()
638 else:
639 # Fallback: reset each parameter individually using the same logic as reset buttons
640 param_info = SignatureAnalyzer.analyze(self.config_class)
641 for param_name in param_info.keys():
642 if hasattr(self.form_manager, '_reset_parameter'):
643 # Use the individual reset logic (PyQt form manager)
644 self.form_manager._reset_parameter(param_name)
645 elif hasattr(self.form_manager, 'reset_parameter'):
646 # Use the individual reset logic (Textual form manager)
647 self.form_manager.reset_parameter(param_name)
649 logger.debug("Reset all parameters using individual field reset logic")
651 def save_config(self):
652 """Save the configuration preserving lazy behavior for unset fields."""
653 try:
654 # Get current values from form manager
655 form_values = self.form_manager.get_current_values()
657 # For lazy dataclasses, use form values directly
658 # The form manager already maintains None vs concrete distinction correctly
659 config_values = form_values
661 # Create new config instance
662 new_config = self.config_class(**config_values)
664 # Emit signal and call callback
665 self.config_saved.emit(new_config)
667 if self.on_save_callback:
668 self.on_save_callback(new_config)
670 self.accept()
672 except Exception as e:
673 logger.error(f"Failed to save configuration: {e}")
674 from PyQt6.QtWidgets import QMessageBox
675 QMessageBox.critical(self, "Save Error", f"Failed to save configuration:\n{e}")
677 def on_config_saved(self, config):
678 """Handle config saved signal."""
679 logger.debug(f"Config saved: {config}")
681 def on_config_cancelled(self):
682 """Handle config cancelled signal."""
683 logger.debug("Config cancelled")
685 def reject(self):
686 """Handle dialog rejection (Cancel button)."""
687 self.config_cancelled.emit()
688 super().reject()