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

1"""Individual function pane widget with parameter editing.""" 

2 

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 

9 

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 

14 

15 

16class FunctionPaneWidget(Container): 

17 """ 

18 Individual function pane with parameter editing capabilities. 

19 

20 Displays function name, editable parameters, and control buttons. 

21 """ 

22 

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) 

27 

28 def __init__(self, func_item: Tuple[Callable, dict], index: int): 

29 super().__init__() 

30 self.func_item = func_item 

31 self.index = index 

32 

33 # Extract function and kwargs using existing business logic 

34 self.func, self.kwargs = PatternDataManager.extract_func_and_kwargs(func_item) 

35 

36 # Set reactive properties 

37 self.function_callable = self.func 

38 self.kwargs = self.kwargs or {} 

39 

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

45 

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 = {} 

51 

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

59 

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) 

68 

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) 

72 

73 with Horizontal() as button_row: 

74 # Empty space (flex-grows) 

75 yield Static("") 

76 

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) 

85 

86 # Empty space (flex-grows)  

87 yield Static("") 

88 

89 # Reset button (or remove if not needed) 

90 

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

94 

95 def _build_parameter_form(self) -> ComposeResult: 

96 """Generate form widgets using shared ParameterFormManager.""" 

97 if not self.form_manager: 

98 return 

99 

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

105 

106 def on_button_pressed(self, event: Button.Pressed) -> None: 

107 """Handle function pane button presses.""" 

108 button_id = event.button.id 

109 

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) 

124 

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) 

133 

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) 

142 

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) 

153 

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

158 

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

165 

166 self._internal_kwargs[param_name] = value 

167 

168 # Emit parameter changed message 

169 self.post_message(self.ParameterChanged(self.index, param_name, value)) 

170 

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

175 

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

181 

182 def _remove_function(self) -> None: 

183 """Remove this function.""" 

184 # Post message to parent widget 

185 self.post_message(self.RemoveFunction(self.index)) 

186 

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

191 

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

196 

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 

201 

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) 

205 

206 # Update local kwargs and notify parent 

207 self._handle_parameter_change(param_name, default_value) 

208 

209 # Refresh the UI widget to show the reset value 

210 self._refresh_field_widget(param_name, default_value) 

211 

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

216 

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 

227 

228 # Update widget based on type 

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

230 from .shared.enum_radio_set import EnumRadioSet 

231 

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

236 

237 elif isinstance(widget, Checkbox): 

238 # Checkbox widget (bool) - set boolean value 

239 widget.value = bool(value) 

240 

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) 

254 

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 

264 

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 

269 

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

274 

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

280 

281 def _reset_all_parameters(self) -> None: 

282 """Reset all parameters to their default values.""" 

283 if not self.form_manager: 

284 return 

285 

286 # Use form manager to reset all parameters 

287 self.form_manager.reset_all_parameters(self.param_defaults) 

288 

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

292 

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) 

296 

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 

305 

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 

311 

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 

317 

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 

323 

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