Coverage for openhcs/textual_tui/windows/dual_editor_window.py: 0.0%

245 statements  

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

1"""DualEditor window - enhanced port of dual_editor_pane.py to Textual.""" 

2 

3import logging 

4from typing import Optional, Callable, Union, List, Dict 

5# ModalScreen import removed - using BaseOpenHCSWindow instead 

6from textual.app import ComposeResult 

7from textual.containers import Container, Horizontal, Vertical 

8from textual.message import Message 

9from textual.widgets import Button, Static, TabbedContent, TabPane, Label, Input, Select 

10from textual.reactive import reactive 

11from textual.widgets._tabbed_content import ContentTabs, ContentSwitcher, ContentTab 

12from textual.widgets._tabs import Tab 

13from textual import events # NEW 

14from textual.widgets import Tabs # NEW – for type hint below (optional) 

15from itertools import zip_longest 

16 

17from openhcs.core.steps.function_step import FunctionStep 

18from openhcs.textual_tui.services.pattern_data_manager import PatternDataManager 

19from openhcs.textual_tui.services.function_registry_service import FunctionRegistryService 

20from openhcs.textual_tui.windows.base_window import BaseOpenHCSWindow 

21# Updated import to get the message class as well 

22from openhcs.textual_tui.widgets.step_parameter_editor import ( 

23 StepParameterEditorWidget, 

24 StepParameterEditorWidget) 

25from openhcs.textual_tui.widgets.function_list_editor import FunctionListEditorWidget 

26 

27logger = logging.getLogger(__name__) 

28 

29 

30class ButtonTab(Tab): 

31 """A fake tab that acts like a button.""" 

32 

33 class ButtonClicked(Message): 

34 """A button tab was clicked.""" 

35 def __init__(self, button_id: str) -> None: 

36 self.button_id = button_id 

37 super().__init__() 

38 

39 def __init__(self, label: str, button_id: str, disabled: bool = False): 

40 # Use a unique ID for the button tab 

41 super().__init__(label, id=f"button-{button_id}", disabled=disabled) 

42 self.button_id = button_id 

43 # Add button-like styling 

44 self.add_class("button-tab") 

45 

46 def _on_click(self, event: events.Click) -> None: 

47 """Send the ButtonClicked message and swallow the click.""" 

48 event.stop() # don't let real tab logic run 

49 event.prevent_default() # prevent default tab behavior 

50 self.post_message(self.ButtonClicked(self.button_id)) 

51 

52 

53class TabbedContentWithButtons(TabbedContent): 

54 """Custom TabbedContent that adds Save/Close buttons to the tab bar.""" 

55 

56 def __init__(self, *args, **kwargs): 

57 super().__init__(*args, **kwargs) 

58 self.save_callback = None 

59 self.close_callback = None 

60 

61 def _get_content_switcher(self) -> Optional[ContentSwitcher]: 

62 """Safely get the content switcher.""" 

63 try: 

64 return self.query_one(ContentSwitcher) 

65 except: 

66 return None 

67 

68 def _safe_switch_content(self, content_id: str) -> bool: 

69 """Safely switch content, returning True if successful.""" 

70 switcher = self._get_content_switcher() 

71 if not switcher: 

72 return False 

73 

74 try: 

75 # Check if the content actually exists before switching 

76 switcher.get_child_by_id(content_id) 

77 switcher.current = content_id 

78 return True 

79 except: 

80 # Content doesn't exist (probably a button tab), ignore silently 

81 logger.debug(f"Ignoring switch to non-existent content: {content_id}") 

82 return False 

83 

84 def set_callbacks(self, save_callback: Optional[Callable] = None, close_callback: Optional[Callable] = None): 

85 """Set the callbacks for save and close buttons.""" 

86 self.save_callback = save_callback 

87 self.close_callback = close_callback 

88 

89 def compose(self) -> ComposeResult: 

90 """Compose the tabbed content with button tabs in the tab bar.""" 

91 # Wrap content in a `TabPane` if required (copied from parent) 

92 pane_content = [ 

93 self._set_id( 

94 ( 

95 content 

96 if isinstance(content, TabPane) 

97 else TabPane(title or self.render_str(f"Tab {index}"), content) 

98 ), 

99 self._generate_tab_id(), 

100 ) 

101 for index, (title, content) in enumerate( 

102 zip_longest(self.titles, self._tab_content), 1 

103 ) 

104 ] 

105 

106 # Get a tab for each pane (copied from parent) 

107 tabs = [ 

108 ContentTab( 

109 content._title, 

110 content.id or "", 

111 disabled=content.disabled, 

112 ) 

113 for content in pane_content 

114 ] 

115 

116 # ── tab strip ─────────────────────────────────────────────────── 

117 # 1️⃣ regular content-selecting tabs 

118 # 2️⃣ an elastic spacer tab (width: 1fr in CSS) 

119 # 3️⃣ our two action "button-tabs" 

120 

121 spacer = Tab("", id="spacer_tab", disabled=True) # visual gap 

122 spacer.add_class("spacer-tab") 

123 

124 # Create button tabs with explicit styling to distinguish them 

125 save_button = ButtonTab("Save", "save", disabled=False) # Always enabled 

126 close_button = ButtonTab("Close", "close") 

127 

128 # Add additional classes for safety 

129 save_button.add_class("action-button") 

130 close_button.add_class("action-button") 

131 

132 tabs.extend([ 

133 spacer, 

134 save_button, 

135 close_button, 

136 ]) 

137 

138 # yield the single ContentTabs row (must be an immediate child) 

139 yield ContentTabs(*tabs, 

140 active=self._initial or None, 

141 tabbed_content=self) 

142 

143 # Yield the content switcher and panes (copied from parent) 

144 with ContentSwitcher(initial=self._initial or None): 

145 yield from pane_content 

146 

147 def _on_tabs_tab_activated( 

148 self, event: Tabs.TabActivated 

149 ) -> None: 

150 """Override to safely handle tab activation, including button tabs.""" 

151 # Check if the activated tab is a button tab by ID 

152 if hasattr(event, 'tab') and event.tab.id and event.tab.id.startswith('button-'): 

153 # Don't activate button tabs as content tabs 

154 event.stop() 

155 return 

156 

157 # For regular tabs, use safe switching instead of the default behavior 

158 if hasattr(event, 'tab') and event.tab.id: 

159 content_id = ContentTab.sans_prefix(event.tab.id) 

160 if self._safe_switch_content(content_id): 

161 # Successfully switched, stop the event to prevent default handling 

162 event.stop() 

163 return 

164 

165 # If we get here, let the default behavior handle it 

166 super()._on_tabs_tab_activated(event) 

167 

168 def on_button_tab_button_clicked(self, event: ButtonTab.ButtonClicked) -> None: 

169 """Handle button tab clicks.""" 

170 if event.button_id == "save" and self.save_callback: 

171 self.save_callback() 

172 elif event.button_id == "close" and self.close_callback: 

173 self.close_callback() 

174 

175 def on_resize(self, event) -> None: 

176 """Handle window resize to readjust tab layout.""" 

177 # Refresh the tabs to recalculate their layout 

178 try: 

179 tabs = self.query_one(ContentTabs) 

180 tabs.refresh() 

181 except: 

182 pass # Tabs not ready yet 

183 

184 

185 

186 

187class DualEditorWindow(BaseOpenHCSWindow): 

188 """ 

189 Enhanced modal screen for editing steps and functions. 

190 

191 Ports the complete functionality from dual_editor_pane.py with: 

192 - Change tracking and validation 

193 - Tab switching with proper state management 

194 - Integration with all visual programming services 

195 - Proper error handling and user feedback 

196 """ 

197 

198 DEFAULT_CSS = """ 

199 DualEditorWindow { 

200 width: 80; height: 20; 

201 min-width: 80; min-height: 20; 

202 } 

203 """ 

204 

205 # Reactive state for change tracking 

206 has_changes = reactive(False) 

207 current_tab = reactive("step") 

208 

209 def __init__(self, step_data: Optional[FunctionStep] = None, is_new: bool = False, 

210 on_save_callback: Optional[Callable] = None): 

211 super().__init__( 

212 window_id="dual_editor", 

213 title="Dual Editor", 

214 mode="temporary", 

215 allow_maximize=True 

216 ) 

217 self.step_data = step_data 

218 self.is_new = is_new 

219 self.on_save_callback = on_save_callback 

220 

221 # Initialize services (reuse existing business logic) 

222 self.pattern_manager = PatternDataManager() 

223 self.registry_service = FunctionRegistryService() 

224 

225 # Create working copy of step data 

226 if self.step_data: 

227 self.editing_step = self.pattern_manager.clone_pattern(self.step_data) 

228 else: 

229 # Create new step with empty function list (user adds functions manually) 

230 self.editing_step = FunctionStep( 

231 func=[], # Start with empty function list 

232 name="New Step" 

233 # Let variable_components and group_by use FunctionStep's defaults 

234 ) 

235 

236 # Store original for change detection 

237 self.original_step = self.pattern_manager.clone_pattern(self.editing_step) 

238 

239 # Editor widgets (will be created in compose) 

240 self.step_editor = None 

241 self.func_editor = None 

242 

243 def compose(self) -> ComposeResult: 

244 """Compose the enhanced dual editor modal.""" 

245 # Custom tabbed content with buttons - start directly with tabs 

246 with TabbedContentWithButtons(id="editor_tabs") as tabbed_content: 

247 tabbed_content.set_callbacks( 

248 save_callback=self._handle_save, 

249 close_callback=self._handle_cancel 

250 ) 

251 

252 with TabPane("Step Settings", id="step_tab"): 

253 # Create step editor with correct constructor 

254 self.step_editor = StepParameterEditorWidget(self.editing_step) 

255 yield self.step_editor 

256 

257 with TabPane("Function Pattern", id="func_tab"): 

258 # Create function editor with validated function data and step identifier 

259 func_data = self._validate_function_data(self.editing_step.func) 

260 step_id = getattr(self.editing_step, 'name', 'unknown_step') 

261 self.func_editor = FunctionListEditorWidget(func_data, step_identifier=step_id) 

262 

263 # Initialize step configuration settings in function editor 

264 self.func_editor.current_group_by = self.editing_step.group_by 

265 self.func_editor.current_variable_components = self.editing_step.variable_components or [] 

266 

267 yield self.func_editor 

268 

269 def on_mount(self) -> None: 

270 """Called when the screen is mounted.""" 

271 # Update change tracking to set initial Save button state 

272 self._update_change_tracking() 

273 

274 

275 

276 def on_input_changed(self, event: Input.Changed) -> None: 

277 """Handle input changes from child widgets.""" 

278 logger.debug(f"Input changed: {event}") 

279 

280 # Update change tracking when any input changes 

281 self._update_change_tracking() 

282 self._update_status("Modified step parameters") 

283 

284 def on_select_changed(self, event: Select.Changed) -> None: 

285 """Handle select changes from child widgets.""" 

286 logger.debug(f"Select changed: {event}") 

287 

288 # Update change tracking when any select changes 

289 self._update_change_tracking() 

290 self._update_status("Modified step parameters") 

291 

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

293 """Handle button presses from child widgets.""" 

294 # Note: Save/Close buttons are handled by TabbedContentWithButtons.on_button_tab_button_clicked 

295 # This method only handles buttons from child widgets like StepParameterEditorWidget 

296 logger.debug(f"Child widget button pressed: {event.button.id}") 

297 self._update_change_tracking() 

298 self._update_status("Modified step configuration") 

299 

300 def on_step_parameter_editor_widget_step_parameter_changed( 

301 self, event: StepParameterEditorWidget.StepParameterChanged # Listen for the specific message 

302 ) -> None: 

303 """Handle parameter changes from the step editor widget.""" 

304 logger.debug("Received StepParameterChanged from child StepParameterEditorWidget") 

305 

306 # Sync step configuration settings to function editor for dynamic component selection 

307 self.func_editor.current_group_by = self.editing_step.group_by 

308 self.func_editor.current_variable_components = self.editing_step.variable_components or [] 

309 

310 self._update_change_tracking() 

311 self._update_status("Modified step parameters (via message)") 

312 

313 def on_function_list_editor_widget_function_pattern_changed( 

314 self, event: FunctionListEditorWidget.FunctionPatternChanged 

315 ) -> None: 

316 """Handle function pattern changes from the function editor widget.""" 

317 logger.debug("Received FunctionPatternChanged from child FunctionListEditorWidget") 

318 # Use current_pattern property to get List or Dict format 

319 self.editing_step.func = self.func_editor.current_pattern 

320 self._update_change_tracking() 

321 self._update_status("Modified function pattern") 

322 

323 

324 

325 def _update_change_tracking(self) -> None: 

326 """Update change tracking state.""" 

327 # Compare current editing step with original 

328 has_changes = not self._steps_equal(self.editing_step, self.original_step) 

329 self.has_changes = has_changes 

330 

331 # Always keep save button enabled - user requested this for better UX 

332 # No need to disable save button based on changes 

333 

334 # Update save button state - always enabled 

335 try: 

336 # Find the save button tab by looking for button tabs 

337 # Use try_query to avoid NoMatches exceptions during widget lifecycle 

338 tabs = self.query(ButtonTab) 

339 if tabs: # Check if any tabs were found 

340 for tab in tabs: 

341 if hasattr(tab, 'button_id') and tab.button_id == "save": 

342 tab.disabled = False # Always enabled 

343 logger.debug(f"Save button always enabled (user preference)") 

344 break 

345 else: 

346 logger.debug("No ButtonTab widgets found yet (widget still mounting?)") 

347 except Exception as e: 

348 logger.debug(f"Error updating save button state: {e}") 

349 

350 def _validate_function_data(self, func_data) -> Union[List, callable, None]: 

351 """Validate and normalize function data for FunctionListEditorWidget.""" 

352 if func_data is None: 

353 return None 

354 elif callable(func_data): 

355 return func_data 

356 elif isinstance(func_data, (list, dict)): 

357 return func_data 

358 else: 

359 logger.warning(f"Invalid function data type: {type(func_data)}, using None") 

360 return None 

361 

362 def _steps_equal(self, step1: FunctionStep, step2: FunctionStep) -> bool: 

363 """Compare two FunctionSteps for equality.""" 

364 return ( 

365 step1.name == step2.name and 

366 step1.func == step2.func and 

367 step1.variable_components == step2.variable_components and 

368 step1.group_by == step2.group_by 

369 ) 

370 

371 def _update_status(self, message: str) -> None: 

372 """Update status message - now just logs since we removed the status bar.""" 

373 logger.debug(f"Status: {message}") 

374 

375 def watch_has_changes(self, has_changes: bool) -> None: 

376 """React to changes in has_changes state.""" 

377 # Update window title to show unsaved changes 

378 base_title = "Dual Editor - Edit Step" if not self.is_new else "Dual Editor - New Step" 

379 if has_changes: 

380 self.title = f"{base_title} *" 

381 else: 

382 self.title = base_title 

383 

384 

385 

386 def _handle_save(self) -> None: 

387 """Handle save button with validation and type conversion.""" 

388 # Sync current UI values to editing_step before validation 

389 self._sync_ui_to_editing_step() 

390 

391 # Debug logging to see what's happening with step name 

392 logger.debug(f"Step name validation - editing_step.name: '{self.editing_step.name}', type: {type(self.editing_step.name)}") 

393 

394 # Validate step data 

395 if not self.editing_step.name or not self.editing_step.name.strip(): 

396 self._update_status("Error: Step name cannot be empty") 

397 return 

398 

399 if not self.editing_step.func: 

400 self._update_status("Error: Function pattern cannot be empty") 

401 return 

402 

403 # Validate and convert function parameter types 

404 validation_errors = self._validate_and_convert_function_parameters() 

405 if validation_errors: 

406 # Show error dialog with specific validation errors 

407 from openhcs.textual_tui.app import ErrorDialog 

408 error_message = "Parameter validation failed. Please fix the following issues:" 

409 error_details = "\n".join(f"{error}" for error in validation_errors) 

410 error_dialog = ErrorDialog(error_message, error_details) 

411 self.app.push_screen(error_dialog) 

412 self._update_status("Error: Invalid parameter values") 

413 return 

414 

415 # Save successful 

416 self._update_status("Saved successfully") 

417 if self.on_save_callback: 

418 self.on_save_callback(self.editing_step) 

419 # Window remains open after save (user preference) 

420 

421 def _validate_and_convert_function_parameters(self) -> List[str]: 

422 """ 

423 Validate and convert all function parameters using type hints. 

424 

425 Returns: 

426 List of error messages. Empty list if all parameters are valid. 

427 """ 

428 errors = [] 

429 

430 try: 

431 # Get current function pattern from the editor 

432 current_pattern = self.func_editor.current_pattern 

433 

434 # Handle different pattern types (list or dict) 

435 functions_to_validate = [] 

436 if isinstance(current_pattern, list): 

437 functions_to_validate = current_pattern 

438 elif isinstance(current_pattern, dict): 

439 # Flatten all functions from all channels 

440 for channel_functions in current_pattern.values(): 

441 if isinstance(channel_functions, list): 

442 functions_to_validate.extend(channel_functions) 

443 

444 # Validate each function 

445 for func_index, func_item in enumerate(functions_to_validate): 

446 if isinstance(func_item, tuple) and len(func_item) == 2: 

447 func, kwargs = func_item 

448 

449 # Get expected parameter types from function signature 

450 from openhcs.textual_tui.widgets.shared.signature_analyzer import SignatureAnalyzer 

451 from openhcs.textual_tui.widgets.shared.parameter_form_manager import ParameterFormManager 

452 

453 param_info = SignatureAnalyzer.analyze(func) 

454 

455 # Validate each parameter 

456 for param_name, info in param_info.items(): 

457 if param_name in kwargs: 

458 current_value = kwargs[param_name] 

459 expected_type = info.param_type 

460 

461 # Skip if value is already the correct type (not a string) 

462 if not isinstance(current_value, str): 

463 continue 

464 

465 # Try to convert using the enhanced type converter 

466 try: 

467 converted_value = ParameterFormManager.convert_string_to_type( 

468 current_value, expected_type, strict=True 

469 ) 

470 # Update the kwargs with the converted value 

471 kwargs[param_name] = converted_value 

472 

473 except ValueError as e: 

474 # Collect the error message 

475 func_name = getattr(func, '__name__', str(func)) 

476 errors.append(f"Function '{func_name}', parameter '{param_name}': {str(e)}") 

477 

478 except Exception as e: 

479 # Catch any unexpected errors during validation 

480 errors.append(f"Validation error: {str(e)}") 

481 

482 return errors 

483 

484 def _sync_ui_to_editing_step(self) -> None: 

485 """Sync current UI values to the editing_step object before validation.""" 

486 try: 

487 # Sync step editor values (name, group_by, variable_components) 

488 if self.step_editor: 

489 # The step editor should have already updated editing_step via messages, 

490 # but let's make sure by getting current values 

491 pass # StepParameterEditorWidget updates editing_step directly 

492 

493 # Sync function editor values (func pattern) 

494 if self.func_editor: 

495 self.editing_step.func = self.func_editor.current_pattern 

496 

497 except Exception as e: 

498 # Log but don't fail - validation will catch issues 

499 logger.debug(f"Error syncing UI to editing_step: {e}") 

500 

501 def _handle_cancel(self) -> None: 

502 """Handle cancel button with change confirmation.""" 

503 if self.has_changes: 

504 # TODO: Add confirmation dialog for unsaved changes 

505 self._update_status("Cancelled - changes discarded") 

506 else: 

507 self._update_status("Cancelled") 

508 

509 if self.on_save_callback: 

510 self.on_save_callback(None) 

511 self.close_window()