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

1""" 

2Base Form Dialog for PyQt6 

3 

4Base class for dialogs that use ParameterFormManager to ensure proper cleanup 

5of cross-window placeholder update connections. 

6 

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. 

10 

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. 

14 

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. 

18 

19The default implementation automatically discovers all ParameterFormManager 

20instances in the widget tree, so subclasses don't need to manually track them. 

21 

22Usage: 

23 1. Inherit from BaseFormDialog instead of QDialog 

24 2. That's it! All ParameterFormManager instances are automatically discovered and cleaned up. 

25 

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""" 

33 

34import logging 

35from typing import Optional, Protocol 

36 

37from PyQt6.QtWidgets import QDialog 

38from PyQt6.QtCore import QEvent 

39 

40logger = logging.getLogger(__name__) 

41 

42 

43class HasUnregisterMethod(Protocol): 

44 """Protocol for objects that can be unregistered from cross-window updates.""" 

45 def unregister_from_cross_window_updates(self) -> None: ... 

46 

47 

48class BaseFormDialog(QDialog): 

49 """ 

50 Base class for dialogs that use ParameterFormManager. 

51  

52 Automatically handles unregistration from cross-window updates when the dialog 

53 closes via any method (accept, reject, or closeEvent). 

54  

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 

58  

59 Example: 

60 class MyDialog(BaseFormDialog): 

61 def __init__(self, ...): 

62 super().__init__(...) 

63 self.form_manager = ParameterFormManager(...) 

64  

65 def _get_form_managers(self): 

66 return [self.form_manager] 

67 """ 

68 

69 def __init__(self, parent=None): 

70 super().__init__(parent) 

71 self._unregistered = False # Track if we've already unregistered 

72 

73 def _get_form_managers(self): 

74 """ 

75 Return a list of all ParameterFormManager instances that need to be unregistered. 

76 

77 Default implementation recursively searches the widget tree for all 

78 ParameterFormManager instances. Subclasses can override for custom behavior. 

79 

80 Returns: 

81 List of ParameterFormManager instances 

82 """ 

83 managers = [] 

84 self._collect_form_managers_recursive(self, managers, visited=set()) 

85 return managers 

86 

87 def _collect_form_managers_recursive(self, widget, managers, visited): 

88 """ 

89 Recursively collect all ParameterFormManager instances from widget tree. 

90 

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. 

93 

94 Uses Protocol-based duck typing to check for unregister method, avoiding 

95 hasattr smell for guaranteed attributes while still supporting dynamic discovery. 

96 

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) 

107 

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 

113 

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) 

119 

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 

127 

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) 

140 

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 

146 

147 logger.info(f"🔍 {self.__class__.__name__}: Unregistering all form managers") 

148 

149 managers = self._get_form_managers() 

150 

151 if not managers: 

152 logger.debug(f"🔍 {self.__class__.__name__}: No form managers found to unregister") 

153 return 

154 

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}") 

161 

162 self._unregistered = True 

163 logger.info(f"🔍 {self.__class__.__name__}: All form managers unregistered") 

164 

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() 

170 

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() 

176 

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) 

182