Coverage for openhcs/textual_tui/widgets/config_form.py: 0.0%

144 statements  

« prev     ^ index     » next       coverage.py v7.10.3, created at 2025-08-14 05:57 +0000

1"""Config form widget with reactive properties.""" 

2 

3from typing import List, Dict, Any, Callable, Optional 

4from textual.containers import Container, Vertical, ScrollableContainer 

5from textual.widgets import Static 

6from textual.app import ComposeResult 

7from textual.reactive import reactive 

8 

9 

10from .shared.parameter_form_manager import ParameterFormManager 

11from .shared.signature_analyzer import SignatureAnalyzer 

12 

13 

14class ConfigFormWidget(ScrollableContainer): 

15 """Reactive form widget for config editing.""" 

16 

17 field_values = reactive(dict, recompose=False) # Prevent automatic recomposition during typing 

18 

19 

20 def __init__(self, dataclass_type: type, instance: Any = None, is_global_config_editing: bool = False, **kwargs): 

21 super().__init__(**kwargs) 

22 self.dataclass_type = dataclass_type 

23 self.instance = instance or dataclass_type() 

24 

25 # Analyze dataclass using unified parameter analysis 

26 param_info = SignatureAnalyzer.analyze(dataclass_type) 

27 

28 # Convert to form manager format 

29 parameters = {} 

30 parameter_types = {} 

31 param_defaults = {} 

32 

33 for name, info in param_info.items(): 

34 # For lazy dataclasses, preserve None values for placeholder behavior 

35 if hasattr(self.instance, '_resolve_field_value'): 

36 # This is a lazy dataclass - use object.__getattribute__ to get stored value 

37 current_value = object.__getattribute__(self.instance, name) if hasattr(self.instance, name) else info.default_value 

38 else: 

39 # Regular dataclass - use normal getattr 

40 current_value = getattr(self.instance, name, info.default_value) 

41 parameters[name] = current_value 

42 parameter_types[name] = info.param_type 

43 param_defaults[name] = info.default_value 

44 

45 # Create shared form manager with parameter info for help functionality 

46 self.form_manager = ParameterFormManager(parameters, parameter_types, "config", param_info, is_global_config_editing=is_global_config_editing) 

47 self.param_defaults = param_defaults 

48 

49 # Initialize field values for reactive updates 

50 self.field_values = parameters.copy() 

51 

52 @classmethod 

53 def from_dataclass(cls, dataclass_type: type, instance: Any = None, is_global_config_editing: bool = False, **kwargs): 

54 """Create ConfigFormWidget from dataclass type and instance.""" 

55 return cls(dataclass_type, instance, is_global_config_editing=is_global_config_editing, **kwargs) 

56 

57 def compose(self) -> ComposeResult: 

58 """Compose the config form using shared form manager.""" 

59 try: 

60 # Use shared form manager to build form 

61 yield from self.form_manager.build_form() 

62 except Exception as e: 

63 yield Static(f"[red]Error building config form: {e}[/red]") 

64 

65 def on_mount(self) -> None: 

66 """Called when the form is mounted - fix scroll position to top.""" 

67 # Force scroll to top after mounting to prevent automatic scrolling to bottom 

68 self.call_after_refresh(self._fix_scroll_position) 

69 

70 def _fix_scroll_position(self) -> None: 

71 """Fix scroll position to top of form.""" 

72 try: 

73 # Force scroll to top (0, 0) 

74 self.scroll_to(0, 0, animate=False) 

75 except Exception: 

76 # If anything goes wrong, just continue 

77 pass 

78 

79 

80 

81 def _on_field_change(self, field_name: str, value: Any) -> None: 

82 """Handle field value changes.""" 

83 if self.form_manager: 

84 self.form_manager.update_parameter(field_name, value) 

85 # Update internal field values without triggering reactive update 

86 # This prevents recomposition and focus loss during typing 

87 if not hasattr(self, '_internal_field_values'): 

88 self._internal_field_values = self.field_values.copy() 

89 

90 # For nested parameters like "path_planning_output_dir_suffix", 

91 # update the top-level "path_planning" parameter 

92 parts = field_name.split('_') 

93 if len(parts) >= 2: 

94 top_level_param = parts[0] 

95 if top_level_param in self.form_manager.parameters: 

96 self._internal_field_values[top_level_param] = self.form_manager.parameters[top_level_param] 

97 else: 

98 # Regular parameter 

99 if field_name in self.form_manager.parameters: 

100 self._internal_field_values[field_name] = self.form_manager.parameters[field_name] 

101 

102 def on_input_changed(self, event) -> None: 

103 """Handle input changes from shared components.""" 

104 if event.input.id.startswith("config_"): 

105 field_name = event.input.id.split("_", 1)[1] 

106 self._on_field_change(field_name, event.value) 

107 

108 def on_checkbox_changed(self, event) -> None: 

109 """Handle checkbox changes from shared components.""" 

110 if event.checkbox.id.startswith("config_"): 

111 field_name = event.checkbox.id.split("_", 1)[1] 

112 self._on_field_change(field_name, event.value) 

113 

114 def on_radio_set_changed(self, event) -> None: 

115 """Handle RadioSet changes from shared components.""" 

116 if event.radio_set.id.startswith("config_"): 

117 field_name = event.radio_set.id.split("_", 1)[1] 

118 if event.pressed and event.pressed.id: 

119 enum_value = event.pressed.id[5:] # Remove "enum_" prefix 

120 self._on_field_change(field_name, enum_value) 

121 

122 def on_button_pressed(self, event) -> None: 

123 """Handle reset button presses from shared components.""" 

124 if event.button.id.startswith("reset_config_"): 

125 field_name = event.button.id.split("_", 2)[2] 

126 self._reset_field(field_name) 

127 

128 def _reset_field(self, field_name: str) -> None: 

129 """Reset a field to its default value.""" 

130 if not self.form_manager: 

131 return 

132 

133 # Handle both top-level and nested parameters 

134 if field_name in self.param_defaults: 

135 # Top-level parameter 

136 default_value = self.param_defaults[field_name] 

137 self.form_manager.reset_parameter(field_name, default_value) 

138 self._on_field_change(field_name, default_value) 

139 # Refresh the UI widget to show the reset value 

140 self._refresh_field_widget(field_name, default_value) 

141 else: 

142 # Check if it's a nested parameter (e.g., "path_planning_output_dir_suffix" or "nested_nested_bool") 

143 parts = field_name.split('_') 

144 if len(parts) >= 2: 

145 # Try to find the nested parameter by checking prefixes from longest to shortest 

146 # This handles cases like "nested_nested_bool" where "nested" is the parent 

147 for i in range(len(parts) - 1, 0, -1): # Start from longest prefix 

148 potential_nested = '_'.join(parts[:i]) 

149 if potential_nested in self.param_defaults: 

150 # Found the nested parent, get the nested field name 

151 nested_field = '_'.join(parts[i:]) 

152 

153 # Get the nested default value from the default dataclass instance 

154 nested_parent_default = self.param_defaults[potential_nested] 

155 if hasattr(nested_parent_default, nested_field): 

156 nested_default = getattr(nested_parent_default, nested_field) 

157 

158 # The form manager's reset_parameter method handles nested parameters automatically 

159 # Just pass the full hierarchical name and it will find the right nested manager 

160 self.form_manager.reset_parameter(field_name, nested_default) 

161 self._on_field_change(field_name, nested_default) 

162 # Refresh the UI widget to show the reset value 

163 self._refresh_field_widget(field_name, nested_default) 

164 return 

165 

166 # If we get here, the parameter wasn't found 

167 import logging 

168 logger = logging.getLogger(__name__) 

169 logger.warning(f"Could not reset field {field_name}: not found in defaults") 

170 

171 def _sync_field_values(self) -> None: 

172 """Sync internal field values to reactive property when safe to do so.""" 

173 if hasattr(self, '_internal_field_values'): 

174 self.field_values = self._internal_field_values.copy() 

175 

176 def _refresh_field_widget(self, field_name: str, value: Any) -> None: 

177 """Refresh a specific field widget to show the new value.""" 

178 try: 

179 widget_id = f"config_{field_name}" 

180 

181 # Try to find the widget 

182 try: 

183 widget = self.query_one(f"#{widget_id}") 

184 except Exception: 

185 # Widget not found with exact ID, try searching more broadly 

186 widgets = self.query(f"[id$='{field_name}']") # Find widgets ending with field_name 

187 if widgets: 

188 widget = widgets[0] 

189 else: 

190 return # Widget not found 

191 

192 # Update widget based on type 

193 from textual.widgets import Input, Checkbox, RadioSet, Collapsible 

194 from .shared.enum_radio_set import EnumRadioSet 

195 

196 if isinstance(widget, Input): 

197 # Input widget (int, float, str) - set value as string 

198 display_value = value.value if hasattr(value, 'value') else value 

199 widget.value = str(display_value) if display_value is not None else "" 

200 

201 elif isinstance(widget, Checkbox): 

202 # Checkbox widget (bool) - set boolean value 

203 widget.value = bool(value) 

204 

205 elif isinstance(widget, (RadioSet, EnumRadioSet)): 

206 # RadioSet/EnumRadioSet widget (Enum, List[Enum]) - find and press the correct radio button 

207 # Handle both enum values and string values 

208 if hasattr(value, 'value'): 

209 # Enum value - use the .value attribute 

210 target_value = value.value 

211 elif isinstance(value, list) and len(value) > 0: 

212 # List[Enum] - get first item's value 

213 first_item = value[0] 

214 target_value = first_item.value if hasattr(first_item, 'value') else str(first_item) 

215 else: 

216 # String value or other 

217 target_value = str(value) 

218 

219 # Find and press the correct radio button 

220 target_id = f"enum_{target_value}" 

221 for radio in widget.query("RadioButton"): 

222 if radio.id == target_id: 

223 radio.value = True 

224 break 

225 else: 

226 # Unpress other radio buttons 

227 radio.value = False 

228 

229 elif isinstance(widget, Collapsible): 

230 # Collapsible widget (nested dataclass) - cannot be reset directly 

231 # The nested parameters are handled by their own reset buttons 

232 pass 

233 

234 elif hasattr(widget, 'value'): 

235 # Generic widget with value attribute - fallback 

236 display_value = value.value if hasattr(value, 'value') else value 

237 widget.value = str(display_value) if display_value is not None else "" 

238 

239 except Exception as e: 

240 # Widget not found or update failed - this is expected for some field types 

241 import logging 

242 logger = logging.getLogger(__name__) 

243 logger.debug(f"Could not refresh widget for field {field_name}: {e}") 

244 

245 def get_config_values(self) -> Dict[str, Any]: 

246 """Get current config values from form manager.""" 

247 if self.form_manager: 

248 return self.form_manager.get_current_values() 

249 else: 

250 # Fallback to internal field values if available, otherwise reactive field_values 

251 if hasattr(self, '_internal_field_values'): 

252 return self._internal_field_values.copy() 

253 return self.field_values.copy()