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

244 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-04 02:09 +0000

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

2 

3import logging 

4from typing import Optional, Callable, Union, List 

5# ModalScreen import removed - using BaseOpenHCSWindow instead 

6from textual.app import ComposeResult 

7from textual.message import Message 

8from textual.widgets import Button, TabbedContent, TabPane, Input, Select 

9from textual.reactive import reactive 

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

11from textual.widgets._tabs import Tab 

12from textual import events # NEW 

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

14from itertools import zip_longest 

15 

16from openhcs.core.steps.function_step import FunctionStep 

17from openhcs.textual_tui.services.pattern_data_manager import PatternDataManager 

18from openhcs.processing.backends.lib_registry.registry_service import RegistryService 

19from openhcs.textual_tui.windows.base_window import BaseOpenHCSWindow 

20# Updated import to get the message class as well 

21from openhcs.textual_tui.widgets.step_parameter_editor import ( 

22 StepParameterEditorWidget) 

23from openhcs.textual_tui.widgets.function_list_editor import FunctionListEditorWidget 

24 

25logger = logging.getLogger(__name__) 

26 

27 

28class ButtonTab(Tab): 

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

30 

31 class ButtonClicked(Message): 

32 """A button tab was clicked.""" 

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

34 self.button_id = button_id 

35 super().__init__() 

36 

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

38 # Use a unique ID for the button tab 

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

40 self.button_id = button_id 

41 # Add button-like styling 

42 self.add_class("button-tab") 

43 

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

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

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

47 event.prevent_default() # prevent default tab behavior 

48 self.post_message(self.ButtonClicked(self.button_id)) 

49 

50 

51class TabbedContentWithButtons(TabbedContent): 

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

53 

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

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

56 self.save_callback = None 

57 self.close_callback = None 

58 

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

60 """Safely get the content switcher.""" 

61 try: 

62 return self.query_one(ContentSwitcher) 

63 except: 

64 return None 

65 

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

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

68 switcher = self._get_content_switcher() 

69 if not switcher: 

70 return False 

71 

72 try: 

73 # Check if the content actually exists before switching 

74 switcher.get_child_by_id(content_id) 

75 switcher.current = content_id 

76 return True 

77 except: 

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

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

80 return False 

81 

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

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

84 self.save_callback = save_callback 

85 self.close_callback = close_callback 

86 

87 def compose(self) -> ComposeResult: 

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

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

90 pane_content = [ 

91 self._set_id( 

92 ( 

93 content 

94 if isinstance(content, TabPane) 

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

96 ), 

97 self._generate_tab_id(), 

98 ) 

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

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

101 ) 

102 ] 

103 

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

105 tabs = [ 

106 ContentTab( 

107 content._title, 

108 content.id or "", 

109 disabled=content.disabled, 

110 ) 

111 for content in pane_content 

112 ] 

113 

114 # ── tab strip ─────────────────────────────────────────────────── 

115 # 1️⃣ regular content-selecting tabs 

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

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

118 

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

120 spacer.add_class("spacer-tab") 

121 

122 # Create button tabs with explicit styling to distinguish them 

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

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

125 

126 # Add additional classes for safety 

127 save_button.add_class("action-button") 

128 close_button.add_class("action-button") 

129 

130 tabs.extend([ 

131 spacer, 

132 save_button, 

133 close_button, 

134 ]) 

135 

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

137 yield ContentTabs(*tabs, 

138 active=self._initial or None, 

139 tabbed_content=self) 

140 

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

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

143 yield from pane_content 

144 

145 def _on_tabs_tab_activated( 

146 self, event: Tabs.TabActivated 

147 ) -> None: 

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

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

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

151 # Don't activate button tabs as content tabs 

152 event.stop() 

153 return 

154 

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

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

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

158 if self._safe_switch_content(content_id): 

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

160 event.stop() 

161 return 

162 

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

164 super()._on_tabs_tab_activated(event) 

165 

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

167 """Handle button tab clicks.""" 

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

169 self.save_callback() 

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

171 self.close_callback() 

172 

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

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

175 # Refresh the tabs to recalculate their layout 

176 try: 

177 tabs = self.query_one(ContentTabs) 

178 tabs.refresh() 

179 except: 

180 pass # Tabs not ready yet 

181 

182 

183 

184 

185class DualEditorWindow(BaseOpenHCSWindow): 

186 """ 

187 Enhanced modal screen for editing steps and functions. 

188 

189 Ports the complete functionality from dual_editor_pane.py with: 

190 - Change tracking and validation 

191 - Tab switching with proper state management 

192 - Integration with all visual programming services 

193 - Proper error handling and user feedback 

194 """ 

195 

196 DEFAULT_CSS = """ 

197 DualEditorWindow { 

198 width: 80; height: 20; 

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

200 } 

201 """ 

202 

203 # Reactive state for change tracking 

204 has_changes = reactive(False) 

205 current_tab = reactive("step") 

206 

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

208 on_save_callback: Optional[Callable] = None): 

209 super().__init__( 

210 window_id="dual_editor", 

211 title="Dual Editor", 

212 mode="temporary", 

213 allow_maximize=True 

214 ) 

215 self.step_data = step_data 

216 self.is_new = is_new 

217 self.on_save_callback = on_save_callback 

218 

219 # Initialize services (reuse existing business logic) 

220 self.pattern_manager = PatternDataManager() 

221 self.registry_service = RegistryService() 

222 

223 # Create working copy of step data 

224 if self.step_data: 

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

226 else: 

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

228 self.editing_step = FunctionStep( 

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

230 name="New Step" 

231 # Let variable_components and group_by use FunctionStep's defaults 

232 ) 

233 

234 # Store original for change detection 

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

236 

237 # Editor widgets (will be created in compose) 

238 self.step_editor = None 

239 self.func_editor = None 

240 

241 def compose(self) -> ComposeResult: 

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

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

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

245 tabbed_content.set_callbacks( 

246 save_callback=self._handle_save, 

247 close_callback=self._handle_cancel 

248 ) 

249 

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

251 # Create step editor with correct constructor 

252 self.step_editor = StepParameterEditorWidget(self.editing_step) 

253 yield self.step_editor 

254 

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

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

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

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

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

260 

261 # Initialize step configuration settings in function editor 

262 self.func_editor.current_group_by = self.editing_step.group_by 

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

264 

265 yield self.func_editor 

266 

267 def on_mount(self) -> None: 

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

269 # Update change tracking to set initial Save button state 

270 self._update_change_tracking() 

271 

272 

273 

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

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

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

277 

278 # Update change tracking when any input changes 

279 self._update_change_tracking() 

280 self._update_status("Modified step parameters") 

281 

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

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

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

285 

286 # Update change tracking when any select changes 

287 self._update_change_tracking() 

288 self._update_status("Modified step parameters") 

289 

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

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

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

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

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

295 self._update_change_tracking() 

296 self._update_status("Modified step configuration") 

297 

298 def on_step_parameter_editor_widget_step_parameter_changed( 

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

300 ) -> None: 

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

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

303 

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

305 self.func_editor.current_group_by = self.editing_step.group_by 

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

307 

308 self._update_change_tracking() 

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

310 

311 def on_function_list_editor_widget_function_pattern_changed( 

312 self, event: FunctionListEditorWidget.FunctionPatternChanged 

313 ) -> None: 

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

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

316 # Use current_pattern property to get List or Dict format 

317 self.editing_step.func = self.func_editor.current_pattern 

318 self._update_change_tracking() 

319 self._update_status("Modified function pattern") 

320 

321 

322 

323 def _update_change_tracking(self) -> None: 

324 """Update change tracking state.""" 

325 # Compare current editing step with original 

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

327 self.has_changes = has_changes 

328 

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

330 # No need to disable save button based on changes 

331 

332 # Update save button state - always enabled 

333 try: 

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

335 # Use try_query to avoid NoMatches exceptions during widget lifecycle 

336 tabs = self.query(ButtonTab) 

337 if tabs: # Check if any tabs were found 

338 for tab in tabs: 

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

340 tab.disabled = False # Always enabled 

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

342 break 

343 else: 

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

345 except Exception as e: 

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

347 

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

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

350 if func_data is None: 

351 return None 

352 elif callable(func_data): 

353 return func_data 

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

355 return func_data 

356 else: 

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

358 return None 

359 

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

361 """Compare two FunctionSteps for equality.""" 

362 return ( 

363 step1.name == step2.name and 

364 step1.func == step2.func and 

365 step1.variable_components == step2.variable_components and 

366 step1.group_by == step2.group_by 

367 ) 

368 

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

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

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

372 

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

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

375 # Update window title to show unsaved changes 

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

377 if has_changes: 

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

379 else: 

380 self.title = base_title 

381 

382 

383 

384 def _handle_save(self) -> None: 

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

386 # Sync current UI values to editing_step before validation 

387 self._sync_ui_to_editing_step() 

388 

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

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

391 

392 # Validate step data 

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

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

395 return 

396 

397 if not self.editing_step.func: 

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

399 return 

400 

401 # Validate and convert function parameter types 

402 validation_errors = self._validate_and_convert_function_parameters() 

403 if validation_errors: 

404 # Show error dialog with specific validation errors 

405 from openhcs.textual_tui.app import ErrorDialog 

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

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

408 error_dialog = ErrorDialog(error_message, error_details) 

409 self.app.push_screen(error_dialog) 

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

411 return 

412 

413 # Save successful 

414 self._update_status("Saved successfully") 

415 if self.on_save_callback: 

416 self.on_save_callback(self.editing_step) 

417 # Window remains open after save (user preference) 

418 

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

420 """ 

421 Validate and convert all function parameters using type hints. 

422 

423 Returns: 

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

425 """ 

426 errors = [] 

427 

428 try: 

429 # Get current function pattern from the editor 

430 current_pattern = self.func_editor.current_pattern 

431 

432 # Handle different pattern types (list or dict) 

433 functions_to_validate = [] 

434 if isinstance(current_pattern, list): 

435 functions_to_validate = current_pattern 

436 elif isinstance(current_pattern, dict): 

437 # Flatten all functions from all channels 

438 for channel_functions in current_pattern.values(): 

439 if isinstance(channel_functions, list): 

440 functions_to_validate.extend(channel_functions) 

441 

442 # Validate each function 

443 for func_index, func_item in enumerate(functions_to_validate): 

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

445 func, kwargs = func_item 

446 

447 # Get expected parameter types from function signature 

448 from openhcs.introspection.signature_analyzer import SignatureAnalyzer 

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

450 

451 param_info = SignatureAnalyzer.analyze(func) 

452 

453 # Validate each parameter 

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

455 if param_name in kwargs: 

456 current_value = kwargs[param_name] 

457 expected_type = info.param_type 

458 

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

460 if not isinstance(current_value, str): 

461 continue 

462 

463 # Try to convert using the enhanced type converter 

464 try: 

465 converted_value = ParameterFormManager.convert_string_to_type( 

466 current_value, expected_type, strict=True 

467 ) 

468 # Update the kwargs with the converted value 

469 kwargs[param_name] = converted_value 

470 

471 except ValueError as e: 

472 # Collect the error message 

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

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

475 

476 except Exception as e: 

477 # Catch any unexpected errors during validation 

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

479 

480 return errors 

481 

482 def _sync_ui_to_editing_step(self) -> None: 

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

484 try: 

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

486 if self.step_editor: 

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

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

489 pass # StepParameterEditorWidget updates editing_step directly 

490 

491 # Sync function editor values (func pattern) 

492 if self.func_editor: 

493 self.editing_step.func = self.func_editor.current_pattern 

494 

495 except Exception as e: 

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

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

498 

499 def _handle_cancel(self) -> None: 

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

501 if self.has_changes: 

502 # TODO: Add confirmation dialog for unsaved changes 

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

504 else: 

505 self._update_status("Cancelled") 

506 

507 if self.on_save_callback: 

508 self.on_save_callback(None) 

509 self.close_window()