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
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 05:57 +0000
1"""Config form widget with reactive properties."""
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
10from .shared.parameter_form_manager import ParameterFormManager
11from .shared.signature_analyzer import SignatureAnalyzer
14class ConfigFormWidget(ScrollableContainer):
15 """Reactive form widget for config editing."""
17 field_values = reactive(dict, recompose=False) # Prevent automatic recomposition during typing
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()
25 # Analyze dataclass using unified parameter analysis
26 param_info = SignatureAnalyzer.analyze(dataclass_type)
28 # Convert to form manager format
29 parameters = {}
30 parameter_types = {}
31 param_defaults = {}
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
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
49 # Initialize field values for reactive updates
50 self.field_values = parameters.copy()
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)
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]")
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)
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
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()
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]
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)
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)
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)
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)
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
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:])
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)
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
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")
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()
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}"
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
192 # Update widget based on type
193 from textual.widgets import Input, Checkbox, RadioSet, Collapsible
194 from .shared.enum_radio_set import EnumRadioSet
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 ""
201 elif isinstance(widget, Checkbox):
202 # Checkbox widget (bool) - set boolean value
203 widget.value = bool(value)
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)
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
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
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 ""
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}")
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()