diff --git a/Cargo.lock b/Cargo.lock index 76f03c4..e46bb9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -607,6 +607,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + [[package]] name = "castaway" version = "0.2.4" @@ -839,6 +845,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa 1.0.15", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "compact_str" version = "0.9.0" @@ -1193,6 +1213,32 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "futures-core", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -1321,8 +1367,18 @@ version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -1338,13 +1394,37 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.108", +] + [[package]] name = "darling_macro" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core", + "darling_core 0.21.3", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn 2.0.108", ] @@ -2221,7 +2301,7 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" dependencies = [ - "darling", + "darling 0.21.3", "proc-macro2", "quote", "syn 2.0.108", @@ -2369,6 +2449,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -2672,7 +2758,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix", + "rustix 1.1.2", "windows-link 0.2.1", ] @@ -3050,6 +3136,17 @@ dependencies = [ "ahash 0.8.12", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.0" @@ -3058,7 +3155,7 @@ checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -3442,6 +3539,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "infer" version = "0.19.0" @@ -3460,6 +3566,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "inventory" version = "0.3.21" @@ -3763,6 +3882,12 @@ dependencies = [ "x11", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -3909,6 +4034,15 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -4075,7 +4209,7 @@ version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" dependencies = [ - "rustix", + "rustix 1.1.2", ] [[package]] @@ -4154,6 +4288,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -4313,6 +4448,25 @@ dependencies = [ "url", ] +[[package]] +name = "mumble-web2-tui" +version = "0.1.0" +dependencies = [ + "color-eyre", + "crossterm", + "dioxus-core", + "dioxus-signals", + "futures", + "futures-channel", + "generational-box", + "mumble-web2-client", + "mumble-web2-common", + "ratatui", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -5626,6 +5780,27 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.10.0", + "cassowary", + "compact_str 0.8.1", + "crossterm", + "indoc", + "instability", + "itertools 0.13.0", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "raw-window-handle" version = "0.5.2" @@ -6006,6 +6181,19 @@ dependencies = [ "transpose", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.2" @@ -6015,7 +6203,7 @@ dependencies = [ "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.11.0", "windows-sys 0.61.2", ] @@ -6186,7 +6374,7 @@ dependencies = [ "base64", "bytes", "chrono", - "compact_str", + "compact_str 0.9.0", "eyre", "futures-util", "http", @@ -6745,6 +6933,17 @@ dependencies = [ "signal-hook-registry", ] +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.6" @@ -6963,6 +7162,34 @@ dependencies = [ "quote", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.108", +] + [[package]] name = "subsecond" version = "0.7.3" @@ -7157,7 +7384,7 @@ dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix", + "rustix 1.1.2", "windows-sys 0.61.2", ] @@ -7852,6 +8079,29 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -8734,7 +8984,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "gethostname", - "rustix", + "rustix 1.1.2", "x11rb-protocol", ] @@ -8751,7 +9001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6cbb733..ea296cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["client", "common", "gui", "proxy"] +members = ["client", "common", "gui", "proxy", "tui"] [workspace.dependencies] serde = { version = "1.0.214", features = ["derive"] } diff --git a/client/Cargo.toml b/client/Cargo.toml index ae4041e..5a9006d 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -113,6 +113,7 @@ tract-onnx = "=0.12.4" tract-pulse = "=0.12.4" [features] +embed-denoiser = [] web = [ "wasm-bindgen", "wasm-bindgen-futures", diff --git a/client/src/effects.rs b/client/src/effects.rs index 89a7dfe..1af59a6 100644 --- a/client/src/effects.rs +++ b/client/src/effects.rs @@ -1,15 +1,34 @@ use crossbeam::atomic::AtomicCell; use df::tract::{mut_slice_as_arrayviewmut, slice_as_arrayview}; use df::tract::{DfParams, DfTract, RuntimeParams}; -use dioxus_asset_resolver::read_asset_bytes; -use manganis::{asset, Asset}; +use std::borrow::Cow; use std::cell::RefCell; use std::sync::Arc; use tracing::{error, info}; use crate::imp::SpawnHandle; -static DF_MODEL: Asset = asset!("/assets/DeepFilterNet3_ll_onnx.tar.gz"); +#[cfg(not(feature = "embed-denoiser"))] +async fn denoiser_model_bytes() -> color_eyre::Result> { + use color_eyre::eyre::eyre; + use manganis::{asset, Asset}; + + static DF_MODEL: Asset = asset!("/assets/DeepFilterNet3_ll_onnx.tar.gz"); + let bytes = dioxus_asset_resolver::read_asset_bytes(&DF_MODEL.to_string()) + .await + .map_err(|err| eyre!("could not read denoising model: {err}"))?; + Ok(Cow::Owned(bytes)) +} + +#[cfg(feature = "embed-denoiser")] +async fn denoiser_model_bytes() -> color_eyre::Result> { + static DF_MODEL: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/DeepFilterNet3_ll_onnx.tar.gz" + )); + Ok(Cow::Borrowed(DF_MODEL)) +} + // TODO: make this user configurable. static DEFAULT_NOISE_FLOOR: f32 = 0.001; // 200ms hold at 48kHz sample rate @@ -44,12 +63,11 @@ fn with_denoising_model(spawn: &SpawnHandle, func: impl FnOnce(&mut DfTract) let cell = Arc::new(AtomicCell::new(None)); let cell_task = cell.clone(); *state = DenoisingModelState::Downloading(cell); - let model = DF_MODEL.to_string(); spawn.spawn(async move { - let model_bytes = match read_asset_bytes(&model).await { + let model_bytes = match denoiser_model_bytes().await { Ok(b) => b, Err(e) => { - error!("could not read denoising model from \"{model}\": {e:?}"); + error!("{e}"); return; } }; diff --git a/tui/Cargo.toml b/tui/Cargo.toml new file mode 100644 index 0000000..938d1b8 --- /dev/null +++ b/tui/Cargo.toml @@ -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" diff --git a/tui/src/main.rs b/tui/src/main.rs new file mode 100644 index 0000000..74372b0 --- /dev/null +++ b/tui/src/main.rs @@ -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 = 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(()) + }) +}