Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions native-bridge/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion native-bridge/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
46 changes: 17 additions & 29 deletions native-bridge/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand All @@ -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)");
Expand Down
136 changes: 77 additions & 59 deletions native-bridge/src/tui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppEvent>,
Expand All @@ -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
Expand Down
Loading