diff --git a/native-bridge/Cargo.lock b/native-bridge/Cargo.lock index f2caa652..c13024c7 100644 --- a/native-bridge/Cargo.lock +++ b/native-bridge/Cargo.lock @@ -411,6 +411,7 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags 2.10.0", "crossterm_winapi", + "futures-core", "mio", "parking_lot", "rustix", diff --git a/native-bridge/Cargo.toml b/native-bridge/Cargo.toml index 1a29df9d..d820b3bf 100644 --- a/native-bridge/Cargo.toml +++ b/native-bridge/Cargo.toml @@ -87,7 +87,7 @@ realfft = "3.4" # === TUI Interface === ratatui = "0.29" -crossterm = "0.28" +crossterm = { version = "0.28", features = ["event-stream"] } # Protocol registration (Windows only) [target.'cfg(windows)'.dependencies] diff --git a/native-bridge/src/main.rs b/native-bridge/src/main.rs index b203c7e1..c0fbb15e 100644 --- a/native-bridge/src/main.rs +++ b/native-bridge/src/main.rs @@ -17,6 +17,7 @@ use network::{NetworkConfig, NetworkManager}; use protocol::LaunchParams; use std::sync::Arc; use tokio::sync::{mpsc, Mutex}; +use tokio::task::LocalSet; use tracing::{info, Level}; /// Application state shared across all components @@ -114,9 +115,9 @@ async fn main() -> Result<()> { })); if enable_tui { - // Run TUI mode - // The WebSocket server runs in a native thread with its own runtime - // This avoids Send requirements and allows the TUI to use blocking I/O + // Run TUI mode with LocalSet for non-Send futures + // AppState contains cpal::Stream which is not Send, so we use spawn_local + // The TUI now uses async event handling, so it cooperates with the runtime info!("Starting TUI interface..."); let tui_rx = tui_rx.unwrap(); @@ -139,33 +140,20 @@ async fn main() -> Result<()> { }).await; } - // Spawn WebSocket server in a native thread with its own tokio runtime - // This avoids Send requirements (AppState contains non-Send cpal::Stream) - // and allows the TUI's blocking I/O to not starve the server + // Use LocalSet to run non-Send futures on the current thread + let local = LocalSet::new(); + + // Spawn the WebSocket server as a local task (doesn't require Send) let server_state = state.clone(); - std::thread::Builder::new() - .name("websocket-server".to_string()) - .spawn(move || { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("Failed to create WebSocket server runtime"); - - rt.block_on(async move { - if let Err(e) = protocol::run_server("127.0.0.1:9999", server_state).await { - tracing::error!("WebSocket server error: {}", e); - } - }); - }) - .expect("Failed to spawn WebSocket server thread"); - - // Small delay to ensure server is listening before TUI takes over terminal - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - - // Run TUI on main thread (uses blocking terminal I/O) - if let Err(e) = tui::run(app, tui_rx).await { - tracing::error!("TUI error: {}", e); - } + local.spawn_local(async move { + if let Err(e) = protocol::run_server("127.0.0.1:9999", server_state).await { + tracing::error!("WebSocket server error: {}", e); + } + }); + + // Run the TUI and LocalSet together + // The TUI is now fully async, so it cooperates with spawn_local tasks + local.run_until(tui::run(app, tui_rx)).await?; } else { // Run headless mode info!("Bridge running on ws://localhost:9999 (headless mode)"); diff --git a/native-bridge/src/tui/mod.rs b/native-bridge/src/tui/mod.rs index 9fd26569..e990df2d 100644 --- a/native-bridge/src/tui/mod.rs +++ b/native-bridge/src/tui/mod.rs @@ -9,16 +9,20 @@ pub use app::{ActivePanel, App, AppEvent, LogLevel}; pub use ui::draw; use crossterm::{ - event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers}, + event::{DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEventKind, KeyModifiers}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; +use futures_util::StreamExt; use ratatui::{backend::CrosstermBackend, Terminal}; use std::io; -use std::time::{Duration, Instant}; +use std::time::Duration; use tokio::sync::mpsc; /// Run the TUI application +/// +/// This function uses async event handling to cooperate with the tokio runtime, +/// allowing other tasks (like the WebSocket server) to run without being starved. pub async fn run( mut app: App, mut event_rx: mpsc::Receiver, @@ -33,77 +37,91 @@ pub async fn run( // Clear terminal terminal.clear()?; - let tick_rate = Duration::from_millis(50); // 20 FPS - let mut last_tick = Instant::now(); + // Create async event stream for terminal events + let mut event_stream = EventStream::new(); + + // Tick interval for UI updates (20 FPS) + let mut tick_interval = tokio::time::interval(Duration::from_millis(50)); loop { // Draw UI terminal.draw(|f| draw(f, &app))?; - // Handle events with timeout - let timeout = tick_rate - .checked_sub(last_tick.elapsed()) - .unwrap_or_else(|| Duration::from_secs(0)); - - // Check for crossterm events (keyboard, etc.) - if crossterm::event::poll(timeout)? { - if let Event::Key(key) = event::read()? { - // Only handle key press events, not repeats or releases - // This prevents flickering when holding a key - if key.kind != KeyEventKind::Press { - continue; - } + // Use tokio::select! to handle multiple async event sources + // This allows proper async cooperation - no blocking! + tokio::select! { + // Terminal events (keyboard, mouse, resize) + maybe_event = event_stream.next() => { + match maybe_event { + Some(Ok(Event::Key(key))) => { + // Only handle key press events, not repeats or releases + if key.kind != KeyEventKind::Press { + continue; + } - match (key.code, key.modifiers) { - // Quit on Ctrl+C or Q - (KeyCode::Char('c'), KeyModifiers::CONTROL) | (KeyCode::Char('q'), _) => { - break; - } - // Direct panel switching (no toggling - prevents flicker) - (KeyCode::Char('a'), _) => { - app.set_panel(ActivePanel::Audio); - } - (KeyCode::Char('e'), _) => { - app.set_panel(ActivePanel::Effects); - } - (KeyCode::Char('n'), _) => { - app.set_panel(ActivePanel::Network); - } - (KeyCode::Char('l'), _) => { - app.set_panel(ActivePanel::Logs); + match (key.code, key.modifiers) { + // Quit on Ctrl+C or Q + (KeyCode::Char('c'), KeyModifiers::CONTROL) | (KeyCode::Char('q'), _) => { + break; + } + // Direct panel switching (no toggling - prevents flicker) + (KeyCode::Char('a'), _) => { + app.set_panel(ActivePanel::Audio); + } + (KeyCode::Char('e'), _) => { + app.set_panel(ActivePanel::Effects); + } + (KeyCode::Char('n'), _) => { + app.set_panel(ActivePanel::Network); + } + (KeyCode::Char('l'), _) => { + app.set_panel(ActivePanel::Logs); + } + // Toggle help + (KeyCode::Char('?'), _) | (KeyCode::F(1), _) => { + app.toggle_help(); + } + // Scroll effects list + (KeyCode::Up, _) | (KeyCode::Char('k'), _) => { + app.scroll_effects_up(); + } + (KeyCode::Down, _) | (KeyCode::Char('j'), _) => { + app.scroll_effects_down(); + } + // Tab between panels + (KeyCode::Tab, _) => { + app.next_panel(); + } + (KeyCode::BackTab, _) => { + app.prev_panel(); + } + _ => {} + } } - // Toggle help - (KeyCode::Char('?'), _) | (KeyCode::F(1), _) => { - app.toggle_help(); + Some(Ok(_)) => { + // Other events (mouse, resize) - ignore for now } - // Scroll effects list - (KeyCode::Up, _) | (KeyCode::Char('k'), _) => { - app.scroll_effects_up(); - } - (KeyCode::Down, _) | (KeyCode::Char('j'), _) => { - app.scroll_effects_down(); - } - // Tab between panels - (KeyCode::Tab, _) => { - app.next_panel(); + Some(Err(e)) => { + // Event stream error + tracing::error!("Terminal event error: {}", e); + break; } - (KeyCode::BackTab, _) => { - app.prev_panel(); + None => { + // Stream ended + break; } - _ => {} } } - } - // Process app events from audio/network threads - while let Ok(event) = event_rx.try_recv() { - app.handle_event(event); - } + // App events from audio/network threads + Some(event) = event_rx.recv() => { + app.handle_event(event); + } - // Tick - if last_tick.elapsed() >= tick_rate { - app.on_tick(); - last_tick = Instant::now(); + // Tick for UI updates and level decay + _ = tick_interval.tick() => { + app.on_tick(); + } } // Check if we should quit