Coverage for openhcs/textual_tui/widgets/function_pane.py: 0.0%
187 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"""Individual function pane widget with parameter editing."""
3from typing import Tuple, Any, Callable, Dict
4from textual.containers import Container, Horizontal, Vertical
5from textual.widgets import Button, Static
6from textual.app import ComposeResult
7from textual.message import Message
8from textual.reactive import reactive
10from openhcs.textual_tui.services.pattern_data_manager import PatternDataManager
11from .shared.parameter_form_manager import ParameterFormManager
12from .shared.signature_analyzer import SignatureAnalyzer
13from .shared.clickable_help_label import ClickableFunctionTitle
16class FunctionPaneWidget(Container):
17 """
18 Individual function pane with parameter editing capabilities.
20 Displays function name, editable parameters, and control buttons.
21 """
23 # Reactive properties for automatic UI updates
24 function_callable = reactive(None, recompose=False)
25 kwargs = reactive(dict, recompose=False) # Prevent recomposition during parameter changes
26 show_parameters = reactive(True, recompose=False)
28 def __init__(self, func_item: Tuple[Callable, dict], index: int):
29 super().__init__()
30 self.func_item = func_item
31 self.index = index
33 # Extract function and kwargs using existing business logic
34 self.func, self.kwargs = PatternDataManager.extract_func_and_kwargs(func_item)
36 # Set reactive properties
37 self.function_callable = self.func
38 self.kwargs = self.kwargs or {}
40 # Create parameter form manager using shared components
41 if self.func:
42 param_info = SignatureAnalyzer.analyze(self.func)
43 parameters = {name: self.kwargs.get(name, info.default_value) for name, info in param_info.items()}
44 parameter_types = {name: info.param_type for name, info in param_info.items()}
46 self.form_manager = ParameterFormManager(parameters, parameter_types, f"func_{index}", param_info)
47 self.param_defaults = {name: info.default_value for name, info in param_info.items()}
48 else:
49 self.form_manager = None
50 self.param_defaults = {}
52 def compose(self) -> ComposeResult:
53 """Compose the function pane with parameter editing."""
54 # Function header with clickable help
55 if self.func:
56 yield ClickableFunctionTitle(self.func, self.index)
57 else:
58 yield Static(f"[bold]{self.index + 1}: Unknown Function[/bold]")
60 # # Control buttons row with Reset All Parameters on the right
61 # with Horizontal() as button_row:
62 # button_row.styles.height = "auto" # Only take height needed for buttons
63 # # Left side: movement and edit buttons
64 # yield Button("↑", id=f"move_up_{self.index}", compact=True)
65 # yield Button("↓", id=f"move_down_{self.index}", compact=True)
66 # yield Button("Add", id=f"add_func_{self.index}", compact=True)
67 # yield Button("Delete", id=f"remove_func_{self.index}", compact=True)
69 # # Right side: Reset All Parameters button
70 # yield Static("", classes="spacer") # Spacer to push button right
71 # yield Button("Reset All Parameters", id=f"reset_all_{self.index}", compact=True)
73 with Horizontal() as button_row:
74 # Empty space (flex-grows)
75 yield Static("")
77 # Centered action button group
78 with Horizontal() as action_group:
79 action_group.styles.width = "auto"
80 yield Button("↑", id=f"move_up_{self.index}", compact=True)
81 yield Button("↓", id=f"move_down_{self.index}", compact=True)
82 yield Button("Add", id=f"add_func_{self.index}", compact=True)
83 yield Button("Delete", id=f"remove_func_{self.index}", compact=True)
84 yield Button("Reset", id=f"reset_all_{self.index}", compact=True)
86 # Empty space (flex-grows)
87 yield Static("")
89 # Reset button (or remove if not needed)
91 # Parameter form (if function exists and parameters shown)
92 if self.func and self.show_parameters and self.form_manager:
93 yield from self._build_parameter_form()
95 def _build_parameter_form(self) -> ComposeResult:
96 """Generate form widgets using shared ParameterFormManager."""
97 if not self.form_manager:
98 return
100 try:
101 # Use shared form manager to build form
102 yield from self.form_manager.build_form()
103 except Exception as e:
104 yield Static(f"[red]Error building parameter form: {e}[/red]")
106 def on_button_pressed(self, event: Button.Pressed) -> None:
107 """Handle function pane button presses."""
108 button_id = event.button.id
110 if button_id.startswith("add_func_"):
111 self._add_function()
112 elif button_id.startswith("remove_func_") or button_id.startswith("delete_"):
113 self._remove_function()
114 elif button_id.startswith("move_up_"):
115 self._move_function(-1)
116 elif button_id.startswith("move_down_"):
117 self._move_function(1)
118 elif button_id.startswith("reset_all_"):
119 self._reset_all_parameters()
120 elif button_id.startswith(f"reset_func_{self.index}_"):
121 # Individual parameter reset
122 param_name = button_id.split("_", 3)[3]
123 self._reset_parameter(param_name)
125 def on_input_changed(self, event) -> None:
126 """Handle input changes from shared components."""
127 if event.input.id.startswith(f"func_{self.index}_"):
128 param_name = event.input.id.split("_", 2)[2]
129 if self.form_manager:
130 self.form_manager.update_parameter(param_name, event.value)
131 final_value = self.form_manager.parameters[param_name]
132 self._handle_parameter_change(param_name, final_value)
134 def on_checkbox_changed(self, event) -> None:
135 """Handle checkbox changes from shared components."""
136 if event.checkbox.id.startswith(f"func_{self.index}_"):
137 param_name = event.checkbox.id.split("_", 2)[2]
138 if self.form_manager:
139 self.form_manager.update_parameter(param_name, event.value)
140 final_value = self.form_manager.parameters[param_name]
141 self._handle_parameter_change(param_name, final_value)
143 def on_radio_set_changed(self, event) -> None:
144 """Handle RadioSet changes from shared components."""
145 if event.radio_set.id.startswith(f"func_{self.index}_"):
146 param_name = event.radio_set.id.split("_", 2)[2]
147 if event.pressed and event.pressed.id:
148 enum_value = event.pressed.id[5:] # Remove "enum_" prefix
149 if self.form_manager:
150 self.form_manager.update_parameter(param_name, enum_value)
151 final_value = self.form_manager.parameters[param_name]
152 self._handle_parameter_change(param_name, final_value)
154 def _change_function(self) -> None:
155 """Change the function (launch function selector)."""
156 # Post message to parent widget to launch function selector
157 self.post_message(self.ChangeFunction(self.index))
159 def _handle_parameter_change(self, param_name: str, value: Any) -> None:
160 """Update kwargs and emit change message."""
161 # Update local kwargs without triggering reactive update
162 # This prevents recomposition and focus loss during typing
163 if not hasattr(self, '_internal_kwargs'):
164 self._internal_kwargs = self.kwargs.copy()
166 self._internal_kwargs[param_name] = value
168 # Emit parameter changed message
169 self.post_message(self.ParameterChanged(self.index, param_name, value))
171 def _sync_kwargs(self) -> None:
172 """Sync internal kwargs to reactive property when safe to do so."""
173 if hasattr(self, '_internal_kwargs'):
174 self.kwargs = self._internal_kwargs.copy()
176 def get_current_kwargs(self) -> dict:
177 """Get current kwargs values (from internal storage if available)."""
178 if hasattr(self, '_internal_kwargs'):
179 return self._internal_kwargs.copy()
180 return self.kwargs.copy()
182 def _remove_function(self) -> None:
183 """Remove this function."""
184 # Post message to parent widget
185 self.post_message(self.RemoveFunction(self.index))
187 def _add_function(self) -> None:
188 """Add a new function after this one."""
189 # Post message to parent widget to add function at index + 1
190 self.post_message(self.AddFunction(self.index + 1))
192 def _move_function(self, direction: int) -> None:
193 """Move function up or down."""
194 # Post message to parent widget
195 self.post_message(self.MoveFunction(self.index, direction))
197 def _reset_parameter(self, param_name: str) -> None:
198 """Reset a specific parameter to its default value."""
199 if not self.form_manager or param_name not in self.param_defaults:
200 return
202 # Use form manager to reset parameter
203 default_value = self.param_defaults[param_name]
204 self.form_manager.reset_parameter(param_name, default_value)
206 # Update local kwargs and notify parent
207 self._handle_parameter_change(param_name, default_value)
209 # Refresh the UI widget to show the reset value
210 self._refresh_field_widget(param_name, default_value)
212 def _refresh_field_widget(self, param_name: str, value: Any) -> None:
213 """Refresh a specific field widget to show the new value."""
214 try:
215 widget_id = f"func_{self.index}_{param_name}"
217 # Try to find the widget
218 try:
219 widget = self.query_one(f"#{widget_id}")
220 except Exception:
221 # Widget not found with exact ID, try searching more broadly
222 widgets = self.query(f"[id$='{param_name}']") # Find widgets ending with param_name
223 if widgets:
224 widget = widgets[0]
225 else:
226 return # Widget not found
228 # Update widget based on type
229 from textual.widgets import Input, Checkbox, RadioSet, Collapsible
230 from .shared.enum_radio_set import EnumRadioSet
232 if isinstance(widget, Input):
233 # Input widget (int, float, str) - set value as string
234 display_value = value.value if hasattr(value, 'value') else value
235 widget.value = str(display_value) if display_value is not None else ""
237 elif isinstance(widget, Checkbox):
238 # Checkbox widget (bool) - set boolean value
239 widget.value = bool(value)
241 elif isinstance(widget, (RadioSet, EnumRadioSet)):
242 # RadioSet/EnumRadioSet widget (Enum, List[Enum]) - find and press the correct radio button
243 # Handle both enum values and string values
244 if hasattr(value, 'value'):
245 # Enum value - use the .value attribute
246 target_value = value.value
247 elif isinstance(value, list) and len(value) > 0:
248 # List[Enum] - get first item's value
249 first_item = value[0]
250 target_value = first_item.value if hasattr(first_item, 'value') else str(first_item)
251 else:
252 # String value or other
253 target_value = str(value)
255 # Find and press the correct radio button
256 target_id = f"enum_{target_value}"
257 for radio in widget.query("RadioButton"):
258 if radio.id == target_id:
259 radio.value = True
260 break
261 else:
262 # Unpress other radio buttons
263 radio.value = False
265 elif isinstance(widget, Collapsible):
266 # Collapsible widget (nested dataclass) - cannot be reset directly
267 # The nested parameters are handled by their own reset buttons
268 pass
270 elif hasattr(widget, 'value'):
271 # Generic widget with value attribute - fallback
272 display_value = value.value if hasattr(value, 'value') else value
273 widget.value = str(display_value) if display_value is not None else ""
275 except Exception as e:
276 # Widget not found or update failed - this is expected for some field types
277 import logging
278 logger = logging.getLogger(__name__)
279 logger.debug(f"Could not refresh widget for field {param_name}: {e}")
281 def _reset_all_parameters(self) -> None:
282 """Reset all parameters to their default values."""
283 if not self.form_manager:
284 return
286 # Use form manager to reset all parameters
287 self.form_manager.reset_all_parameters(self.param_defaults)
289 # Update internal kwargs and notify parent
290 self._internal_kwargs = self.form_manager.get_current_values()
291 self.post_message(self.ParameterChanged(self.index, 'all', self._internal_kwargs))
293 # Refresh all UI widgets to show the reset values
294 for param_name, default_value in self.param_defaults.items():
295 self._refresh_field_widget(param_name, default_value)
297 # Custom messages for parent communication
298 class ParameterChanged(Message):
299 """Message sent when parameter value changes."""
300 def __init__(self, index: int, param_name: str, value: Any):
301 super().__init__()
302 self.index = index
303 self.param_name = param_name
304 self.value = value
306 class ChangeFunction(Message):
307 """Message sent when function should be changed."""
308 def __init__(self, index: int):
309 super().__init__()
310 self.index = index
312 class RemoveFunction(Message):
313 """Message sent when function should be removed."""
314 def __init__(self, index: int):
315 super().__init__()
316 self.index = index
318 class AddFunction(Message):
319 """Message sent when a new function should be added at specified position."""
320 def __init__(self, insert_index: int):
321 super().__init__()
322 self.insert_index = insert_index
324 class MoveFunction(Message):
325 """Message sent when function should be moved up or down."""
326 def __init__(self, index: int, direction: int):
327 super().__init__()
328 self.index = index
329 self.direction = direction