Files
mumble-web2/tui/src/main.rs
T
sam 0f8f294265
Build Mumble Web 2 / macos_build (push) Successful in 1m2s
Build Mumble Web 2 / windows_build (push) Successful in 3m11s
Build Mumble Web 2 / linux_build (push) Failing after 44s
Build Mumble Web 2 / android_build (push) Successful in 4m42s
working mumble web tui
Assisted-by: Claude:claude-opus-4-7
2026-05-05 00:20:15 -06:00

776 lines
24 KiB
Rust

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<T> = RefCell<T>;
fn new<T: 'static>(value: T) -> Self::Signal<T> {
RefCell::new(value)
}
fn read<T: 'static>(signal: &Self::Signal<T>) -> impl std::ops::Deref<Target = T> {
signal.borrow()
}
fn write<T: 'static>(signal: &Self::Signal<T>) -> impl std::ops::DerefMut<Target = T> {
signal.borrow_mut()
}
}
pub type State = mumble_web2_client::State<RefCellReactivity>;
pub type SharedState = mumble_web2_client::SharedState<RefCellReactivity>;
// ---------------------------------------------------------------------------
// 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<Command>,
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<Command>,
config: ConfigSystem,
overrides: ProxyOverrides,
) -> Self {
let address = config
.config_get::<String>("server_url")
.or_else(|| overrides.proxy_url.clone())
.unwrap_or_default();
let username = config.config_get::<String>("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<ChannelId> = 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<ListItem> = 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<Line> = 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<Line> = 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::<String>("username", &app.username);
if app.overrides.any_server {
app.config.config_set::<String>("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::<bool>("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::<bool>("denoise").unwrap_or(true),
}),
});
let (tx, rx) = mpsc::unbounded::<Command>();
// 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(())
})
}