diff --git a/src/iced-app/src/main.rs b/src/iced-app/src/main.rs index 93b8057..6bdc296 100644 --- a/src/iced-app/src/main.rs +++ b/src/iced-app/src/main.rs @@ -8,27 +8,27 @@ use std::collections::HashSet; use std::sync::Arc; use tokio::sync::{mpsc, Mutex}; -use tsclientlib::sync::{SyncConnection, SyncConnectionHandle}; -use tsclientlib::{ClientId, Connection, DisconnectOptions, Identity, MessageTarget}; use tsclientlib::events::{Event, PropertyId}; use tsclientlib::prelude::*; +use tsclientlib::sync::{SyncConnection, SyncConnectionHandle}; +use tsclientlib::{ClientId, Connection, DisconnectOptions, Identity, MessageTarget}; -mod theme; +mod audio; mod icons; mod identity; +mod noise_cancel; mod persistence; mod runtime; +mod theme; mod types; mod view; -mod audio; -mod noise_cancel; +use crate::persistence::{load_bookmarks, load_settings, persist_bookmarks, persist_settings}; use crate::runtime::{run_connection, start_transmission, stop_transmission}; use crate::types::{ AppSettings, BookmarkInfo, ChannelEntry, ChatMessage, ClientEntry, LocalClientAudio, Message, - Page, TsEvent, + Page, SettingsSection, TsEvent, }; -use crate::persistence::{load_bookmarks, load_settings, persist_bookmarks, persist_settings}; fn main() -> iced::Result { iced::application("ReTeamSpeak", App::update, App::view) @@ -84,6 +84,7 @@ struct App { afk: bool, selected_output_device: String, selected_input_device: String, + settings_section: SettingsSection, } impl App { @@ -147,6 +148,7 @@ impl App { afk: false, selected_output_device, selected_input_device, + settings_section: SettingsSection::Audio, }; (app, Task::none()) @@ -297,11 +299,9 @@ impl App { async move { let (event_tx, event_rx) = mpsc::channel(100); *event_rx_store.lock().await = Some(event_rx); - let mut builder = Connection::build(format!( - "{}:{}", - bookmark.address, bookmark.port - )) - .name(nickname); + let mut builder = + Connection::build(format!("{}:{}", bookmark.address, bookmark.port)) + .name(nickname); if let Some(pwd) = password { builder = builder.password(pwd); @@ -388,7 +388,11 @@ impl App { let selected_device = self.selected_output_device.clone(); tokio::spawn(async move { let mut playback = audio.lock().await; - let device = if selected_device.is_empty() { None } else { Some(selected_device.as_str()) }; + let device = if selected_device.is_empty() { + None + } else { + Some(selected_device.as_str()) + }; if let Err(e) = playback.start(device) { tracing::warn!("Audio playback failed to start: {e}"); } @@ -442,10 +446,13 @@ impl App { Task::none() } Message::ToggleLocalClientMute(client_id) => { - let entry = self.local_client_audio.entry(client_id).or_insert(LocalClientAudio { - muted: false, - volume: 1.0, - }); + let entry = self + .local_client_audio + .entry(client_id) + .or_insert(LocalClientAudio { + muted: false, + volume: 1.0, + }); entry.muted = !entry.muted; let audio = self.audio.clone(); let muted = entry.muted; @@ -455,10 +462,13 @@ impl App { Task::none() } Message::IncreaseClientVolume(client_id) => { - let entry = self.local_client_audio.entry(client_id).or_insert(LocalClientAudio { - muted: false, - volume: 1.0, - }); + let entry = self + .local_client_audio + .entry(client_id) + .or_insert(LocalClientAudio { + muted: false, + volume: 1.0, + }); entry.volume = (entry.volume + 0.1).min(2.0); let audio = self.audio.clone(); let volume = entry.volume; @@ -468,10 +478,13 @@ impl App { Task::none() } Message::DecreaseClientVolume(client_id) => { - let entry = self.local_client_audio.entry(client_id).or_insert(LocalClientAudio { - muted: false, - volume: 1.0, - }); + let entry = self + .local_client_audio + .entry(client_id) + .or_insert(LocalClientAudio { + muted: false, + volume: 1.0, + }); entry.volume = (entry.volume - 0.1).max(0.0); let audio = self.audio.clone(); let volume = entry.volume; @@ -519,9 +532,16 @@ impl App { ) } Message::PttPressed => { - if !self.ptt_active && self.connected && self.talk_mode == audio::TalkMode::PushToTalk { + if !self.ptt_active + && self.connected + && self.talk_mode == audio::TalkMode::PushToTalk + { self.ptt_active = true; - let device = if self.selected_input_device.is_empty() { None } else { Some(self.selected_input_device.clone()) }; + let device = if self.selected_input_device.is_empty() { + None + } else { + Some(self.selected_input_device.clone()) + }; start_transmission( self.mic.clone(), self.handle.clone(), @@ -551,9 +571,16 @@ impl App { Task::none() } Message::StartContinuous => { - if !self.continuous_active && self.connected && self.talk_mode == audio::TalkMode::Continuous { + if !self.continuous_active + && self.connected + && self.talk_mode == audio::TalkMode::Continuous + { self.continuous_active = true; - let device = if self.selected_input_device.is_empty() { None } else { Some(self.selected_input_device.clone()) }; + let device = if self.selected_input_device.is_empty() { + None + } else { + Some(self.selected_input_device.clone()) + }; start_transmission( self.mic.clone(), self.handle.clone(), @@ -643,8 +670,7 @@ impl App { .with_connection(move |con| -> Result<(), String> { let state = con.get_state().map_err(|e| e.to_string())?; if state.clients.contains_key(&state.own_client) { - let cmd = state.client_update() - .set_input_muted(muted); + let cmd = state.client_update().set_input_muted(muted); cmd.send(con).map_err(|e| e.to_string())?; } Ok(()) @@ -667,8 +693,7 @@ impl App { .with_connection(move |con| -> Result<(), String> { let state = con.get_state().map_err(|e| e.to_string())?; if state.clients.contains_key(&state.own_client) { - let cmd = state.client_update() - .set_output_muted(muted); + let cmd = state.client_update().set_output_muted(muted); cmd.send(con).map_err(|e| e.to_string())?; } Ok(()) @@ -691,7 +716,8 @@ impl App { .with_connection(move |con| -> Result<(), String> { let state = con.get_state().map_err(|e| e.to_string())?; if state.clients.contains_key(&state.own_client) { - let cmd = state.client_update() + let cmd = state + .client_update() .set_output_hardware_enabled(!muted); cmd.send(con).map_err(|e| e.to_string())?; } @@ -716,11 +742,13 @@ impl App { let state = con.get_state().map_err(|e| e.to_string())?; if state.clients.contains_key(&state.own_client) { let cmd = if afk { - state.client_update() + state + .client_update() .set_input_muted(true) .set_away(Some("AFK")) } else { - state.client_update() + state + .client_update() .set_input_muted(false) .set_away(Some("")) }; @@ -789,6 +817,10 @@ impl App { self.save_settings(); Task::none() } + Message::SelectSettingsSection(section) => { + self.settings_section = section; + Task::none() + } Message::SetAppearance(mode) => { self.appearance_mode = mode; self.save_settings(); @@ -829,9 +861,7 @@ impl App { _ => {} }, Event::Message { - invoker, - message, - .. + invoker, message, .. } => { self.messages.push(ChatMessage { invoker_name: invoker.name.clone(), @@ -856,25 +886,35 @@ impl App { state.server.version.clone(), state.server.max_clients, state.own_client, - state.channels.values().map(|ch| ChannelEntry { - id: ch.id, - name: ch.name.clone(), - parent: ch.parent, - order: ch.order, - }).collect::>(), - state.clients.values().map(|c| ClientEntry { - id: c.id, - name: c.name.clone(), - channel: c.channel, - input_muted: c.input_muted, - output_muted: c.output_muted, - }).collect::>(), + state + .channels + .values() + .map(|ch| ChannelEntry { + id: ch.id, + name: ch.name.clone(), + parent: ch.parent, + order: ch.order, + }) + .collect::>(), + state + .clients + .values() + .map(|c| ClientEntry { + id: c.id, + name: c.name.clone(), + channel: c.channel, + input_muted: c.input_muted, + output_muted: c.output_muted, + }) + .collect::>(), )); } None }) .await; - if let Ok(Some((name, platform, version, max_clients, own_id, channels, clients))) = result { + if let Ok(Some((name, platform, version, max_clients, own_id, channels, clients))) = + result + { self.server_name = name; self.server_platform = platform; self.server_version = version; @@ -896,13 +936,19 @@ impl App { let result = handle .with_connection(|con| { if let Ok(state) = con.get_state() { - return Some(state.clients.values().map(|c| ClientEntry { - id: c.id, - name: c.name.clone(), - channel: c.channel, - input_muted: c.input_muted, - output_muted: c.output_muted, - }).collect::>()); + return Some( + state + .clients + .values() + .map(|c| ClientEntry { + id: c.id, + name: c.name.clone(), + channel: c.channel, + input_muted: c.input_muted, + output_muted: c.output_muted, + }) + .collect::>(), + ); } None }) @@ -923,12 +969,18 @@ impl App { let result = handle .with_connection(|con| { if let Ok(state) = con.get_state() { - return Some(state.channels.values().map(|ch| ChannelEntry { - id: ch.id, - name: ch.name.clone(), - parent: ch.parent, - order: ch.order, - }).collect::>()); + return Some( + state + .channels + .values() + .map(|ch| ChannelEntry { + id: ch.id, + name: ch.name.clone(), + parent: ch.parent, + order: ch.order, + }) + .collect::>(), + ); } None }) @@ -975,7 +1027,8 @@ impl App { }; match event { Some(event) => { - let is_disconnect = matches!(event, TsEvent::Disconnected | TsEvent::Error(_)); + let is_disconnect = + matches!(event, TsEvent::Disconnected | TsEvent::Error(_)); if sender.send(Message::TsEvent(event)).await.is_err() { break; } @@ -1028,10 +1081,12 @@ impl App { let sidebar = self.view_sidebar(); let content = self.view_content(); - row![sidebar, container(content).style(theme::main_panel_container)] - .width(Length::Fill) - .height(Length::Fill) - .into() + row![ + sidebar, + container(content).style(theme::main_panel_container) + ] + .width(Length::Fill) + .height(Length::Fill) + .into() } - } diff --git a/src/iced-app/src/theme.rs b/src/iced-app/src/theme.rs index fd01d42..455311f 100644 --- a/src/iced-app/src/theme.rs +++ b/src/iced-app/src/theme.rs @@ -16,24 +16,104 @@ impl AppearanceMode { } } -const LIGHT_BG: Color = Color { r: 245.0 / 255.0, g: 245.0 / 255.0, b: 247.0 / 255.0, a: 1.0 }; -const LIGHT_SURFACE: Color = Color { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }; -const LIGHT_ELEVATED: Color = Color { r: 251.0 / 255.0, g: 251.0 / 255.0, b: 253.0 / 255.0, a: 1.0 }; -const LIGHT_TEXT: Color = Color { r: 29.0 / 255.0, g: 29.0 / 255.0, b: 31.0 / 255.0, a: 1.0 }; -const LIGHT_TEXT_SECONDARY: Color = Color { r: 110.0 / 255.0, g: 110.0 / 255.0, b: 115.0 / 255.0, a: 1.0 }; -const LIGHT_BORDER: Color = Color { r: 210.0 / 255.0, g: 210.0 / 255.0, b: 215.0 / 255.0, a: 1.0 }; -const LIGHT_ACCENT: Color = Color { r: 0.0, g: 122.0 / 255.0, b: 1.0, a: 1.0 }; +const LIGHT_BG: Color = Color { + r: 245.0 / 255.0, + g: 245.0 / 255.0, + b: 247.0 / 255.0, + a: 1.0, +}; +const LIGHT_SURFACE: Color = Color { + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, +}; +const LIGHT_ELEVATED: Color = Color { + r: 251.0 / 255.0, + g: 251.0 / 255.0, + b: 253.0 / 255.0, + a: 1.0, +}; +const LIGHT_TEXT: Color = Color { + r: 29.0 / 255.0, + g: 29.0 / 255.0, + b: 31.0 / 255.0, + a: 1.0, +}; +const LIGHT_TEXT_SECONDARY: Color = Color { + r: 110.0 / 255.0, + g: 110.0 / 255.0, + b: 115.0 / 255.0, + a: 1.0, +}; +const LIGHT_BORDER: Color = Color { + r: 210.0 / 255.0, + g: 210.0 / 255.0, + b: 215.0 / 255.0, + a: 1.0, +}; +const LIGHT_ACCENT: Color = Color { + r: 0.0, + g: 122.0 / 255.0, + b: 1.0, + a: 1.0, +}; -const DARK_BG: Color = Color { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }; -const DARK_SURFACE: Color = Color { r: 28.0 / 255.0, g: 28.0 / 255.0, b: 30.0 / 255.0, a: 1.0 }; -const DARK_ELEVATED: Color = Color { r: 44.0 / 255.0, g: 44.0 / 255.0, b: 46.0 / 255.0, a: 1.0 }; -const DARK_TEXT: Color = Color { r: 245.0 / 255.0, g: 245.0 / 255.0, b: 247.0 / 255.0, a: 1.0 }; -const DARK_TEXT_SECONDARY: Color = Color { r: 174.0 / 255.0, g: 174.0 / 255.0, b: 178.0 / 255.0, a: 1.0 }; -const DARK_BORDER: Color = Color { r: 56.0 / 255.0, g: 56.0 / 255.0, b: 58.0 / 255.0, a: 1.0 }; -const DARK_ACCENT: Color = Color { r: 10.0 / 255.0, g: 132.0 / 255.0, b: 1.0, a: 1.0 }; +const DARK_BG: Color = Color { + r: 11.0 / 255.0, + g: 11.0 / 255.0, + b: 13.0 / 255.0, + a: 1.0, +}; +const DARK_SURFACE: Color = Color { + r: 28.0 / 255.0, + g: 28.0 / 255.0, + b: 31.0 / 255.0, + a: 1.0, +}; +const DARK_ELEVATED: Color = Color { + r: 22.0 / 255.0, + g: 22.0 / 255.0, + b: 24.0 / 255.0, + a: 1.0, +}; +const DARK_TEXT: Color = Color { + r: 245.0 / 255.0, + g: 245.0 / 255.0, + b: 247.0 / 255.0, + a: 1.0, +}; +const DARK_TEXT_SECONDARY: Color = Color { + r: 161.0 / 255.0, + g: 161.0 / 255.0, + b: 170.0 / 255.0, + a: 1.0, +}; +const DARK_BORDER: Color = Color { + r: 1.0, + g: 1.0, + b: 1.0, + a: 0.08, +}; +const DARK_ACCENT: Color = Color { + r: 10.0 / 255.0, + g: 132.0 / 255.0, + b: 1.0, + a: 1.0, +}; -const SUCCESS: Color = Color { r: 52.0 / 255.0, g: 199.0 / 255.0, b: 89.0 / 255.0, a: 1.0 }; -const DANGER: Color = Color { r: 1.0, g: 69.0 / 255.0, b: 58.0 / 255.0, a: 1.0 }; +const SUCCESS: Color = Color { + r: 52.0 / 255.0, + g: 199.0 / 255.0, + b: 89.0 / 255.0, + a: 1.0, +}; +const DANGER: Color = Color { + r: 1.0, + g: 69.0 / 255.0, + b: 58.0 / 255.0, + a: 1.0, +}; pub fn light_theme() -> Theme { Theme::custom( @@ -66,15 +146,27 @@ fn is_dark(theme: &Theme) -> bool { } fn surface(theme: &Theme) -> Color { - if is_dark(theme) { DARK_SURFACE } else { LIGHT_SURFACE } + if is_dark(theme) { + DARK_SURFACE + } else { + LIGHT_SURFACE + } } fn elevated(theme: &Theme) -> Color { - if is_dark(theme) { DARK_ELEVATED } else { LIGHT_ELEVATED } + if is_dark(theme) { + DARK_ELEVATED + } else { + LIGHT_ELEVATED + } } fn text_secondary(theme: &Theme) -> Color { - if is_dark(theme) { DARK_TEXT_SECONDARY } else { LIGHT_TEXT_SECONDARY } + if is_dark(theme) { + DARK_TEXT_SECONDARY + } else { + LIGHT_TEXT_SECONDARY + } } pub fn icon_color(theme: &Theme) -> Color { @@ -86,7 +178,11 @@ pub fn nav_icon_color(theme: &Theme) -> Color { } fn border(theme: &Theme) -> Color { - if is_dark(theme) { DARK_BORDER } else { LIGHT_BORDER } + if is_dark(theme) { + DARK_BORDER + } else { + LIGHT_BORDER + } } fn accent(theme: &Theme) -> Color { @@ -95,12 +191,15 @@ fn accent(theme: &Theme) -> Color { fn accent_hover(theme: &Theme) -> Color { let primary = accent(theme); - Color { a: primary.a, ..Color::from_rgba( - (primary.r + 0.05).min(1.0), - (primary.g + 0.05).min(1.0), - (primary.b + 0.05).min(1.0), - 1.0, - ) } + Color { + a: primary.a, + ..Color::from_rgba( + (primary.r + 0.05).min(1.0), + (primary.g + 0.05).min(1.0), + (primary.b + 0.05).min(1.0), + 1.0, + ) + } } pub fn sidebar_container(theme: &Theme) -> container::Style { @@ -108,7 +207,7 @@ pub fn sidebar_container(theme: &Theme) -> container::Style { background: Some(Background::Color(elevated(theme))), border: Border { color: border(theme), - width: 0.0, + width: if is_dark(theme) { 1.0 } else { 0.0 }, radius: 0.0.into(), }, ..container::Style::default() @@ -213,7 +312,11 @@ pub fn input_style(theme: &Theme, status: text_input::Status) -> text_input::Sty background: Background::Color(elevated(theme)), border: Border { color: base, - width: if matches!(status, text_input::Status::Focused) { 1.5 } else { 1.0 }, + width: if matches!(status, text_input::Status::Focused) { + 1.5 + } else { + 1.0 + }, radius: 12.0.into(), }, icon: text_secondary(theme), @@ -237,7 +340,7 @@ pub fn primary_button(theme: &Theme, status: button::Status) -> button::Style { border: Border { color: Color::TRANSPARENT, width: 0.0, - radius: 12.0.into(), + radius: 999.0.into(), }, shadow: Shadow { color: Color::from_rgba(0.0, 0.0, 0.0, if is_dark(theme) { 0.20 } else { 0.10 }), @@ -260,7 +363,7 @@ pub fn secondary_button(theme: &Theme, status: button::Status) -> button::Style border: Border { color: border(theme), width: 1.0, - radius: 12.0.into(), + radius: 999.0.into(), }, shadow: Shadow::default(), } @@ -273,7 +376,7 @@ pub fn danger_button(_theme: &Theme, _status: button::Status) -> button::Style { border: Border { color: Color::TRANSPARENT, width: 0.0, - radius: 12.0.into(), + radius: 999.0.into(), }, shadow: Shadow::default(), } @@ -286,7 +389,7 @@ pub fn nav_button_active(theme: &Theme, _status: button::Status) -> button::Styl border: Border { color: Color::TRANSPARENT, width: 0.0, - radius: 12.0.into(), + radius: 999.0.into(), }, shadow: Shadow::default(), } @@ -309,7 +412,7 @@ pub fn nav_button_inactive(theme: &Theme, status: button::Status) -> button::Sty border: Border { color: Color::TRANSPARENT, width: 0.0, - radius: 12.0.into(), + radius: 999.0.into(), }, shadow: Shadow::default(), } diff --git a/src/iced-app/src/types.rs b/src/iced-app/src/types.rs index b83bec3..88cf9f1 100644 --- a/src/iced-app/src/types.rs +++ b/src/iced-app/src/types.rs @@ -2,8 +2,8 @@ use serde::{Deserialize, Serialize}; use tsclientlib::events::Event; use tsclientlib::{ChannelId, ClientId, Identity}; -use crate::{audio, noise_cancel}; use crate::theme; +use crate::{audio, noise_cancel}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Page { @@ -13,6 +13,50 @@ pub(crate) enum Page { Settings, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SettingsSection { + General, + Audio, + Capture, + Playback, + Hotkeys, + Notifications, + Appearance, + Chat, + Identities, + Advanced, +} + +impl SettingsSection { + pub(crate) const ALL: [Self; 10] = [ + Self::General, + Self::Audio, + Self::Capture, + Self::Playback, + Self::Hotkeys, + Self::Notifications, + Self::Appearance, + Self::Chat, + Self::Identities, + Self::Advanced, + ]; + + pub(crate) fn label(self) -> &'static str { + match self { + Self::General => "General", + Self::Audio => "Audio", + Self::Capture => "Capture", + Self::Playback => "Playback", + Self::Hotkeys => "Hotkeys", + Self::Notifications => "Notifications", + Self::Appearance => "Appearance", + Self::Chat => "Chat", + Self::Identities => "Identities", + Self::Advanced => "Advanced", + } + } +} + #[derive(Debug, Clone)] #[allow(clippy::enum_variant_names)] pub(crate) enum Message { @@ -58,6 +102,7 @@ pub(crate) enum Message { IdentityImported(Result), SetOutputDevice(String), SetInputDevice(String), + SelectSettingsSection(SettingsSection), SetAppearance(theme::AppearanceMode), Noop, } diff --git a/src/iced-app/src/view.rs b/src/iced-app/src/view.rs index ad944b9..1e2d13f 100644 --- a/src/iced-app/src/view.rs +++ b/src/iced-app/src/view.rs @@ -1,20 +1,47 @@ -use iced::widget::{button, column, container, horizontal_space, row, scrollable, text, text_input, vertical_space}; +use iced::widget::{ + button, column, container, horizontal_space, pick_list, row, scrollable, text, text_input, + vertical_space, +}; use iced::{Element, Length, Theme}; use tsclientlib::ChannelId; use crate::audio; use crate::noise_cancel; +use crate::types::{BookmarkInfo, ChannelEntry, ClientEntry, SettingsSection}; use crate::{icons, theme, App, Message, Page}; -use crate::types::{BookmarkInfo, ChannelEntry, ClientEntry}; impl App { pub(crate) fn view_sidebar(&self) -> Element<'_, Message> { let theme_value = self.theme(); let primary_nav = column![ - nav_button(&theme_value, icons::Icon::Home, "Home", Page::Home, self.page), - nav_button(&theme_value, icons::Icon::Bookmark, "Bookmarks", Page::Bookmarks, self.page), - nav_button(&theme_value, icons::Icon::UserCircle, "Identities", Page::Identities, self.page), - nav_button(&theme_value, icons::Icon::Cog, "Settings", Page::Settings, self.page), + nav_button( + &theme_value, + icons::Icon::Home, + "Home", + Page::Home, + self.page + ), + nav_button( + &theme_value, + icons::Icon::Bookmark, + "Bookmarks", + Page::Bookmarks, + self.page + ), + nav_button( + &theme_value, + icons::Icon::UserCircle, + "Identities", + Page::Identities, + self.page + ), + nav_button( + &theme_value, + icons::Icon::Cog, + "Settings", + Page::Settings, + self.page + ), ] .spacing(4); @@ -23,9 +50,12 @@ impl App { let footer = container( column![ - text("Current Voice").size(11).style(text::secondary), + text("CURRENT VOICE").size(11).style(text::secondary), vertical_space().height(4), - text(self.voice_summary_line()).size(12), + text(self.connection_line()).size(13), + text(self.voice_summary_line()) + .size(12) + .style(text::secondary), ] .spacing(2), ) @@ -34,7 +64,10 @@ impl App { container( column![ - text("TeamSpeak").size(24), + text("ReTeamSpeak").size(24), + text("Voice-first TeamSpeak client") + .size(12) + .style(text::secondary), vertical_space().height(16), primary_nav, vertical_space().height(20), @@ -44,40 +77,33 @@ impl App { vertical_space().height(Length::Fill), footer, ] - .width(220) + .width(240) .height(Length::Fill) .padding(16) .spacing(0), ) .style(theme::sidebar_container) - .width(220) + .width(240) .height(Length::Fill) .into() } pub(crate) fn view_sidebar_bookmarks(&self) -> Element<'_, Message> { - let mut list = column![].spacing(4).padding(12); + let mut list = column![].spacing(6); for (i, bookmark) in self.bookmarks.iter().enumerate() { let is_selected = self.selected_bookmark == Some(i); let btn = button( - row![ - column![ - text(&bookmark.name).size(13), - text(format!("{}:{}", bookmark.address, bookmark.port)) - .size(11) - .style(text::secondary), - ] - .spacing(2) - .width(Length::Fill), - button(text("x").size(10)) - .padding(4) - .style(theme::danger_button) - .on_press(Message::DeleteBookmark(i)), + column![column![ + text(&bookmark.name).size(13), + text(format!("{}:{}", bookmark.address, bookmark.port)) + .size(11) + .style(text::secondary), ] - .align_y(iced::Alignment::Center) - .spacing(8), + .spacing(2) + .width(Length::Fill),] + .spacing(2), ) .width(Length::Fill) .on_press(Message::SelectBookmark(Some(i))) @@ -94,6 +120,7 @@ impl App { if self.editing_bookmark { let form = container( column![ + text("Add Bookmark").size(13), text_input("Server name", &self.bm_name_input) .on_input(Message::BookmarkNameChanged) .style(theme::input_style), @@ -119,7 +146,7 @@ impl App { .padding(12), ) .style(theme::card_container) - .padding(8); + .padding(0); list = list.push(form); } @@ -127,7 +154,7 @@ impl App { container(column![ container( row![ - text("Bookmarks").size(14), + text("BOOKMARKS").size(11).style(text::secondary), horizontal_space(), button(text("+").size(16)) .padding([2, 8]) @@ -136,10 +163,11 @@ impl App { ] .align_y(iced::Alignment::Center), ) - .padding([0, 12]), + .padding([0, 4]), vertical_space().height(8), scrollable(list).height(Length::Fill), ]) + .padding(12) .style(theme::sidebar_section_container) .into() } @@ -149,22 +177,24 @@ impl App { let recent = if recent_bookmarks.is_empty() { column![text("No recent server").size(13).style(text::secondary)] } else { - recent_bookmarks.into_iter().fold(column![].spacing(8), |column, bookmark| { - column.push( - column![ - text(&bookmark.name).size(13), - text(format!("{}:{}", bookmark.address, bookmark.port)) - .size(11) - .style(text::secondary), - ] - .spacing(2), - ) - }) + recent_bookmarks + .into_iter() + .fold(column![].spacing(8), |column, bookmark| { + column.push( + column![ + text(&bookmark.name).size(13), + text(format!("{}:{}", bookmark.address, bookmark.port)) + .size(11) + .style(text::secondary), + ] + .spacing(2), + ) + }) }; container( column![ - text("Recent").size(14), + text("RECENT").size(11).style(text::secondary), vertical_space().height(8), recent, ] @@ -178,7 +208,13 @@ impl App { pub(crate) fn view_content(&self) -> Element<'_, Message> { match self.page { Page::Home => self.view_home_content(), - Page::Bookmarks => self.view_server_content(), + Page::Bookmarks => { + if self.connected { + self.view_server_content() + } else { + self.view_bookmarks_content() + } + } Page::Identities => self.view_identities_content(), Page::Settings => self.view_settings_content(), } @@ -196,32 +232,51 @@ impl App { .size(13) .style(text::secondary), vertical_space().height(12), - button(text(if self.connected { "Open Server" } else { "Connect" }).size(13)) - .padding([10, 18]) - .style(theme::primary_button) - .on_press(if self.connected { Message::PageChanged(Page::Bookmarks) } else { Message::Connect }), + button( + text(if self.connected { + "Open Server" + } else { + "Connect" + }) + .size(13) + ) + .padding([10, 18]) + .style(theme::primary_button) + .on_press(if self.connected { + Message::PageChanged(Page::Bookmarks) + } else { + Message::Connect + }), ] .spacing(0) .padding(20), ) .style(theme::hero_container) } else { - container(text("Select a bookmark to continue").size(13).style(text::secondary)) - .padding(20) - .style(theme::hero_container) - } - } else { - container(text("Select a bookmark to continue").size(13).style(text::secondary)) + container( + text("Select a bookmark to continue") + .size(13) + .style(text::secondary), + ) .padding(20) .style(theme::hero_container) + } + } else { + container( + text("Select a bookmark to continue") + .size(13) + .style(text::secondary), + ) + .padding(20) + .style(theme::hero_container) }; let favorite_bookmarks = self.favorite_bookmarks(2); - let mut favorite_col = column![text("Favorites").size(17), vertical_space().height(8)].spacing(0); + let mut favorite_col = + column![text("Favorites").size(17), vertical_space().height(8)].spacing(0); if favorite_bookmarks.is_empty() { - favorite_col = favorite_col.push( - text("No favorites yet").size(13).style(text::secondary), - ); + favorite_col = + favorite_col.push(text("No favorites yet").size(13).style(text::secondary)); } else { for bookmark in favorite_bookmarks { favorite_col = favorite_col.push(text(bookmark.name.clone()).size(15)); @@ -271,6 +326,177 @@ impl App { .into() } + pub(crate) fn view_bookmarks_content(&self) -> Element<'_, Message> { + let mut content = column![ + text("Bookmarks").size(32), + vertical_space().height(8), + text("Connect quickly to saved TeamSpeak servers.") + .size(15) + .style(text::secondary), + vertical_space().height(24), + row![ + text(if let Some(idx) = self.selected_bookmark { + self.bookmarks + .get(idx) + .map(|bookmark| format!("Selected: {}", bookmark.name)) + .unwrap_or_else(|| "Choose a server to connect".to_string()) + } else { + "Choose a server to connect".to_string() + }) + .size(13) + .style(text::secondary), + horizontal_space(), + button(text("+ Add Server").size(12)) + .padding([8, 16]) + .style(theme::primary_button) + .on_press(Message::AddBookmark), + ] + .align_y(iced::Alignment::Center), + vertical_space().height(16), + ] + .spacing(0); + + if self.editing_bookmark { + content = content.push( + container( + column![ + text("New Bookmark").size(17), + text("Save a TeamSpeak server for quick access.") + .size(13) + .style(text::secondary), + vertical_space().height(16), + text("Server Name").size(13), + text_input("KR TeamSpeak", &self.bm_name_input) + .on_input(Message::BookmarkNameChanged) + .style(theme::input_style), + vertical_space().height(8), + text("Address").size(13), + text_input("kr.teamspeak.app", &self.bm_address_input) + .on_input(Message::BookmarkAddressChanged) + .style(theme::input_style), + vertical_space().height(8), + text("Port").size(13), + text_input("9987", &self.bm_port_input) + .on_input(Message::BookmarkPortChanged) + .style(theme::input_style), + vertical_space().height(12), + row![ + button(text("Save Bookmark").size(12)) + .padding([8, 16]) + .style(theme::primary_button) + .on_press(Message::Connect), + button(text("Cancel").size(12)) + .padding([8, 16]) + .style(theme::secondary_button) + .on_press(Message::CancelAddBookmark), + ] + .spacing(8), + ] + .spacing(0) + .padding(20), + ) + .style(theme::card_container), + ); + content = content.push(vertical_space().height(16)); + } + + if self.bookmarks.is_empty() && !self.editing_bookmark { + content = content.push( + container( + column![ + text("No bookmarks yet").size(20), + vertical_space().height(8), + text("Add a TeamSpeak server to connect faster next time.") + .size(14) + .style(text::secondary), + vertical_space().height(16), + button(text("Add Server").size(12)) + .padding([8, 16]) + .style(theme::primary_button) + .on_press(Message::AddBookmark), + ] + .spacing(0) + .padding(24), + ) + .style(theme::hero_container), + ); + } else { + for (i, bookmark) in self.bookmarks.iter().enumerate() { + let is_selected = self.selected_bookmark == Some(i); + let subtitle = if bookmark.last_used_at.is_some() { + "Last used recently" + } else { + "Saved server" + }; + content = content.push( + container( + column![ + row![ + column![ + text(&bookmark.name).size(17), + text(format!("{}:{}", bookmark.address, bookmark.port)) + .size(13) + .style(text::secondary), + ] + .spacing(4) + .width(Length::Fill), + text(if is_selected { "Selected" } else { "Saved" }) + .size(11) + .style(text::secondary), + ] + .align_y(iced::Alignment::Center), + vertical_space().height(8), + text(subtitle).size(12).style(text::secondary), + if let Some(nickname) = &bookmark.nickname { + text(format!("Nickname: {}", nickname)) + .size(12) + .style(text::secondary) + } else { + text("Uses your current nickname override when set.") + .size(12) + .style(text::secondary) + }, + vertical_space().height(16), + row![ + button( + text(if is_selected { "Connect" } else { "Select" }).size(12) + ) + .padding([8, 16]) + .style(if is_selected { + theme::primary_button + } else { + theme::secondary_button + }) + .on_press(if is_selected { + Message::Connect + } else { + Message::SelectBookmark(Some(i)) + }), + button(text("Delete").size(12)) + .padding([8, 16]) + .style(theme::secondary_button) + .on_press(Message::DeleteBookmark(i)), + ] + .spacing(8), + ] + .spacing(0) + .padding(20), + ) + .style(if is_selected { + theme::hero_container + } else { + theme::card_container + }), + ); + content = content.push(vertical_space().height(16)); + } + } + + content = content.push(self.view_voice_capsule()); + + scrollable(container(content.padding([48, 56]).max_width(880))).into() + } + pub(crate) fn view_identities_content(&self) -> Element<'_, Message> { let identity_status = if let Some(identity) = &self.imported_identity { column![ @@ -285,7 +511,9 @@ impl App { column![ text("Selected Identity").size(17), vertical_space().height(8), - text("Default client identity").size(13).style(text::secondary), + text("Default client identity") + .size(13) + .style(text::secondary), ] }; @@ -302,9 +530,12 @@ impl App { vertical_space().height(16), text("Paste TeamSpeak identity string").size(13), vertical_space().height(8), - text_input("Enter exported identity string...", &self.identity_string_input) - .on_input(Message::IdentityStringChanged) - .style(theme::input_style), + text_input( + "Enter exported identity string...", + &self.identity_string_input + ) + .on_input(Message::IdentityStringChanged) + .style(theme::input_style), vertical_space().height(8), button(text("Import From String").size(13)) .padding([10, 18]) @@ -313,9 +544,12 @@ impl App { vertical_space().height(16), text("Or import from file").size(13), vertical_space().height(8), - text_input("Path to exported identity file...", &self.identity_file_input) - .on_input(Message::IdentityFilePathChanged) - .style(theme::input_style), + text_input( + "Path to exported identity file...", + &self.identity_file_input + ) + .on_input(Message::IdentityFilePathChanged) + .style(theme::input_style), vertical_space().height(8), button(text("Import From File").size(13)) .padding([10, 18]) @@ -387,35 +621,66 @@ impl App { } if self.identity_level > 0 { connect_form = connect_form.push( - text(format!("Computing identity level {}...", self.identity_level)) - .size(12) - .style(text::secondary), + text(format!( + "Computing identity level {}...", + self.identity_level + )) + .size(12) + .style(text::secondary), ); } if self.connected { - let mic_label = if self.mic_muted { "๐Ÿ”‡ Mic" } else { "๐ŸŽค Mic" }; - let speaker_label = if self.speaker_muted { "๐Ÿ”‡ Speaker" } else { "๐Ÿ”Š Speaker" }; - let headset_label = if self.headset_muted { "๐Ÿ”‡ Headset" } else { "๐ŸŽง Headset" }; + let mic_label = if self.mic_muted { + "๐Ÿ”‡ Mic" + } else { + "๐ŸŽค Mic" + }; + let speaker_label = if self.speaker_muted { + "๐Ÿ”‡ Speaker" + } else { + "๐Ÿ”Š Speaker" + }; + let headset_label = if self.headset_muted { + "๐Ÿ”‡ Headset" + } else { + "๐ŸŽง Headset" + }; let afk_label = if self.afk { "AFK โœ“" } else { "AFK" }; let control_bar = row![ button(text(mic_label).size(11)) .padding([6, 12]) - .style(if self.mic_muted { theme::danger_button } else { theme::secondary_button }) + .style(if self.mic_muted { + theme::danger_button + } else { + theme::secondary_button + }) .on_press(Message::ToggleMicMute), button(text(speaker_label).size(11)) .padding([6, 12]) - .style(if self.speaker_muted { theme::danger_button } else { theme::secondary_button }) + .style(if self.speaker_muted { + theme::danger_button + } else { + theme::secondary_button + }) .on_press(Message::ToggleSpeakerMute), button(text(headset_label).size(11)) .padding([6, 12]) - .style(if self.headset_muted { theme::danger_button } else { theme::secondary_button }) + .style(if self.headset_muted { + theme::danger_button + } else { + theme::secondary_button + }) .on_press(Message::ToggleHeadsetMute), horizontal_space(), button(text(afk_label).size(11)) .padding([6, 12]) - .style(if self.afk { theme::danger_button } else { theme::secondary_button }) + .style(if self.afk { + theme::danger_button + } else { + theme::secondary_button + }) .on_press(Message::ToggleAfk), ] .spacing(8) @@ -423,15 +688,16 @@ impl App { let server_header = container( column![ - row![ - text(&self.server_name).size(24), - horizontal_space(), - ], - text(format!("{} / {} โ€” {}/{} online", - self.server_platform, self.server_version, - self.clients.len(), self.server_max_clients)) - .size(13) - .style(text::secondary), + row![text(&self.server_name).size(24), horizontal_space(),], + text(format!( + "{} / {} โ€” {}/{} online", + self.server_platform, + self.server_version, + self.clients.len(), + self.server_max_clients + )) + .size(13) + .style(text::secondary), vertical_space().height(8), control_bar, ] @@ -444,18 +710,24 @@ impl App { let mut browser_list = column![].spacing(8); let mut match_count = 0usize; for root in self.sorted_child_channels(ChannelId(0)) { - if let Some(section) = self.view_channel_tree(root, 0, &search, &mut match_count) { + if let Some(section) = + self.view_channel_tree(root, 0, &search, &mut match_count) + { browser_list = browser_list.push(section); } } if self.channels.is_empty() { browser_list = browser_list.push( - text("No channels available yet.").size(13).style(text::secondary), + text("No channels available yet.") + .size(13) + .style(text::secondary), ); } else if match_count == 0 { browser_list = browser_list.push( - text("No channels or users match your search.").size(13).style(text::secondary), + text("No channels or users match your search.") + .size(13) + .style(text::secondary), ); } @@ -544,155 +816,121 @@ impl App { } pub(crate) fn view_settings_content(&self) -> Element<'_, Message> { - let output_label = if self.selected_output_device.is_empty() { - "System Default" - } else { - &self.selected_output_device + let outputs = { + let mut values = vec!["System Default".to_string()]; + values.extend(audio::AudioPlayback::list_output_devices()); + values }; - let input_label = if self.selected_input_device.is_empty() { - "System Default" + let inputs = { + let mut values = vec!["System Default".to_string()]; + values.extend(audio::AudioPlayback::list_input_devices()); + values + }; + let selected_output = if self.selected_output_device.is_empty() { + Some("System Default".to_string()) } else { - &self.selected_input_device + Some(self.selected_output_device.clone()) + }; + let selected_input = if self.selected_input_device.is_empty() { + Some("System Default".to_string()) + } else { + Some(self.selected_input_device.clone()) }; - - let mut settings_col = column![ - text("Settings").size(32), - vertical_space().height(8), - text("Tune appearance, audio devices, and identity behavior.") - .size(15) - .style(text::secondary), - vertical_space().height(24), - ] - .spacing(8) - .padding(20); - - let appearance_col = column![ - text("Appearance").size(17), - vertical_space().height(8), - text(format!("Theme: {}", self.appearance_mode.label())) - .size(13) - .style(text::secondary), - vertical_space().height(12), - row![ - button(text("Light").size(12)) - .padding([6, 14]) - .style(if self.appearance_mode == theme::AppearanceMode::Light { theme::primary_button } else { theme::secondary_button }) - .on_press(Message::SetAppearance(theme::AppearanceMode::Light)), - button(text("Dark").size(12)) - .padding([6, 14]) - .style(if self.appearance_mode == theme::AppearanceMode::Dark { theme::primary_button } else { theme::secondary_button }) - .on_press(Message::SetAppearance(theme::AppearanceMode::Dark)), - ] - .spacing(8), - ] - .padding(20) - .spacing(0); - - settings_col = settings_col.push( - container(appearance_col) - .style(theme::card_container) - .width(560), - ); - - settings_col = settings_col.push(vertical_space().height(16)); - - #[allow(unused_mut)] - let mut devices_col = column![ - text("Audio Output Device").size(13), - text(output_label).size(12).style(text::secondary), - text("Audio Input Device").size(13), - text(input_label).size(12).style(text::secondary), - ] - .spacing(4); - - let outputs = audio::AudioPlayback::list_output_devices(); - let inputs = audio::AudioPlayback::list_input_devices(); - let mut output_btns = row![].spacing(4); - for dev in outputs { - let label = dev.clone(); - let is_selected = self.selected_output_device == dev || (self.selected_output_device.is_empty() && dev == "default"); - output_btns = output_btns.push( - button(text(label).size(10)) - .padding([4, 8]) - .style(if is_selected { theme::primary_button } else { theme::secondary_button }) - .on_press(Message::SetOutputDevice(dev)), - ); - } - let mut input_btns = row![].spacing(4); - for dev in inputs { - let label = dev.clone(); - let is_selected = self.selected_input_device == dev || (self.selected_input_device.is_empty() && dev == "default"); - input_btns = input_btns.push( - button(text(label).size(10)) - .padding([4, 8]) - .style(if is_selected { theme::primary_button } else { theme::secondary_button }) - .on_press(Message::SetInputDevice(dev)), - ); - } - devices_col = devices_col - .push(vertical_space().height(4)) - .push(text("Output devices:").size(11).style(text::secondary)) - .push(output_btns) - .push(vertical_space().height(4)) - .push(text("Input devices:").size(11).style(text::secondary)) - .push(input_btns); - - settings_col = settings_col.push( - container(devices_col.spacing(4).padding(16)) - .style(theme::card_container) - .width(560), - ); let nc_label = self.nc_method.label(); let sonora_noise_label = self.sonora_settings.noise_suppression_level.label(); + let audio_card = container( + column![ + text("Audio Devices").size(17), + vertical_space().height(8), + text("Select the devices used for playback and microphone capture.") + .size(13) + .style(text::secondary), + vertical_space().height(16), + text("Output Device").size(13), + pick_list(outputs, selected_output, |value| { + Message::SetOutputDevice(if value == "System Default" { + String::new() + } else { + value + }) + }) + .placeholder("Choose an output device") + .width(Length::Fill) + .padding([10, 12]), + vertical_space().height(8), + text("Uses the system default output when no override is selected.") + .size(12) + .style(text::secondary), + vertical_space().height(16), + text("Input Device").size(13), + pick_list(inputs, selected_input, |value| { + Message::SetInputDevice(if value == "System Default" { + String::new() + } else { + value + }) + }) + .placeholder("Choose a microphone") + .width(Length::Fill) + .padding([10, 12]), + vertical_space().height(8), + text("The selected microphone is used for both push-to-talk and voice activation.") + .size(12) + .style(text::secondary), + ] + .spacing(0) + .padding(20), + ) + .style(theme::card_container); + let voice_col = column![ text("Talk Mode").size(13), - text("Choose how your microphone starts transmitting.").size(12).style(text::secondary), + text("Choose how your microphone starts transmitting.") + .size(12) + .style(text::secondary), + vertical_space().height(8), + row![ + button(text("Push-to-Talk").size(11)) + .padding([4, 12]) + .style(if self.talk_mode == audio::TalkMode::PushToTalk { + theme::primary_button + } else { + theme::secondary_button + }) + .on_press(if self.talk_mode == audio::TalkMode::PushToTalk { + Message::Noop + } else { + Message::ToggleTalkMode + }), + button(text("Voice Activation").size(11)) + .padding([4, 12]) + .style(if self.talk_mode == audio::TalkMode::Continuous { + theme::primary_button + } else { + theme::secondary_button + }) + .on_press(if self.talk_mode == audio::TalkMode::Continuous { + Message::Noop + } else { + Message::ToggleTalkMode + }), + ] + .spacing(8) + .align_y(iced::Alignment::Center), vertical_space().height(8), text("Push-to-Talk Key").size(13), text("V (hold to talk)").size(12).style(text::secondary), - vertical_space().height(8), + vertical_space().height(12), text("Microphone Processing Backend").size(13), text(nc_label).size(12).style(text::secondary), - ] - .spacing(4) - .padding(16); - - let voice_col = voice_col.push(row![ - button(text("Push-to-Talk").size(11)) - .padding([4, 12]) - .style(if self.talk_mode == audio::TalkMode::PushToTalk { - theme::primary_button - } else { - theme::secondary_button - }) - .on_press(if self.talk_mode == audio::TalkMode::PushToTalk { - Message::Noop - } else { - Message::ToggleTalkMode - }), - button(text("Voice Activation").size(11)) - .padding([4, 12]) - .style(if self.talk_mode == audio::TalkMode::Continuous { - theme::primary_button - } else { - theme::secondary_button - }) - .on_press(if self.talk_mode == audio::TalkMode::Continuous { - Message::Noop - } else { - Message::ToggleTalkMode - }), - ] - .spacing(8) - .align_y(iced::Alignment::Center)); - - let voice_col = voice_col.push( + vertical_space().height(8), text("Advanced options below only affect microphone cleanup, not playback.") .size(11) .style(text::secondary), - ); + ] + .spacing(4); let voice_col = noise_cancel::NoiseCancelMethod::all().iter().copied().fold( voice_col.push(text("Processing backend").size(11).style(text::secondary)), @@ -715,9 +953,17 @@ impl App { let voice_col = voice_col .push(vertical_space().height(8)) .push(text("Advanced Sonora Processing").size(13)) - .push(text("Tune noise cleanup, auto gain, and low-frequency filtering.").size(11).style(text::secondary)) + .push( + text("Tune noise cleanup, auto gain, and low-frequency filtering.") + .size(11) + .style(text::secondary), + ) .push(vertical_space().height(4)) - .push(text("Noise Suppression Strength").size(11).style(text::secondary)) + .push( + text("Noise Suppression Strength") + .size(11) + .style(text::secondary), + ) .push(text(sonora_noise_label).size(12).style(text::secondary)); let voice_col = noise_cancel::NoiseSuppressionLevelSetting::all() @@ -738,14 +984,20 @@ impl App { voice_col .push(vertical_space().height(8)) - .push(text("Automatic Gain Control").size(11).style(text::secondary)) .push( - button(text(if self.sonora_settings.agc_enabled { - "AGC2 On" - } else { - "AGC2 Off" - }) - .size(11)) + text("Automatic Gain Control") + .size(11) + .style(text::secondary), + ) + .push( + button( + text(if self.sonora_settings.agc_enabled { + "AGC2 On" + } else { + "AGC2 Off" + }) + .size(11), + ) .padding([4, 12]) .style(if self.sonora_settings.agc_enabled { theme::primary_button @@ -756,12 +1008,14 @@ impl App { ) .push(text("High-Pass Filter").size(11).style(text::secondary)) .push( - button(text(if self.sonora_settings.high_pass_enabled { - "High-Pass On" - } else { - "High-Pass Off" - }) - .size(11)) + button( + text(if self.sonora_settings.high_pass_enabled { + "High-Pass On" + } else { + "High-Pass Off" + }) + .size(11), + ) .padding([4, 12]) .style(if self.sonora_settings.high_pass_enabled { theme::primary_button @@ -774,65 +1028,219 @@ impl App { voice_col }; - settings_col = settings_col.push( - container(voice_col) - .style(theme::card_container) - .width(560), - ); + let voice_card = container(voice_col.padding(20)).style(theme::card_container); - let identity_col = column![ - text("TeamSpeak Identity").size(13), - text("Import from an identity string or export file").size(12).style(text::secondary), - vertical_space().height(8), - text_input("Identity file path", &self.identity_file_input) + let identity_card = container( + column![ + text("TeamSpeak Identity").size(17), + vertical_space().height(8), + text("Import from an identity string or export file.") + .size(13) + .style(text::secondary), + vertical_space().height(16), + text("Identity File").size(13), + text_input( + "Path to exported identity file...", + &self.identity_file_input + ) .on_input(Message::IdentityFilePathChanged) .style(theme::input_style), - vertical_space().height(8), - button(text("Import From File").size(12)) - .padding([8, 16]) - .style(theme::primary_button) - .on_press(Message::ImportIdentityFromFile), - ] - .spacing(4) - .padding(16); + vertical_space().height(8), + button(text("Import From File").size(12)) + .padding([8, 16]) + .style(theme::primary_button) + .on_press(Message::ImportIdentityFromFile), + ] + .spacing(0) + .padding(20), + ) + .style(theme::card_container); - settings_col = settings_col.push( - container(identity_col) - .style(theme::card_container) - .width(560), - ); + let appearance_card = container( + column![ + text("Appearance").size(17), + vertical_space().height(8), + text(format!("Theme: {}", self.appearance_mode.label())) + .size(13) + .style(text::secondary), + vertical_space().height(12), + row![ + button(text("Light").size(12)) + .padding([6, 14]) + .style(if self.appearance_mode == theme::AppearanceMode::Light { + theme::primary_button + } else { + theme::secondary_button + }) + .on_press(Message::SetAppearance(theme::AppearanceMode::Light)), + button(text("Dark").size(12)) + .padding([6, 14]) + .style(if self.appearance_mode == theme::AppearanceMode::Dark { + theme::primary_button + } else { + theme::secondary_button + }) + .on_press(Message::SetAppearance(theme::AppearanceMode::Dark)), + ] + .spacing(8), + ] + .spacing(0) + .padding(20), + ) + .style(theme::card_container); - settings_col = settings_col.push(vertical_space().height(16)); - settings_col = settings_col.push(self.view_voice_capsule()); + let placeholder_card = |title: &'static str, body: &'static str| { + container( + column![ + text(title).size(17), + vertical_space().height(8), + text(body).size(13).style(text::secondary), + ] + .spacing(0) + .padding(20), + ) + .style(theme::card_container) + }; - container(settings_col).into() + let settings_content: Element<'_, Message> = match self.settings_section { + SettingsSection::General => placeholder_card( + "General", + "General client preferences will live here as the settings layout is expanded.", + ) + .into(), + SettingsSection::Audio => column![audio_card, vertical_space().height(16), voice_card] + .spacing(0) + .into(), + SettingsSection::Capture => voice_card.into(), + SettingsSection::Playback => audio_card.into(), + SettingsSection::Hotkeys => placeholder_card( + "Hotkeys", + "Push-to-talk, mute, and deafen shortcuts are planned for this section.", + ) + .into(), + SettingsSection::Notifications => placeholder_card( + "Notifications", + "Connection, private message, and voice-state alerts will be grouped here.", + ) + .into(), + SettingsSection::Appearance => appearance_card.into(), + SettingsSection::Chat => placeholder_card( + "Chat", + "Chat presentation and unread behavior will move into this focused section.", + ) + .into(), + SettingsSection::Identities => identity_card.into(), + SettingsSection::Advanced => placeholder_card( + "Advanced", + "Advanced diagnostics and lower-level behavior stay out of the primary flow.", + ) + .into(), + }; + + let settings_nav = + SettingsSection::ALL + .into_iter() + .fold(column![].spacing(6), |column, section| { + column.push( + button(text(section.label()).size(12)) + .width(Length::Fill) + .padding([8, 12]) + .style(if self.settings_section == section { + theme::nav_button_active + } else { + theme::nav_button_inactive + }) + .on_press(Message::SelectSettingsSection(section)), + ) + }); + + scrollable( + column![ + text("Settings").size(32), + vertical_space().height(8), + text("Tune your local client preferences.") + .size(15) + .style(text::secondary), + vertical_space().height(24), + row![ + container( + column![ + text("Sections").size(11).style(text::secondary), + vertical_space().height(12), + settings_nav, + ] + .spacing(0) + .padding(16), + ) + .width(220) + .style(theme::sidebar_section_container), + column![ + settings_content, + vertical_space().height(16), + self.view_voice_capsule() + ] + .spacing(0) + .width(Length::Fill), + ] + .spacing(20), + ] + .spacing(0) + .padding([48, 56]), + ) + .into() } pub(crate) fn view_voice_capsule(&self) -> Element<'_, Message> { let mut actions = row![ button( row![ - icons::view(icons::Icon::Microphone, 14.0, theme::icon_color(&self.theme())), - text(if self.mic_muted { "Unmute Mic" } else { "Mute Mic" }).size(11), + icons::view( + icons::Icon::Microphone, + 14.0, + theme::icon_color(&self.theme()) + ), + text(if self.mic_muted { + "Unmute Mic" + } else { + "Mute Mic" + }) + .size(11), ] .spacing(6), ) .padding([6, 12]) - .style(if self.mic_muted { theme::danger_button } else { theme::secondary_button }) + .style(if self.mic_muted { + theme::danger_button + } else { + theme::secondary_button + }) .on_press(Message::ToggleMicMute), button( row![ icons::view( - if self.headset_muted { icons::Icon::SpeakerXMark } else { icons::Icon::SpeakerWave }, + if self.headset_muted { + icons::Icon::SpeakerXMark + } else { + icons::Icon::SpeakerWave + }, 14.0, theme::icon_color(&self.theme()), ), - text(if self.headset_muted { "Undeafen" } else { "Deafen" }).size(11), + text(if self.headset_muted { + "Undeafen" + } else { + "Deafen" + }) + .size(11), ] .spacing(6), ) .padding([6, 12]) - .style(if self.headset_muted { theme::danger_button } else { theme::secondary_button }) + .style(if self.headset_muted { + theme::danger_button + } else { + theme::secondary_button + }) .on_press(Message::ToggleHeadsetMute), ] .spacing(8); @@ -851,14 +1259,20 @@ impl App { column![ row![ icons::view( - if self.connected { icons::Icon::SpeakerWave } else { icons::Icon::SpeakerXMark }, + if self.connected { + icons::Icon::SpeakerWave + } else { + icons::Icon::SpeakerXMark + }, 16.0, theme::icon_color(&self.theme()), ), text(self.connection_line()).size(15), ] .spacing(8), - text(self.voice_summary_line()).size(12).style(text::secondary), + text(self.voice_summary_line()) + .size(12) + .style(text::secondary), ] .spacing(4) .width(Length::Fill), @@ -889,14 +1303,22 @@ impl App { } pub(crate) fn connection_line(&self) -> String { - let state = if self.connected { "Connected" } else { "Disconnected" }; + let state = if self.connected { + "Connected" + } else { + "Disconnected" + }; let channel = self.current_channel_name().unwrap_or("No channel"); format!("{} ยท {}", state, channel) } pub(crate) fn voice_summary_line(&self) -> String { let mic = if self.mic_muted { "Mic Off" } else { "Mic On" }; - let output = if self.headset_muted { "Deafened" } else { "Audio On" }; + let output = if self.headset_muted { + "Deafened" + } else { + "Audio On" + }; format!("{} ยท {} ยท {}", mic, output, self.talk_mode_label()) } @@ -997,44 +1419,45 @@ impl App { let has_children = !child_sections.is_empty(); let force_expanded = !search.is_empty(); - let is_collapsed = has_children - && !force_expanded - && self.collapsed_channels.contains(&channel.id.0); + let is_collapsed = + has_children && !force_expanded && self.collapsed_channels.contains(&channel.id.0); let indent = 12 + (depth as u16 * 20); - let mut section = column![ - button( - row![ - if has_children { - Element::from(icons::view( - if is_collapsed { icons::Icon::ChevronRight } else { icons::Icon::ChevronDown }, - 14.0, - theme::icon_color(&self.theme()), - )) - } else { - container(horizontal_space().width(14)).into() - }, - text(channel.name.clone()).size(13).width(Length::Fill), - text(format!("{} users", matching_clients.len())) - .size(11) - .style(text::secondary), - ] - .spacing(8) - .padding([2, 0]), - ) - .width(Length::Fill) - .padding([8, indent]) - .style(if is_current { - theme::bookmark_button_selected - } else { - theme::bookmark_button - }) - .on_press(if has_children { - Message::ToggleChannelCollapsed(channel.id) - } else { - Message::JoinChannel(channel.id) - }) - ] + let mut section = column![button( + row![ + if has_children { + Element::from(icons::view( + if is_collapsed { + icons::Icon::ChevronRight + } else { + icons::Icon::ChevronDown + }, + 14.0, + theme::icon_color(&self.theme()), + )) + } else { + container(horizontal_space().width(14)).into() + }, + text(channel.name.clone()).size(13).width(Length::Fill), + text(format!("{} users", matching_clients.len())) + .size(11) + .style(text::secondary), + ] + .spacing(8) + .padding([2, 0]), + ) + .width(Length::Fill) + .padding([8, indent]) + .style(if is_current { + theme::bookmark_button_selected + } else { + theme::bookmark_button + }) + .on_press(if has_children { + Message::ToggleChannelCollapsed(channel.id) + } else { + Message::JoinChannel(channel.id) + })] .spacing(4); if !is_collapsed { @@ -1067,14 +1490,20 @@ impl App { theme::icon_color(&self.theme()), ), text(client.name.clone()).size(12).width(Length::Fill), - text(format!("{:.0}%", volume * 100.0)).size(11).style(text::secondary), + text(format!("{:.0}%", volume * 100.0)) + .size(11) + .style(text::secondary), button(text("-").size(10)) .padding([2, 6]) .style(theme::secondary_button) .on_press(Message::DecreaseClientVolume(client.id)), button(text(if locally_muted { "Unmute" } else { "Mute" }).size(10)) .padding([2, 6]) - .style(if locally_muted { theme::danger_button } else { theme::secondary_button }) + .style(if locally_muted { + theme::danger_button + } else { + theme::secondary_button + }) .on_press(Message::ToggleLocalClientMute(client.id)), button(text("+").size(10)) .padding([2, 6]) @@ -1097,7 +1526,13 @@ impl App { } } -fn nav_button(theme: &Theme, icon: icons::Icon, label: &str, page: Page, current: Page) -> Element<'static, Message> { +fn nav_button( + theme: &Theme, + icon: icons::Icon, + label: &str, + page: Page, + current: Page, +) -> Element<'static, Message> { let color = if current == page { iced::Color::WHITE } else {