Coverage for openhcs/pyqt_gui/windows/base_form_dialog.py: 0.0%
65 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"""
2Base Form Dialog for PyQt6
4Base class for dialogs that use ParameterFormManager to ensure proper cleanup
5of cross-window placeholder update connections.
7This base class solves the problem of ghost form managers remaining in the
8_active_form_managers registry after a dialog closes, which causes infinite
9placeholder refresh loops and runaway CPU usage.
11The issue occurs because Qt's QDialog.accept() and QDialog.reject() methods
12do NOT trigger closeEvent() - they just hide the dialog. This means any cleanup
13code in closeEvent() is never called when the user clicks Save or Cancel.
15This base class overrides accept(), reject(), and closeEvent() to ensure that
16form managers are always unregistered from cross-window updates, regardless of
17how the dialog is closed.
19The default implementation automatically discovers all ParameterFormManager
20instances in the widget tree, so subclasses don't need to manually track them.
22Usage:
23 1. Inherit from BaseFormDialog instead of QDialog
24 2. That's it! All ParameterFormManager instances are automatically discovered and cleaned up.
26Example:
27 class MyConfigDialog(BaseFormDialog):
28 def __init__(self, ...):
29 super().__init__(...)
30 self.form_manager = ParameterFormManager(...)
31 # No need to override _get_form_managers() - automatic discovery!
32"""
34import logging
35from typing import Optional, Protocol
37from PyQt6.QtWidgets import QDialog
38from PyQt6.QtCore import QEvent
40logger = logging.getLogger(__name__)
43class HasUnregisterMethod(Protocol):
44 """Protocol for objects that can be unregistered from cross-window updates."""
45 def unregister_from_cross_window_updates(self) -> None: ...
48class BaseFormDialog(QDialog):
49 """
50 Base class for dialogs that use ParameterFormManager.
52 Automatically handles unregistration from cross-window updates when the dialog
53 closes via any method (accept, reject, or closeEvent).
55 Subclasses should:
56 1. Store their ParameterFormManager instance(s) in a way that can be discovered
57 2. Override _get_form_managers() to return a list of all form managers to unregister
59 Example:
60 class MyDialog(BaseFormDialog):
61 def __init__(self, ...):
62 super().__init__(...)
63 self.form_manager = ParameterFormManager(...)
65 def _get_form_managers(self):
66 return [self.form_manager]
67 """
69 def __init__(self, parent=None):
70 super().__init__(parent)
71 self._unregistered = False # Track if we've already unregistered
73 def _get_form_managers(self):
74 """
75 Return a list of all ParameterFormManager instances that need to be unregistered.
77 Default implementation recursively searches the widget tree for all
78 ParameterFormManager instances. Subclasses can override for custom behavior.
80 Returns:
81 List of ParameterFormManager instances
82 """
83 managers = []
84 self._collect_form_managers_recursive(self, managers, visited=set())
85 return managers
87 def _collect_form_managers_recursive(self, widget, managers, visited):
88 """
89 Recursively collect all ParameterFormManager instances from widget tree.
91 This eliminates the need for manual tracking - just inherit from BaseFormDialog
92 and all nested form managers will be automatically discovered and cleaned up.
94 Uses Protocol-based duck typing to check for unregister method, avoiding
95 hasattr smell for guaranteed attributes while still supporting dynamic discovery.
97 Args:
98 widget: Widget to search
99 managers: List to append found managers to
100 visited: Set of already-visited widget IDs to prevent infinite loops
101 """
102 # Prevent infinite loops from circular references
103 widget_id = id(widget)
104 if widget_id in visited:
105 return
106 visited.add(widget_id)
108 # Check if this widget IS a ParameterFormManager (duck typing via Protocol)
109 # This is legitimate hasattr - we're discovering unknown widget types
110 if callable(getattr(widget, 'unregister_from_cross_window_updates', None)):
111 managers.append(widget)
112 return # Don't recurse into the manager itself
114 # Check if this widget HAS a form_manager attribute
115 # This is legitimate - form_manager is an optional composition pattern
116 form_manager = getattr(widget, 'form_manager', None)
117 if form_manager is not None and callable(getattr(form_manager, 'unregister_from_cross_window_updates', None)):
118 managers.append(form_manager)
120 # Recursively search child widgets using Qt's children() method
121 try:
122 for child in widget.children():
123 self._collect_form_managers_recursive(child, managers, visited)
124 except (RuntimeError, AttributeError):
125 # Widget already deleted - this is expected during cleanup
126 pass
128 # Also check common container attributes that might hold widgets
129 # These are known patterns in our UI architecture
130 for attr_name in ['function_panes', 'step_editor', 'func_editor', 'parameter_editor']:
131 attr_value = getattr(widget, attr_name, None)
132 if attr_value is not None:
133 # Handle lists of widgets
134 if isinstance(attr_value, list):
135 for item in attr_value:
136 self._collect_form_managers_recursive(item, managers, visited)
137 # Handle single widget
138 else:
139 self._collect_form_managers_recursive(attr_value, managers, visited)
141 def _unregister_all_form_managers(self):
142 """Unregister all form managers from cross-window updates."""
143 if self._unregistered:
144 logger.debug(f"🔍 {self.__class__.__name__}: Already unregistered, skipping")
145 return
147 logger.info(f"🔍 {self.__class__.__name__}: Unregistering all form managers")
149 managers = self._get_form_managers()
151 if not managers:
152 logger.debug(f"🔍 {self.__class__.__name__}: No form managers found to unregister")
153 return
155 for manager in managers:
156 try:
157 logger.info(f"🔍 {self.__class__.__name__}: Calling unregister on {manager.field_id} (id={id(manager)})")
158 manager.unregister_from_cross_window_updates()
159 except Exception as e:
160 logger.error(f"Failed to unregister form manager {manager.field_id}: {e}")
162 self._unregistered = True
163 logger.info(f"🔍 {self.__class__.__name__}: All form managers unregistered")
165 def accept(self):
166 """Override accept to unregister before closing."""
167 logger.info(f"🔍 {self.__class__.__name__}: accept() called")
168 self._unregister_all_form_managers()
169 super().accept()
171 def reject(self):
172 """Override reject to unregister before closing."""
173 logger.info(f"🔍 {self.__class__.__name__}: reject() called")
174 self._unregister_all_form_managers()
175 super().reject()
177 def closeEvent(self, event):
178 """Override closeEvent to unregister before closing."""
179 logger.info(f"🔍 {self.__class__.__name__}: closeEvent() called")
180 self._unregister_all_form_managers()
181 super().closeEvent(event)