use std::cell::RefCell; use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; use dioxus_core::with_owner; use futures_channel::mpsc; use generational_box::Owner; use mumble_web2_client::{ network_entrypoint, AudioSettings, ChannelId, Command, ConfigSystem, ConfigSystemInterface as _, ConnectionState, Platform, PlatformInterface as _, ServerState, UserState, }; use mumble_web2_common::ProxyOverrides; use ratatui::{ layout::{Constraint, Direction, Layout}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}, Frame, }; pub struct RefCellReactivity; impl mumble_web2_client::Reactivity for RefCellReactivity { type Signal = RefCell; fn new(value: T) -> Self::Signal { RefCell::new(value) } fn read(signal: &Self::Signal) -> impl std::ops::Deref { signal.borrow() } fn write(signal: &Self::Signal) -> impl std::ops::DerefMut { signal.borrow_mut() } } pub type State = mumble_web2_client::State; pub type SharedState = mumble_web2_client::SharedState; // --------------------------------------------------------------------------- // App state (TUI-local, not shared with client) // --------------------------------------------------------------------------- #[derive(Clone, Copy, PartialEq, Eq)] enum Focus { Address, Username, } #[derive(Clone, Copy, PartialEq, Eq)] enum Pane { Channels, Chat, } struct App { state: SharedState, tx: mpsc::UnboundedSender, config: ConfigSystem, overrides: ProxyOverrides, // Login fields address: String, username: String, login_focus: Focus, // Server view active_pane: Pane, chat_input: String, chat_focused: bool, channel_list: Vec<(ChannelId, u16)>, // (id, depth) - flattened tree for navigation channel_cursor: usize, should_quit: bool, } impl App { fn new( state: SharedState, tx: mpsc::UnboundedSender, config: ConfigSystem, overrides: ProxyOverrides, ) -> Self { let address = config .config_get::("server_url") .or_else(|| overrides.proxy_url.clone()) .unwrap_or_default(); let username = config.config_get::("username").unwrap_or_default(); Self { state, tx, config, overrides, address, username, login_focus: Focus::Username, active_pane: Pane::Channels, chat_input: String::new(), chat_focused: false, channel_list: Vec::new(), channel_cursor: 0, should_quit: false, } } fn send(&self, cmd: Command) { let _ = self.tx.unbounded_send(cmd); } fn is_connected(&self) -> bool { matches!(&*self.state.status.borrow(), ConnectionState::Connected) } // Build a flat list of (channel_id, depth) by walking the tree. fn rebuild_channel_list(&mut self) { self.channel_list.clear(); let server = self.state.server.borrow(); // Find root channels (no parent) let mut roots: Vec = server .channels_state .channels .iter() .filter(|(_, ch)| ch.parent.is_none()) .map(|(&id, _)| id) .collect(); roots.sort(); for root in roots { Self::walk_channel(&mut self.channel_list, &server, root, 0); } } fn walk_channel( list: &mut Vec<(ChannelId, u16)>, server: &ServerState, id: ChannelId, depth: u16, ) { list.push((id, depth)); let Some(ch) = server.channels_state.channels.get(&id) else { return; }; for &child in ch.children.iter() { Self::walk_channel(list, server, child, depth + 1); } } } // --------------------------------------------------------------------------- // User icon helpers // --------------------------------------------------------------------------- fn user_indicator(user: &UserState) -> &'static str { if user.deaf || user.self_deaf { "D" } else if user.mute || user.self_mute { "M" } else if user.suppress { "S" } else { " " } } fn user_style(user: &UserState) -> Style { if user.deaf || user.self_deaf { Style::default().fg(Color::Red) } else if user.mute || user.self_mute || user.suppress { Style::default().fg(Color::Yellow) } else { Style::default().fg(Color::Green) } } // --------------------------------------------------------------------------- // Rendering // --------------------------------------------------------------------------- fn draw(frame: &mut Frame, app: &mut App) { if app.is_connected() { draw_server(frame, app); } else { draw_login(frame, app); } } fn draw_login(frame: &mut Frame, app: &App) { let area = frame.area(); // Center a box let vert = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Min(0), Constraint::Length(10), Constraint::Min(0), ]) .split(area); let horiz = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Min(0), Constraint::Length(50), Constraint::Min(0), ]) .split(vert[1]); let box_area = horiz[1]; let block = Block::default() .title(" Mumble Web 2 ") .borders(Borders::ALL); let inner = block.inner(box_area); frame.render_widget(Clear, box_area); frame.render_widget(block, box_area); let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), // address label Constraint::Length(1), // address input Constraint::Length(1), // spacer Constraint::Length(1), // username label Constraint::Length(1), // username input Constraint::Length(1), // spacer Constraint::Length(1), // status / button hint Constraint::Min(0), ]) .split(inner); let status = &*app.state.status.borrow(); // Address if app.overrides.any_server { let label_style = if app.login_focus == Focus::Address { Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD) } else { Style::default() }; frame.render_widget( Paragraph::new("Server Address:").style(label_style), chunks[0], ); let input_style = if app.login_focus == Focus::Address { Style::default().fg(Color::White) } else { Style::default().fg(Color::DarkGray) }; frame.render_widget( Paragraph::new(format!("> {}", app.address)).style(input_style), chunks[1], ); } // Username let label_style = if app.login_focus == Focus::Username { Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD) } else { Style::default() }; frame.render_widget(Paragraph::new("Username:").style(label_style), chunks[3]); let input_style = if app.login_focus == Focus::Username { Style::default().fg(Color::White) } else { Style::default().fg(Color::DarkGray) }; frame.render_widget( Paragraph::new(format!("> {}", app.username)).style(input_style), chunks[4], ); // Status line let status_line = match status { ConnectionState::Disconnected => Line::from(Span::styled( "[Enter] Connect", Style::default().fg(Color::Green), )), ConnectionState::Connecting => Line::from(Span::styled( "Connecting...", Style::default().fg(Color::Yellow), )), ConnectionState::Failed(msg) => Line::from(vec![ Span::styled("Failed: ", Style::default().fg(Color::Red)), Span::raw(msg.clone()), Span::styled(" [Enter] Retry", Style::default().fg(Color::Green)), ]), ConnectionState::Connected => unreachable!(), }; frame.render_widget(Paragraph::new(status_line), chunks[6]); } fn draw_server(frame: &mut Frame, app: &mut App) { app.rebuild_channel_list(); let server = app.state.server.borrow(); let audio = app.state.audio.borrow(); // Main layout: channels left, chat right, controls bottom let vert = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(0), Constraint::Length(3)]) .split(frame.area()); let horiz = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(35), Constraint::Percentage(65)]) .split(vert[0]); // --- Channel tree --- let chan_block = Block::default() .title(" Channels ") .borders(Borders::ALL) .border_style(if app.active_pane == Pane::Channels && !app.chat_focused { Style::default().fg(Color::Cyan) } else { Style::default() }); let mut items: Vec = Vec::new(); for (i, &(ch_id, depth)) in app.channel_list.iter().enumerate() { let Some(ch) = server.channels_state.channels.get(&ch_id) else { continue; }; let indent = " ".repeat(depth as usize); let marker = if ch.children.is_empty() { " " } else { "▾ " }; let is_selected = i == app.channel_cursor; let style = if is_selected { Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }; let prefix = if is_selected { ">" } else { " " }; // Channel name line let mut lines = vec![Line::from(Span::styled( format!("{prefix}{indent}{marker}{}", ch.name), style, ))]; // Users in this channel for &uid in ch.users.iter() { if let Some(user) = server.users.get(&uid) { let is_self = server.session == Some(uid); let ind = user_indicator(user); let u_style = if is_self { user_style(user).add_modifier(Modifier::UNDERLINED) } else { user_style(user) }; lines.push(Line::from(Span::styled( format!(" {indent} [{ind}] {}", user.name), u_style, ))); } } items.push(ListItem::new(lines)); } let channel_list = List::new(items).block(chan_block); frame.render_widget(channel_list, horiz[0]); // --- Chat panel --- let chat_area = horiz[1]; let chat_layout = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(0), Constraint::Length(3)]) .split(chat_area); let chat_block = Block::default() .title(" Chat ") .borders(Borders::ALL) .border_style(if app.active_pane == Pane::Chat && !app.chat_focused { Style::default().fg(Color::Cyan) } else { Style::default() }); let chat_lines: Vec = server .chat .iter() .map(|msg| { let sender = msg .sender .and_then(|uid| server.users.get(&uid)) .map(|u| u.name.as_str()) .unwrap_or("server"); Line::from(vec![ Span::styled( format!("{sender}: "), Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD), ), Span::raw(&msg.raw), ]) }) .collect(); // Show last N lines that fit let chat_inner_height = chat_block.inner(chat_layout[0]).height as usize; let skip = chat_lines.len().saturating_sub(chat_inner_height); let visible_lines: Vec = chat_lines.into_iter().skip(skip).collect(); let chat_widget = Paragraph::new(visible_lines) .block(chat_block) .wrap(Wrap { trim: false }); frame.render_widget(chat_widget, chat_layout[0]); // Chat input let input_block = Block::default() .title(if app.chat_focused { " Input (Esc to cancel) " } else { " [t] to type " }) .borders(Borders::ALL) .border_style(if app.chat_focused { Style::default().fg(Color::Green) } else { Style::default() }); let input_widget = Paragraph::new(app.chat_input.as_str()).block(input_block); frame.render_widget(input_widget, chat_layout[1]); // --- Controls bar --- let this_user = server.this_user(); let (self_mute, mute, suppress, self_deaf, deaf) = this_user .map(|u| (u.self_mute, u.mute, u.suppress, u.self_deaf, u.deaf)) .unwrap_or_default(); let muted = mute || suppress || self_mute; let deafened = deaf || self_deaf; let status_text = match &*app.state.status.borrow() { ConnectionState::Connected => "Connected", ConnectionState::Connecting => "Connecting", ConnectionState::Disconnected => "Disconnected", ConnectionState::Failed(_) => "Failed", }; let current_channel = this_user .and_then(|u| server.channels_state.channels.get(&u.channel)) .map(|ch| ch.name.as_str()) .unwrap_or("?"); let controls = Line::from(vec![ Span::styled( format!(" {status_text} "), Style::default().fg(Color::Green), ), Span::styled( format!("#{current_channel} "), Style::default().fg(Color::White), ), Span::raw("│ "), Span::styled( if muted { "[m]ute ✓ " } else { "[m]ute " }, if muted { Style::default().fg(Color::Yellow) } else { Style::default() }, ), Span::styled( if deafened { "[d]eaf ✓ " } else { "[d]eaf " }, if deafened { Style::default().fg(Color::Red) } else { Style::default() }, ), Span::styled( if audio.denoise { "[n]oise ✓ " } else { "[n]oise " }, if audio.denoise { Style::default().fg(Color::Cyan) } else { Style::default() }, ), Span::raw("│ "), Span::styled("[q]uit", Style::default().fg(Color::DarkGray)), ]); let controls_block = Block::default().borders(Borders::ALL); let controls_widget = Paragraph::new(controls).block(controls_block); frame.render_widget(controls_widget, vert[1]); } // --------------------------------------------------------------------------- // Event handling // --------------------------------------------------------------------------- fn handle_login_key(app: &mut App, code: KeyCode) { match code { KeyCode::Tab | KeyCode::BackTab => { app.login_focus = match app.login_focus { Focus::Address => Focus::Username, Focus::Username => { if app.overrides.any_server { Focus::Address } else { Focus::Username } } }; } KeyCode::Enter => { let status = &*app.state.status.borrow(); if matches!( status, ConnectionState::Disconnected | ConnectionState::Failed(_) ) { app.config.config_set::("username", &app.username); if app.overrides.any_server { app.config.config_set::("server_url", &app.address); } app.send(Command::Connect { address: app.address.clone(), username: app.username.clone(), config: app.overrides.clone(), }); } } KeyCode::Char(c) => { let field = match app.login_focus { Focus::Address => &mut app.address, Focus::Username => &mut app.username, }; field.push(c); } KeyCode::Backspace => { let field = match app.login_focus { Focus::Address => &mut app.address, Focus::Username => &mut app.username, }; field.pop(); } KeyCode::Esc => { app.should_quit = true; } _ => {} } } fn handle_server_key(app: &mut App, code: KeyCode) { if app.chat_focused { match code { KeyCode::Esc => { app.chat_focused = false; } KeyCode::Enter => { if !app.chat_input.is_empty() { let server = app.state.server.borrow(); if let Some(user) = server.this_user() { let channels = vec![user.channel]; let markdown = std::mem::take(&mut app.chat_input); drop(server); app.send(Command::SendChat { markdown, channels }); } } } KeyCode::Char(c) => { app.chat_input.push(c); } KeyCode::Backspace => { app.chat_input.pop(); } _ => {} } return; } match code { KeyCode::Char('q') => { app.send(Command::Disconnect); app.should_quit = true; } KeyCode::Char('m') => { let server = app.state.server.borrow(); if let Some(user) = server.this_user() { if !user.mute && !user.suppress { let new_mute = !user.self_mute; drop(server); app.send(Command::SetMute { mute: new_mute }); } } } KeyCode::Char('d') => { let server = app.state.server.borrow(); if let Some(user) = server.this_user() { if !user.deaf { let new_deaf = !user.self_deaf; drop(server); app.send(Command::SetDeaf { deaf: new_deaf }); } } } KeyCode::Char('n') => { let audio = app.state.audio.borrow().clone(); let new_denoise = !audio.denoise; *app.state.audio.borrow_mut() = AudioSettings { denoise: new_denoise, }; app.send(Command::UpdateAudioSettings(AudioSettings { denoise: new_denoise, })); app.config.config_set::("denoise", &new_denoise); } KeyCode::Char('t') => { app.chat_focused = true; } KeyCode::Tab => { app.active_pane = match app.active_pane { Pane::Channels => Pane::Chat, Pane::Chat => Pane::Channels, }; } KeyCode::Char('j') | KeyCode::Down => { if !app.channel_list.is_empty() { app.channel_cursor = (app.channel_cursor + 1).min(app.channel_list.len() - 1); } } KeyCode::Char('k') | KeyCode::Up => { app.channel_cursor = app.channel_cursor.saturating_sub(1); } KeyCode::Enter => { if let Some(&(ch_id, _)) = app.channel_list.get(app.channel_cursor) { let server = app.state.server.borrow(); if let Some(uid) = server.session { drop(server); app.send(Command::EnterChannel { channel: ch_id, user: uid, }); } } } _ => {} } } fn handle_event(app: &mut App, ev: Event) { let Event::Key(key) = ev else { return }; if key.kind != KeyEventKind::Press { return; } // Ctrl-C always quits if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { app.send(Command::Disconnect); app.should_quit = true; return; } if app.is_connected() { handle_server_key(app, key.code); } else { handle_login_key(app, key.code); } } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- fn init_file_logging() -> color_eyre::Result<()> { use tracing::level_filters::LevelFilter; use tracing_subscriber::filter::EnvFilter; let log_path = std::env::var("MUMBLE_TUI_LOG") .unwrap_or_else(|_| std::env::temp_dir().join("mumble-tui.log").to_string_lossy().into_owned()); let file = std::fs::OpenOptions::new() .create(true) .append(true) .open(&log_path)?; let env_filter = EnvFilter::builder() .with_default_directive(LevelFilter::INFO.into()) .from_env_lossy(); tracing_subscriber::fmt() .with_target(true) .with_level(true) .with_ansi(false) .with_env_filter(env_filter) .with_writer(file) .init(); eprintln!("logging to {log_path}"); Ok(()) } fn main() -> color_eyre::Result<()> { color_eyre::install()?; init_file_logging()?; // Use a single-threaded runtime since dioxus Signals are !Send. let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build()?; let local = tokio::task::LocalSet::new(); local.block_on(&rt, async { let config = ConfigSystem::new()?; let overrides = Platform::load_proxy_overrides().await.unwrap_or_default(); let state = SharedState::new(State { status: RefCell::new(ConnectionState::Disconnected), server: RefCell::new(Default::default()), audio: RefCell::new(AudioSettings { denoise: config.config_get::("denoise").unwrap_or(true), }), }); let (tx, rx) = mpsc::unbounded::(); // Spawn the network loop on the local task set (not Send-bound). let net_state = state.clone(); tokio::task::spawn_local(async move { network_entrypoint(rx, net_state).await; }); // Setup terminal crossterm::terminal::enable_raw_mode()?; let mut stdout = std::io::stdout(); crossterm::execute!( stdout, crossterm::terminal::EnterAlternateScreen, crossterm::event::EnableMouseCapture )?; let backend = ratatui::backend::CrosstermBackend::new(stdout); let mut terminal = ratatui::Terminal::new(backend)?; let mut app = App::new(state, tx, config, overrides); // Event loop loop { terminal.draw(|f| draw(f, &mut app))?; if app.should_quit { break; } // Poll with a short timeout so we re-render when state changes. // Yield to the tokio runtime between polls so network tasks can progress. if crossterm::event::poll(std::time::Duration::from_millis(16))? { let ev = crossterm::event::read()?; handle_event(&mut app, ev); } tokio::task::yield_now().await; } // Restore terminal crossterm::terminal::disable_raw_mode()?; crossterm::execute!( terminal.backend_mut(), crossterm::terminal::LeaveAlternateScreen, crossterm::event::DisableMouseCapture )?; terminal.show_cursor()?; Ok(()) }) }