working mumble web tui
Assisted-by: Claude:claude-opus-4-7
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "mumble-web2-tui"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
mumble-web2-client = { version = "0.1.0", path = "../client", features = ["desktop", "embed-denoiser"] }
|
||||
mumble-web2-common = { version = "0.1.0", path = "../common" }
|
||||
ratatui = "0.29"
|
||||
crossterm = { version = "0.28", features = ["event-stream"] }
|
||||
tokio = { version = "^1.41.1", features = ["rt", "macros"] }
|
||||
futures-channel = "^0.3.30"
|
||||
futures = "^0.3.30"
|
||||
dioxus-signals = "0.7.2"
|
||||
dioxus-core = "0.7.2"
|
||||
generational-box = "0.7.2"
|
||||
color-eyre = "^0.6.3"
|
||||
tracing-subscriber = { version = "^0.3.18", features = ["env-filter"] }
|
||||
tracing = "^0.1.40"
|
||||
+775
@@ -0,0 +1,775 @@
|
||||
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(())
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user