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

259 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-04 02:09 +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 typing import Optional 

12 

13from textual.app import App, ComposeResult 

14from textual.reactive import reactive 

15from textual.containers import Container 

16from textual.widgets import Static, Button, TextArea 

17 

18# OpenHCS imports 

19from openhcs.core.config import GlobalPipelineConfig 

20from openhcs.io.base import storage_registry 

21from openhcs.io.filemanager import FileManager 

22 

23# Widget imports (will be created) 

24from .widgets.main_content import MainContent 

25from .widgets.status_bar import StatusBar 

26 

27# Textual-window imports 

28from textual_window import Window, WindowSwitcher, window_manager, TilingLayout 

29from openhcs.textual_tui.widgets.custom_window_bar import CustomWindowBar 

30from openhcs.textual_tui.windows.base_window import BaseOpenHCSWindow 

31 

32 

33 

34logger = logging.getLogger(__name__) 

35 

36 

37class ErrorDialog(BaseOpenHCSWindow): 

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

39 

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

41 self.error_message = error_message 

42 self.error_details = error_details 

43 super().__init__( 

44 window_id="error_dialog", 

45 title="🚨 ERROR", 

46 mode="temporary" 

47 ) 

48 

49 def compose(self) -> ComposeResult: 

50 """Compose the error dialog content.""" 

51 # Error message 

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

53 

54 # Error details with syntax highlighting if available 

55 if self.error_details: 

56 yield TextArea( 

57 text=self.error_details, 

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

59 theme="monokai", 

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

61 show_line_numbers=True, 

62 soft_wrap=True, 

63 id="error_content" 

64 ) 

65 

66 # Close button 

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

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

69 

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

71 """Handle button presses.""" 

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

73 self.close_window() 

74 

75 DEFAULT_CSS = """ 

76 ErrorDialog { 

77 width: auto; 

78 height: auto; 

79 max-width: 120; 

80 max-height: 40; 

81 min-width: 50; 

82 min-height: 15; 

83 } 

84 

85 .error-message { 

86 color: $error; 

87 text-style: bold; 

88 margin-bottom: 1; 

89 text-align: center; 

90 } 

91 

92 #error_content { 

93 height: auto; 

94 width: auto; 

95 margin: 0; 

96 max-height: 30; 

97 min-height: 5; 

98 border: solid $primary; 

99 } 

100 """ 

101 

102 

103class OpenHCSTUIApp(App): 

104 """ 

105 Main OpenHCS Textual TUI Application. 

106 

107 This app provides a complete interface for OpenHCS pipeline management 

108 with proper reactive state management and clean architectural boundaries. 

109 """ 

110 CSS_PATH = "styles.css" 

111 

112 # Blocking window for pseudo-modal behavior 

113 blocking_window = None 

114# CSS = """ 

115# /* General dialog styling */ 

116# .dialog { 

117# background: $surface; 

118# border: tall $primary; 

119# padding: 1 2; 

120# width: auto; 

121# height: auto; 

122# } 

123# 

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

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

126# padding-left: 1; 

127# text-style: none; 

128# } 

129# 

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

131# background: $accent; 

132# color: $text; 

133# } 

134# 

135# /* MenuBar */ 

136# MenuBar { 

137# height: 3; 

138# border: solid white; 

139# } 

140# 

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

142# Button { 

143# height: 1; 

144# } 

145# 

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

147# MenuBar Button { 

148# margin: 0 1; 

149# width: auto; 

150# } 

151# 

152# /* MenuBar title - properly centered */ 

153# MenuBar Static { 

154# text-align: center; 

155# content-align: center middle; 

156# width: 1fr; 

157# text-style: bold; 

158# } 

159# 

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

161# #function_list_header Button { 

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

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

164# } 

165# 

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

167# #plate_manager_container { 

168# border: solid white; 

169# width: 1fr; 

170# min-width: 0; 

171# } 

172# 

173# #pipeline_editor_container { 

174# border: solid white; 

175# width: 1fr; 

176# min-width: 0; 

177# } 

178# 

179# /* StatusBar */ 

180# StatusBar { 

181# height: 3; 

182# border: solid white; 

183# } 

184# 

185# /* Button containers - full width */ 

186# #plate_manager_container Horizontal, 

187# #pipeline_editor_container Horizontal { 

188# width: 100%; 

189# } 

190# 

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

192# #plate_manager_container Button, 

193# #pipeline_editor_container Button { 

194# width: 1fr; 

195# margin: 0; 

196# min-width: 0; 

197# } 

198# 

199# /* App fills terminal height properly */ 

200# OpenHCSTUIApp { 

201# height: 100vh; 

202# } 

203# 

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

205# MainContent { 

206# height: 1fr; 

207# width: 100%; 

208# } 

209# 

210# /* Main horizontal layout is responsive */ 

211# MainContent > Horizontal { 

212# width: 100%; 

213# height: 100%; 

214# } 

215# 

216# 

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

218# ScrollableContainer { 

219# height: 1fr; 

220# } 

221# 

222# /* Static content styling */ 

223# Static { 

224# text-align: center; 

225# } 

226# 

227# 

228# """ 

229 

230 def _generate_bindings(): 

231 from openhcs.core.config import TilingKeybindings 

232 

233 keybindings = TilingKeybindings() 

234 

235 app_bindings = [ 

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

237 ("tab", "focus_next", "Next"), 

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

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

240 ] 

241 

242 # Add all tiling keybindings from config 

243 for field_name in keybindings.__dataclass_fields__: 

244 binding = getattr(keybindings, field_name) 

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

246 

247 return app_bindings 

248 

249 BINDINGS = _generate_bindings() 

250 

251 # App-level reactive state 

252 current_status = reactive("Ready") 

253 

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

255 """ 

256 Initialize the OpenHCS TUI App. 

257  

258 Args: 

259 global_config: Global configuration (uses default if None) 

260 """ 

261 super().__init__() 

262 

263 # Core configuration - minimal TUI responsibility 

264 self.global_config = global_config or GlobalPipelineConfig() 

265 

266 # Create shared components (pattern from SimpleOpenHCSTUILauncher) 

267 self.storage_registry = storage_registry 

268 self.filemanager = FileManager(self.storage_registry) 

269 

270 # Toolong compatibility attributes 

271 self.save_merge = None # For Toolong LogView compatibility 

272 self.file_paths = [] # For LogScreen compatibility 

273 self.watcher = None # For LogScreen compatibility 

274 self.merge = False # For LogScreen compatibility 

275 

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

277 

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

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

280 self.file_paths = file_paths 

281 self.watcher = watcher 

282 self.merge = merge 

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

284 

285 def compose(self) -> ComposeResult: 

286 """Compose the main application layout.""" 

287 # TEMPORARILY DISABLED - testing if WindowSwitcher causes hangs 

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

289 

290 # Custom WindowBar with no left button 

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

292 

293 

294 

295 # Status bar for status messages 

296 yield StatusBar() 

297 

298 # Main content fills the rest 

299 yield MainContent( 

300 filemanager=self.filemanager, 

301 global_config=self.global_config 

302 ) 

303 

304 async def on_mount(self) -> None: 

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

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

307 self.current_status = "OpenHCS TUI Ready" 

308 

309 # Mount singleton toolong window BEFORE configuring tiling 

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

311 try: 

312 from openhcs.textual_tui.windows.toolong_window import ToolongWindow 

313 toolong_window = ToolongWindow() 

314 await self.mount(toolong_window) 

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

316 toolong_window.open_state = False 

317 except Exception as e: 

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

319 import traceback 

320 logger.error(traceback.format_exc()) 

321 

322 # Configure default window manager settings from separate TUI config 

323 from openhcs.textual_tui.config import TUIConfig 

324 tui_config = TUIConfig() # Use default TUI configuration 

325 window_manager.set_tiling_layout(tui_config.default_tiling_layout) 

326 window_manager.set_window_gap(tui_config.default_window_gap) 

327 

328 # Notify user about tiling mode if enabled in config 

329 if tui_config.enable_startup_notification: 

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

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

332 

333 

334 

335 # Status bar will automatically show this log message 

336 # No need to manually update it anymore 

337 

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

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

340 try: 

341 await self._add_start_menu_button() 

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

343 except Exception as e: 

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

345 # Continue without start menu button for now 

346 

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

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

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

350 logger.info(status) 

351 

352 

353 

354 def action_quit(self) -> None: 

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

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

357 

358 # Force cleanup of background threads before exit 

359 try: 

360 import threading 

361 import time 

362 

363 # Give a moment for normal cleanup 

364 self.exit() 

365 

366 # Schedule aggressive cleanup after a short delay 

367 def force_cleanup(): 

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

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

370 if active_threads: 

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

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

373 import os 

374 os._exit(0) 

375 

376 # Start cleanup thread as daemon 

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

378 cleanup_thread.start() 

379 

380 except Exception as e: 

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

382 self.exit() 

383 

384 def action_toggle_window_switcher(self): 

385 """Toggle the window switcher.""" 

386 switcher = self.query_one(WindowSwitcher) 

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

388 

389 # Focus navigation actions - call existing window manager methods 

390 def action_focus_next_window(self) -> None: 

391 """Focus next window.""" 

392 window_manager.focus_next_window() 

393 

394 def action_focus_previous_window(self) -> None: 

395 """Focus previous window.""" 

396 window_manager.focus_previous_window() 

397 

398 # Layout control wrapper methods 

399 def action_set_horizontal_split(self) -> None: 

400 """Set horizontal split tiling.""" 

401 window_manager.set_tiling_layout(TilingLayout.HORIZONTAL_SPLIT) 

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

403 

404 def action_set_vertical_split(self) -> None: 

405 """Set vertical split tiling.""" 

406 window_manager.set_tiling_layout(TilingLayout.VERTICAL_SPLIT) 

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

408 

409 def action_set_grid_layout(self) -> None: 

410 """Set grid layout tiling.""" 

411 window_manager.set_tiling_layout(TilingLayout.GRID) 

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

413 

414 def action_set_master_detail(self) -> None: 

415 """Set master-detail tiling.""" 

416 window_manager.set_tiling_layout(TilingLayout.MASTER_DETAIL) 

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

418 

419 def action_toggle_floating(self) -> None: 

420 """Toggle floating mode.""" 

421 current = window_manager.tiling_layout 

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

423 window_manager.set_tiling_layout(new_layout) 

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

425 

426 # Window movement actions - call extracted window manager methods 

427 def action_move_focused_window_prev(self) -> None: 

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

429 window_manager.move_focused_window_prev() 

430 

431 def action_move_focused_window_next(self) -> None: 

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

433 window_manager.move_focused_window_next() 

434 

435 def action_rotate_window_order_left(self) -> None: 

436 """Rotate all windows left.""" 

437 window_manager.rotate_window_order_left() 

438 

439 def action_rotate_window_order_right(self) -> None: 

440 """Rotate all windows right.""" 

441 window_manager.rotate_window_order_right() 

442 

443 # Gap control actions 

444 def action_gap_increase(self) -> None: 

445 """Increase gap between windows.""" 

446 window_manager.adjust_window_gap(1) 

447 

448 def action_gap_decrease(self) -> None: 

449 """Decrease gap between windows.""" 

450 window_manager.adjust_window_gap(-1) 

451 

452 # Bulk operation actions - call existing window manager methods 

453 def action_minimize_all_windows(self) -> None: 

454 """Minimize all windows.""" 

455 window_manager.minimize_all_windows() 

456 self.notify("Minimized all windows") 

457 

458 def action_open_all_windows(self) -> None: 

459 """Open all windows.""" 

460 window_manager.open_all_windows() 

461 self.notify("Opened all windows") 

462 

463 async def _add_start_menu_button(self): 

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

465 try: 

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

467 from openhcs.textual_tui.widgets.start_menu_button import StartMenuButton 

468 

469 # Get the CustomWindowBar 

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

471 window_bar = self.query_one(CustomWindowBar) 

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

473 

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

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

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

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

478 

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

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

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

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

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

484 

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

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

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

488 

489 except Exception as e: 

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

491 import traceback 

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

493 raise 

494 

495 

496 

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

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

499 if self.blocking_window: 

500 return # Only allow one blocking window at a time 

501 

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

503 self.blocking_window = window 

504 self._disable_main_interactions() 

505 self.mount(window) 

506 return window 

507 

508 def _disable_main_interactions(self): 

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

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

511 pass 

512 

513 def _enable_main_interactions(self): 

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

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

516 pass 

517 

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

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

520 # Check if this is our blocking window 

521 # Event has window reference through WindowMessage base 

522 if event.control == self.blocking_window: 

523 self.blocking_window = None 

524 self._enable_main_interactions() 

525 

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

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

528 error_details = "" 

529 if exception: 

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

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

532 error_details += "Traceback:\n" 

533 error_details += traceback.format_exc() 

534 

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

536 

537 # Show error dialog using window system 

538 from textual.css.query import NoMatches 

539 

540 try: 

541 # Check if error dialog already exists 

542 window = self.query_one(ErrorDialog) 

543 # Update existing dialog 

544 window.error_message = error_message 

545 window.error_details = error_details 

546 window.open_state = True 

547 except NoMatches: 

548 # Create new error dialog window 

549 error_dialog = ErrorDialog(error_message, error_details) 

550 self.run_worker(self._mount_error_dialog(error_dialog)) 

551 

552 async def _mount_error_dialog(self, error_dialog): 

553 """Mount error dialog window.""" 

554 await self.mount(error_dialog) 

555 error_dialog.open_state = True 

556 

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

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

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

560 error_str = str(error) 

561 if ( 

562 "No nodes match" in error_str and 

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

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

565 ): 

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

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

568 return 

569 

570 # Log the error for debugging 

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

572 

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

574 # This allows the global error handler to catch it 

575 raise error 

576 

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

578 """Let async exceptions bubble up.""" 

579 self._handle_exception(error) 

580 

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

582 """Let unhandled exceptions bubble up.""" 

583 self._handle_exception(error) 

584 

585 async def on_unmount(self) -> None: 

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

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

588 

589 # Force cleanup of any ReactiveLogMonitor instances 

590 try: 

591 from openhcs.textual_tui.widgets.reactive_log_monitor import ReactiveLogMonitor 

592 monitors = self.query(ReactiveLogMonitor) 

593 for monitor in monitors: 

594 monitor.stop_monitoring() 

595 except Exception as e: 

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

597 

598 # Force cleanup of any PlateManager workers 

599 try: 

600 from openhcs.textual_tui.widgets.plate_manager import PlateManager 

601 plate_managers = self.query(PlateManager) 

602 for pm in plate_managers: 

603 if hasattr(pm, '_stop_monitoring'): 

604 pm._stop_monitoring() 

605 except Exception as e: 

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

607 

608 # Aggressive thread cleanup 

609 try: 

610 import threading 

611 import time 

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

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

614 if active_threads: 

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

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

617 except Exception as e: 

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

619 

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

621 

622 

623async def main(): 

624 """ 

625 Main entry point for the OpenHCS Textual TUI. 

626 

627 This function handles initialization and runs the application. 

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

629 """ 

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

631 

632 try: 

633 # Load configuration with cache support 

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

635 global_config = await load_cached_global_config() 

636 

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

638 # to avoid duplicate initialization 

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

640 

641 # Create and run the app 

642 app = OpenHCSTUIApp(global_config=global_config) 

643 await app.run_async() 

644 

645 except KeyboardInterrupt: 

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

647 except Exception as e: 

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

649 finally: 

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

651 

652 

653if __name__ == "__main__": 

654 asyncio.run(main())