Coverage for openhcs/textual_tui/app.py: 0.0%

264 statements  

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

1""" 

2OpenHCS Textual TUI Main Application 

3 

4A modern terminal user interface built with Textual framework. 

5This is the main application class that orchestrates the entire TUI. 

6""" 

7 

8import asyncio 

9import logging 

10import traceback 

11from pathlib import Path 

12from typing import Optional 

13 

14from textual.app import App, ComposeResult 

15from textual.reactive import reactive 

16from textual.screen import ModalScreen 

17from textual.containers import Container, Horizontal, VerticalScroll 

18from textual.widgets import Static, Button, TextArea 

19from rich.syntax import Syntax 

20 

21# OpenHCS imports 

22from openhcs.core.config import GlobalPipelineConfig, get_default_global_config 

23from openhcs.core.orchestrator.gpu_scheduler import setup_global_gpu_registry 

24from openhcs.io.base import storage_registry 

25from openhcs.io.filemanager import FileManager 

26 

27# Widget imports (will be created) 

28from .widgets.main_content import MainContent 

29from .widgets.status_bar import StatusBar 

30 

31# Textual-window imports 

32from textual_window import Window, WindowSwitcher, window_manager, TilingLayout 

33from openhcs.textual_tui.widgets.custom_window_bar import CustomWindowBar 

34from openhcs.textual_tui.windows import HelpWindow, ConfigWindow, DualEditorWindow, PipelinePlateWindow 

35from openhcs.textual_tui.windows.base_window import BaseOpenHCSWindow 

36 

37 

38 

39logger = logging.getLogger(__name__) 

40 

41 

42class ErrorDialog(BaseOpenHCSWindow): 

43 """Error dialog with syntax highlighting using textual-window system.""" 

44 

45 def __init__(self, error_message: str, error_details: str = ""): 

46 self.error_message = error_message 

47 self.error_details = error_details 

48 super().__init__( 

49 window_id="error_dialog", 

50 title="🚨 ERROR", 

51 mode="temporary" 

52 ) 

53 

54 def compose(self) -> ComposeResult: 

55 """Compose the error dialog content.""" 

56 # Error message 

57 yield Static(self.error_message, classes="error-message", markup=False) 

58 

59 # Error details with syntax highlighting if available 

60 if self.error_details: 

61 yield TextArea( 

62 text=self.error_details, 

63 language="python", # Python syntax highlighting for tracebacks 

64 theme="monokai", 

65 read_only=True, # Make it read-only but selectable 

66 show_line_numbers=True, 

67 soft_wrap=True, 

68 id="error_content" 

69 ) 

70 

71 # Close button 

72 with Container(classes="dialog-buttons"): 

73 yield Button("Close", id="close", compact=True) 

74 

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

76 """Handle button presses.""" 

77 if event.button.id == "close": 

78 self.close_window() 

79 

80 DEFAULT_CSS = """ 

81 ErrorDialog { 

82 width: auto; 

83 height: auto; 

84 max-width: 120; 

85 max-height: 40; 

86 min-width: 50; 

87 min-height: 15; 

88 } 

89 

90 .error-message { 

91 color: $error; 

92 text-style: bold; 

93 margin-bottom: 1; 

94 text-align: center; 

95 } 

96 

97 #error_content { 

98 height: auto; 

99 width: auto; 

100 margin: 0; 

101 max-height: 30; 

102 min-height: 5; 

103 border: solid $primary; 

104 } 

105 """ 

106 

107 

108class OpenHCSTUIApp(App): 

109 """ 

110 Main OpenHCS Textual TUI Application. 

111 

112 This app provides a complete interface for OpenHCS pipeline management 

113 with proper reactive state management and clean architectural boundaries. 

114 """ 

115 CSS_PATH = "styles.css" 

116 

117 # Blocking window for pseudo-modal behavior 

118 blocking_window = None 

119# CSS = """ 

120# /* General dialog styling */ 

121# .dialog { 

122# background: $surface; 

123# border: tall $primary; 

124# padding: 1 2; 

125# width: auto; 

126# height: auto; 

127# } 

128# 

129# /* SelectionList styling - remove circles, keep highlighting */ 

130# SelectionList > .selection-list--option { 

131# padding-left: 1; 

132# text-style: none; 

133# } 

134# 

135# SelectionList > .selection-list--option-highlighted { 

136# background: $accent; 

137# color: $text; 

138# } 

139# 

140# /* MenuBar */ 

141# MenuBar { 

142# height: 3; 

143# border: solid white; 

144# } 

145# 

146# /* All buttons - uniform height and styling */ 

147# Button { 

148# height: 1; 

149# } 

150# 

151# /* MenuBar buttons - content-based width */ 

152# MenuBar Button { 

153# margin: 0 1; 

154# width: auto; 

155# } 

156# 

157# /* MenuBar title - properly centered */ 

158# MenuBar Static { 

159# text-align: center; 

160# content-align: center middle; 

161# width: 1fr; 

162# text-style: bold; 

163# } 

164# 

165# /* Function list header buttons - specific styling for spacing */ 

166# #function_list_header Button { 

167# margin: 0 1; /* 0 vertical, 1 horizontal margin */ 

168# width: auto; /* Let buttons size to their content */ 

169# } 

170# 

171# /* Main content containers with proper borders and responsive sizing */ 

172# #plate_manager_container { 

173# border: solid white; 

174# width: 1fr; 

175# min-width: 0; 

176# } 

177# 

178# #pipeline_editor_container { 

179# border: solid white; 

180# width: 1fr; 

181# min-width: 0; 

182# } 

183# 

184# /* StatusBar */ 

185# StatusBar { 

186# height: 3; 

187# border: solid white; 

188# } 

189# 

190# /* Button containers - full width */ 

191# #plate_manager_container Horizontal, 

192# #pipeline_editor_container Horizontal { 

193# width: 100%; 

194# } 

195# 

196# /* Content area buttons - responsive width distribution */ 

197# #plate_manager_container Button, 

198# #pipeline_editor_container Button { 

199# width: 1fr; 

200# margin: 0; 

201# min-width: 0; 

202# } 

203# 

204# /* App fills terminal height properly */ 

205# OpenHCSTUIApp { 

206# height: 100vh; 

207# } 

208# 

209# /* Main content layout fills remaining space and is responsive */ 

210# MainContent { 

211# height: 1fr; 

212# width: 100%; 

213# } 

214# 

215# /* Main horizontal layout is responsive */ 

216# MainContent > Horizontal { 

217# width: 100%; 

218# height: 100%; 

219# } 

220# 

221# 

222# /* Content areas adapt to available space */ 

223# ScrollableContainer { 

224# height: 1fr; 

225# } 

226# 

227# /* Static content styling */ 

228# Static { 

229# text-align: center; 

230# } 

231# 

232# 

233# """ 

234 

235 def _generate_bindings(): 

236 from openhcs.core.config import TilingKeybindings 

237 

238 keybindings = TilingKeybindings() 

239 

240 app_bindings = [ 

241 ("ctrl+q", "quit", "Quit"), 

242 ("tab", "focus_next", "Next"), 

243 ("shift+tab", "focus_previous", "Previous"), 

244 ("f1", "toggle_window_switcher", "Switch Windows"), 

245 ] 

246 

247 # Add all tiling keybindings from config 

248 for field_name in keybindings.__dataclass_fields__: 

249 binding = getattr(keybindings, field_name) 

250 app_bindings.append((binding.key, binding.action, binding.description)) 

251 

252 return app_bindings 

253 

254 BINDINGS = _generate_bindings() 

255 

256 # App-level reactive state 

257 current_status = reactive("Ready") 

258 

259 def __init__(self, global_config: Optional[GlobalPipelineConfig] = None): 

260 """ 

261 Initialize the OpenHCS TUI App. 

262  

263 Args: 

264 global_config: Global configuration (uses default if None) 

265 """ 

266 super().__init__() 

267 

268 # Core configuration - minimal TUI responsibility 

269 self.global_config = global_config or get_default_global_config() 

270 

271 # Create shared components (pattern from SimpleOpenHCSTUILauncher) 

272 self.storage_registry = storage_registry 

273 self.filemanager = FileManager(self.storage_registry) 

274 

275 # Toolong compatibility attributes 

276 self.save_merge = None # For Toolong LogView compatibility 

277 self.file_paths = [] # For LogScreen compatibility 

278 self.watcher = None # For LogScreen compatibility 

279 self.merge = False # For LogScreen compatibility 

280 

281 logger.debug("OpenHCSTUIApp initialized with Textual reactive system") 

282 

283 def configure_toolong(self, file_paths: list, watcher, merge: bool = False): 

284 """Configure app for Toolong LogScreen compatibility.""" 

285 self.file_paths = file_paths 

286 self.watcher = watcher 

287 self.merge = merge 

288 logger.debug(f"App configured for Toolong with {len(file_paths)} files, merge={merge}") 

289 

290 def compose(self) -> ComposeResult: 

291 """Compose the main application layout.""" 

292 # TEMPORARILY DISABLED - testing if WindowSwitcher causes hangs 

293 # yield WindowSwitcher() # Invisible Alt-Tab overlay 

294 

295 # Custom WindowBar with no left button 

296 yield CustomWindowBar(dock="bottom", start_open=True) 

297 

298 

299 

300 # Status bar for status messages 

301 yield StatusBar() 

302 

303 # Main content fills the rest 

304 yield MainContent( 

305 filemanager=self.filemanager, 

306 global_config=self.global_config 

307 ) 

308 

309 async def on_mount(self) -> None: 

310 """Called when the app is mounted.""" 

311 logger.info("OpenHCS TUI mounted and ready") 

312 self.current_status = "OpenHCS TUI Ready" 

313 

314 # Mount singleton toolong window BEFORE configuring tiling 

315 # This prevents it from being affected by the tiling system 

316 try: 

317 from openhcs.textual_tui.windows.toolong_window import ToolongWindow 

318 toolong_window = ToolongWindow() 

319 await self.mount(toolong_window) 

320 # Start minimized so it doesn't interfere with main UI 

321 toolong_window.open_state = False 

322 except Exception as e: 

323 logger.error(f"Failed to mount toolong window at startup: {e}") 

324 import traceback 

325 logger.error(traceback.format_exc()) 

326 

327 # Configure default window manager settings from separate TUI config 

328 from openhcs.core.config import TUIConfig 

329 tui_config = TUIConfig() # Use default TUI configuration 

330 window_manager.set_tiling_layout(tui_config.default_tiling_layout) 

331 window_manager.set_window_gap(tui_config.default_window_gap) 

332 

333 # Notify user about tiling mode if enabled in config 

334 if tui_config.enable_startup_notification: 

335 layout_name = tui_config.default_tiling_layout.value.replace('_', ' ').title() 

336 self.notify(f"Window Manager: {layout_name} tiling enabled (gap={tui_config.default_window_gap})", severity="information") 

337 

338 

339 

340 # Status bar will automatically show this log message 

341 # No need to manually update it anymore 

342 

343 # Add our start menu button to the WindowBar using the same pattern as window buttons 

344 logger.debug("🚀 APP MOUNT: About to add start menu button") 

345 try: 

346 await self._add_start_menu_button() 

347 logger.debug("🚀 APP MOUNT: Start menu button added") 

348 except Exception as e: 

349 logger.error(f"🚀 APP MOUNT: Start menu button failed: {e}") 

350 # Continue without start menu button for now 

351 

352 def watch_current_status(self, status: str) -> None: 

353 """Watch for status changes and log them (status bar will show automatically).""" 

354 # Log the status change - status bar will pick it up automatically 

355 logger.info(status) 

356 

357 

358 

359 def action_quit(self) -> None: 

360 """Handle quit action with aggressive cleanup.""" 

361 logger.info("OpenHCS TUI shutting down") 

362 

363 # Force cleanup of background threads before exit 

364 try: 

365 import threading 

366 import time 

367 

368 # Give a moment for normal cleanup 

369 self.exit() 

370 

371 # Schedule aggressive cleanup after a short delay 

372 def force_cleanup(): 

373 time.sleep(0.5) # Give normal exit a chance 

374 active_threads = [t for t in threading.enumerate() if t != threading.current_thread() and t.is_alive()] 

375 if active_threads: 

376 logger.warning(f"Force cleaning up {len(active_threads)} threads on quit") 

377 # Can't set daemon on running threads, just force exit 

378 import os 

379 os._exit(0) 

380 

381 # Start cleanup thread as daemon 

382 cleanup_thread = threading.Thread(target=force_cleanup, daemon=True) 

383 cleanup_thread.start() 

384 

385 except Exception as e: 

386 logger.debug(f"Error in quit cleanup: {e}") 

387 self.exit() 

388 

389 def action_toggle_window_switcher(self): 

390 """Toggle the window switcher.""" 

391 switcher = self.query_one(WindowSwitcher) 

392 switcher.action_toggle() # Correct textual-window API method 

393 

394 # Focus navigation actions - call existing window manager methods 

395 def action_focus_next_window(self) -> None: 

396 """Focus next window.""" 

397 window_manager.focus_next_window() 

398 

399 def action_focus_previous_window(self) -> None: 

400 """Focus previous window.""" 

401 window_manager.focus_previous_window() 

402 

403 # Layout control wrapper methods 

404 def action_set_horizontal_split(self) -> None: 

405 """Set horizontal split tiling.""" 

406 window_manager.set_tiling_layout(TilingLayout.HORIZONTAL_SPLIT) 

407 self.notify("Tiling: Horizontal Split") 

408 

409 def action_set_vertical_split(self) -> None: 

410 """Set vertical split tiling.""" 

411 window_manager.set_tiling_layout(TilingLayout.VERTICAL_SPLIT) 

412 self.notify("Tiling: Vertical Split") 

413 

414 def action_set_grid_layout(self) -> None: 

415 """Set grid layout tiling.""" 

416 window_manager.set_tiling_layout(TilingLayout.GRID) 

417 self.notify("Tiling: Grid Layout") 

418 

419 def action_set_master_detail(self) -> None: 

420 """Set master-detail tiling.""" 

421 window_manager.set_tiling_layout(TilingLayout.MASTER_DETAIL) 

422 self.notify("Tiling: Master Detail") 

423 

424 def action_toggle_floating(self) -> None: 

425 """Toggle floating mode.""" 

426 current = window_manager.tiling_layout 

427 new_layout = TilingLayout.HORIZONTAL_SPLIT if current == TilingLayout.FLOATING else TilingLayout.FLOATING 

428 window_manager.set_tiling_layout(new_layout) 

429 self.notify("Tiling: Toggled Floating") 

430 

431 # Window movement actions - call extracted window manager methods 

432 def action_move_focused_window_prev(self) -> None: 

433 """Move current window to previous position.""" 

434 window_manager.move_focused_window_prev() 

435 

436 def action_move_focused_window_next(self) -> None: 

437 """Move current window to next position.""" 

438 window_manager.move_focused_window_next() 

439 

440 def action_rotate_window_order_left(self) -> None: 

441 """Rotate all windows left.""" 

442 window_manager.rotate_window_order_left() 

443 

444 def action_rotate_window_order_right(self) -> None: 

445 """Rotate all windows right.""" 

446 window_manager.rotate_window_order_right() 

447 

448 # Gap control actions 

449 def action_gap_increase(self) -> None: 

450 """Increase gap between windows.""" 

451 window_manager.adjust_window_gap(1) 

452 

453 def action_gap_decrease(self) -> None: 

454 """Decrease gap between windows.""" 

455 window_manager.adjust_window_gap(-1) 

456 

457 # Bulk operation actions - call existing window manager methods 

458 def action_minimize_all_windows(self) -> None: 

459 """Minimize all windows.""" 

460 window_manager.minimize_all_windows() 

461 self.notify("Minimized all windows") 

462 

463 def action_open_all_windows(self) -> None: 

464 """Open all windows.""" 

465 window_manager.open_all_windows() 

466 self.notify("Opened all windows") 

467 

468 async def _add_start_menu_button(self): 

469 """Add our start menu button to the WindowBar at the leftmost position.""" 

470 try: 

471 logger.debug("🚀 START MENU: Creating start menu button") 

472 from openhcs.textual_tui.widgets.start_menu_button import StartMenuButton 

473 

474 # Get the CustomWindowBar 

475 logger.debug("🚀 START MENU: Getting CustomWindowBar") 

476 window_bar = self.query_one(CustomWindowBar) 

477 logger.debug(f"🚀 START MENU: Found window bar: {window_bar}") 

478 

479 # Check if right button exists (no left button in CustomWindowBar) 

480 logger.debug("🚀 START MENU: Looking for right button") 

481 right_button = window_bar.query_one("#windowbar_button_right") 

482 logger.debug(f"🚀 START MENU: Found right button: {right_button}") 

483 

484 # Add our start menu button at the very beginning (leftmost position) 

485 # Mount before the right button to be at the far left 

486 logger.debug("🚀 START MENU: Creating StartMenuButton") 

487 start_button = StartMenuButton(window_bar=window_bar, id="start_menu_button") 

488 logger.debug(f"🚀 START MENU: Created start button: {start_button}") 

489 

490 logger.debug("🚀 START MENU: Mounting start button") 

491 await window_bar.mount(start_button, before=right_button) 

492 logger.debug("🚀 START MENU: Start menu button mounted successfully") 

493 

494 except Exception as e: 

495 logger.error(f"🚀 START MENU: Failed to add start menu button: {e}") 

496 import traceback 

497 logger.error(f"🚀 START MENU: Traceback: {traceback.format_exc()}") 

498 raise 

499 

500 

501 

502 def open_blocking_window(self, window_class, *args, **kwargs): 

503 """Open a blocking window that disables main UI interactions.""" 

504 if self.blocking_window: 

505 return # Only allow one blocking window at a time 

506 

507 window = window_class(*args, **kwargs) 

508 self.blocking_window = window 

509 self._disable_main_interactions() 

510 self.mount(window) 

511 return window 

512 

513 def _disable_main_interactions(self): 

514 """Disable main UI interactions when modal window is open.""" 

515 # Note: MenuBar removed - interactions now handled by start menu 

516 pass 

517 

518 def _enable_main_interactions(self): 

519 """Re-enable main UI interactions when modal window closes.""" 

520 # Note: MenuBar removed - interactions now handled by start menu 

521 pass 

522 

523 def on_window_closed(self, event: Window.Closed) -> None: 

524 """Handle window closed events from textual-window.""" 

525 # Check if this is our blocking window 

526 # Event has window reference through WindowMessage base 

527 if event.control == self.blocking_window: 

528 self.blocking_window = None 

529 self._enable_main_interactions() 

530 

531 def show_error(self, error_message: str, exception: Exception = None) -> None: 

532 """Show a global error dialog with optional exception details.""" 

533 error_details = "" 

534 if exception: 

535 error_details = f"Exception: {type(exception).__name__}\n" 

536 error_details += f"Message: {str(exception)}\n\n" 

537 error_details += "Traceback:\n" 

538 error_details += traceback.format_exc() 

539 

540 logger.error(f"Global error: {error_message}", exc_info=exception) 

541 

542 # Show error dialog using window system 

543 from textual.css.query import NoMatches 

544 

545 try: 

546 # Check if error dialog already exists 

547 window = self.query_one(ErrorDialog) 

548 # Update existing dialog 

549 window.error_message = error_message 

550 window.error_details = error_details 

551 window.open_state = True 

552 except NoMatches: 

553 # Create new error dialog window 

554 error_dialog = ErrorDialog(error_message, error_details) 

555 self.run_worker(self._mount_error_dialog(error_dialog)) 

556 

557 async def _mount_error_dialog(self, error_dialog): 

558 """Mount error dialog window.""" 

559 await self.mount(error_dialog) 

560 error_dialog.open_state = True 

561 

562 def _handle_exception(self, error: Exception) -> None: 

563 """Handle exceptions with special cases for Toolong internal errors.""" 

564 # Check for known Toolong internal timing errors that are non-fatal 

565 error_str = str(error) 

566 if ( 

567 "No nodes match" in error_str and 

568 ("FindDialog" in error_str or "Label" in error_str) and 

569 ("InfoOverlay" in error_str or "LogView" in error_str) 

570 ): 

571 # This is a known Toolong internal timing issue - log but don't crash 

572 logger.warning(f"Ignoring Toolong internal timing error: {error_str}") 

573 return 

574 

575 # Log the error for debugging 

576 logger.error(f"Unhandled exception in TUI: {str(error)}", exc_info=True) 

577 

578 # Re-raise the exception to let it crash loudly 

579 # This allows the global error handler to catch it 

580 raise error 

581 

582 async def _on_exception(self, error: Exception) -> None: 

583 """Let async exceptions bubble up.""" 

584 self._handle_exception(error) 

585 

586 def _on_unhandled_exception(self, error: Exception) -> None: 

587 """Let unhandled exceptions bubble up.""" 

588 self._handle_exception(error) 

589 

590 async def on_unmount(self) -> None: 

591 """Clean up when app is shutting down with aggressive thread cleanup.""" 

592 logger.info("OpenHCS TUI app unmounting, cleaning up threads...") 

593 

594 # Force cleanup of any ReactiveLogMonitor instances 

595 try: 

596 from openhcs.textual_tui.widgets.reactive_log_monitor import ReactiveLogMonitor 

597 monitors = self.query(ReactiveLogMonitor) 

598 for monitor in monitors: 

599 monitor.stop_monitoring() 

600 except Exception as e: 

601 logger.debug(f"Error cleaning up ReactiveLogMonitors: {e}") 

602 

603 # Force cleanup of any PlateManager workers 

604 try: 

605 from openhcs.textual_tui.widgets.plate_manager import PlateManager 

606 plate_managers = self.query(PlateManager) 

607 for pm in plate_managers: 

608 if hasattr(pm, '_stop_monitoring'): 

609 pm._stop_monitoring() 

610 except Exception as e: 

611 logger.debug(f"Error cleaning up PlateManagers: {e}") 

612 

613 # Aggressive thread cleanup 

614 try: 

615 import threading 

616 import time 

617 time.sleep(0.2) # Give threads a moment to stop 

618 active_threads = [t for t in threading.enumerate() if t != threading.current_thread() and t.is_alive()] 

619 if active_threads: 

620 logger.warning(f"Found {len(active_threads)} active threads during shutdown") 

621 # Can't set daemon on running threads, just log them 

622 except Exception as e: 

623 logger.debug(f"Error checking threads: {e}") 

624 

625 logger.info("OpenHCS TUI app cleanup complete") 

626 

627 

628async def main(): 

629 """ 

630 Main entry point for the OpenHCS Textual TUI. 

631 

632 This function handles initialization and runs the application. 

633 Note: Logging is setup by the main entry point, not here. 

634 """ 

635 logger.info("Starting OpenHCS Textual TUI from app.py...") 

636 

637 try: 

638 # Load configuration with cache support 

639 from openhcs.textual_tui.services.config_cache_adapter import load_cached_global_config_tui as load_cached_global_config 

640 global_config = await load_cached_global_config() 

641 

642 # REMOVED: setup_global_gpu_registry - this is now ONLY done in __main__.py 

643 # to avoid duplicate initialization 

644 logger.info("Using global_config with GPU registry already initialized by __main__.py") 

645 

646 # Create and run the app 

647 app = OpenHCSTUIApp(global_config=global_config) 

648 await app.run_async() 

649 

650 except KeyboardInterrupt: 

651 logger.info("TUI terminated by user") 

652 except Exception as e: 

653 logger.error(f"Unhandled error: {e}", exc_info=True) 

654 finally: 

655 logger.info("OpenHCS Textual TUI finished") 

656 

657 

658if __name__ == "__main__": 

659 asyncio.run(main())