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
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 05:57 +0000
1"""
2OpenHCS Textual TUI Main Application
4A modern terminal user interface built with Textual framework.
5This is the main application class that orchestrates the entire TUI.
6"""
8import asyncio
9import logging
10import traceback
11from pathlib import Path
12from typing import Optional
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
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
27# Widget imports (will be created)
28from .widgets.main_content import MainContent
29from .widgets.status_bar import StatusBar
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
39logger = logging.getLogger(__name__)
42class ErrorDialog(BaseOpenHCSWindow):
43 """Error dialog with syntax highlighting using textual-window system."""
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 )
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)
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 )
71 # Close button
72 with Container(classes="dialog-buttons"):
73 yield Button("Close", id="close", compact=True)
75 def on_button_pressed(self, event: Button.Pressed) -> None:
76 """Handle button presses."""
77 if event.button.id == "close":
78 self.close_window()
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 }
90 .error-message {
91 color: $error;
92 text-style: bold;
93 margin-bottom: 1;
94 text-align: center;
95 }
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 """
108class OpenHCSTUIApp(App):
109 """
110 Main OpenHCS Textual TUI Application.
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"
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# """
235 def _generate_bindings():
236 from openhcs.core.config import TilingKeybindings
238 keybindings = TilingKeybindings()
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 ]
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))
252 return app_bindings
254 BINDINGS = _generate_bindings()
256 # App-level reactive state
257 current_status = reactive("Ready")
259 def __init__(self, global_config: Optional[GlobalPipelineConfig] = None):
260 """
261 Initialize the OpenHCS TUI App.
263 Args:
264 global_config: Global configuration (uses default if None)
265 """
266 super().__init__()
268 # Core configuration - minimal TUI responsibility
269 self.global_config = global_config or get_default_global_config()
271 # Create shared components (pattern from SimpleOpenHCSTUILauncher)
272 self.storage_registry = storage_registry
273 self.filemanager = FileManager(self.storage_registry)
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
281 logger.debug("OpenHCSTUIApp initialized with Textual reactive system")
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}")
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
295 # Custom WindowBar with no left button
296 yield CustomWindowBar(dock="bottom", start_open=True)
300 # Status bar for status messages
301 yield StatusBar()
303 # Main content fills the rest
304 yield MainContent(
305 filemanager=self.filemanager,
306 global_config=self.global_config
307 )
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"
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())
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)
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")
340 # Status bar will automatically show this log message
341 # No need to manually update it anymore
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
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)
359 def action_quit(self) -> None:
360 """Handle quit action with aggressive cleanup."""
361 logger.info("OpenHCS TUI shutting down")
363 # Force cleanup of background threads before exit
364 try:
365 import threading
366 import time
368 # Give a moment for normal cleanup
369 self.exit()
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)
381 # Start cleanup thread as daemon
382 cleanup_thread = threading.Thread(target=force_cleanup, daemon=True)
383 cleanup_thread.start()
385 except Exception as e:
386 logger.debug(f"Error in quit cleanup: {e}")
387 self.exit()
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
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()
399 def action_focus_previous_window(self) -> None:
400 """Focus previous window."""
401 window_manager.focus_previous_window()
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")
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")
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")
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")
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")
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()
436 def action_move_focused_window_next(self) -> None:
437 """Move current window to next position."""
438 window_manager.move_focused_window_next()
440 def action_rotate_window_order_left(self) -> None:
441 """Rotate all windows left."""
442 window_manager.rotate_window_order_left()
444 def action_rotate_window_order_right(self) -> None:
445 """Rotate all windows right."""
446 window_manager.rotate_window_order_right()
448 # Gap control actions
449 def action_gap_increase(self) -> None:
450 """Increase gap between windows."""
451 window_manager.adjust_window_gap(1)
453 def action_gap_decrease(self) -> None:
454 """Decrease gap between windows."""
455 window_manager.adjust_window_gap(-1)
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")
463 def action_open_all_windows(self) -> None:
464 """Open all windows."""
465 window_manager.open_all_windows()
466 self.notify("Opened all windows")
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
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}")
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}")
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}")
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")
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
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
507 window = window_class(*args, **kwargs)
508 self.blocking_window = window
509 self._disable_main_interactions()
510 self.mount(window)
511 return window
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
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
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()
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()
540 logger.error(f"Global error: {error_message}", exc_info=exception)
542 # Show error dialog using window system
543 from textual.css.query import NoMatches
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))
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
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
575 # Log the error for debugging
576 logger.error(f"Unhandled exception in TUI: {str(error)}", exc_info=True)
578 # Re-raise the exception to let it crash loudly
579 # This allows the global error handler to catch it
580 raise error
582 async def _on_exception(self, error: Exception) -> None:
583 """Let async exceptions bubble up."""
584 self._handle_exception(error)
586 def _on_unhandled_exception(self, error: Exception) -> None:
587 """Let unhandled exceptions bubble up."""
588 self._handle_exception(error)
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...")
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}")
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}")
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}")
625 logger.info("OpenHCS TUI app cleanup complete")
628async def main():
629 """
630 Main entry point for the OpenHCS Textual TUI.
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...")
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()
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")
646 # Create and run the app
647 app = OpenHCSTUIApp(global_config=global_config)
648 await app.run_async()
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")
658if __name__ == "__main__":
659 asyncio.run(main())