776 lines
24 KiB
Rust
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(())
|
|
})
|
|
}
|