From 61f2d5bdb63259ac2b00a209fd0221629c5ed336 Mon Sep 17 00:00:00 2001 From: ReTeamSpeak Date: Wed, 13 May 2026 10:31:07 +0900 Subject: [PATCH] feat: Apple-style dark UI, bookmark CRUD, join channel, settings UI Redesign: - Custom dark theme with Apple-inspired palette (SIDEBAR_BG, BG_PRIMARY, etc.) - Rounded cards with subtle shadows and depth - Styled inputs with focus/hover states - Styled buttons (primary, secondary, danger, nav, bookmark) - Proper visual hierarchy with generous whitespace - Sidebar with dark background, cards with elevated surfaces Features: - Bookmark CRUD: add new server, delete existing - Join channel: click channel in list to move to it - Settings page with audio device info placeholder - ServerQuery: configurable command input - Chat messages with invoker name display - Channel list shows current channel highlighted - Client list with mute indicators - Error display with danger styling - Identity level progress indicator Architecture: - Custom theme module (theme.rs) with all styles - Bookmark editing form in sidebar - Channel click handler sends client_move command - 69 tests passing, clippy clean --- src/iced-app/src/main.rs | 654 ++++++++++++++++++++++++-------------- src/iced-app/src/theme.rs | 400 +++++++++++++++++++++++ 2 files changed, 813 insertions(+), 241 deletions(-) create mode 100644 src/iced-app/src/theme.rs diff --git a/src/iced-app/src/main.rs b/src/iced-app/src/main.rs index d336559..df197a2 100644 --- a/src/iced-app/src/main.rs +++ b/src/iced-app/src/main.rs @@ -1,7 +1,7 @@ use iced::futures::sink::SinkExt; use iced::futures::StreamExt; -use iced::widget::{button, column, container, horizontal_rule, horizontal_space, row, scrollable, text, text_input, vertical_space}; -use iced::{Element, Length, Subscription, Task}; +use iced::widget::{button, column, container, horizontal_space, row, scrollable, text, text_input, vertical_space}; +use iced::{Element, Length, Subscription, Task, Theme}; use std::sync::Arc; use tokio::sync::{mpsc, Mutex}; @@ -11,11 +11,14 @@ use tsclientlib::{ChannelId, ClientId, Connection, DisconnectOptions, MessageTar use tsclientlib::events::{Event, PropertyId}; use tsclientlib::prelude::*; +mod theme; + #[cfg(feature = "audio")] mod audio; fn main() -> iced::Result { iced::application("ReTeamSpeak", App::update, App::view) + .theme(App::theme) .subscription(App::subscription) .run_with(App::new) } @@ -32,15 +35,22 @@ enum Page { enum Message { PageChanged(Page), SelectBookmark(Option), + AddBookmark, + DeleteBookmark(usize), + BookmarkNameChanged(String), + BookmarkAddressChanged(String), + BookmarkPortChanged(String), NicknameChanged(String), PasswordChanged(String), Connect, + JoinChannel(ChannelId), Disconnect, TsEvent(TsEvent), MessageInputChanged(String), SendChannelMessage, QueryAddressChanged(String), QueryPortChanged(String), + QueryCommandChanged(String), RunServerQuery, QueryResponse(String), QueryError(String), @@ -97,6 +107,10 @@ struct App { page: Page, bookmarks: Vec, selected_bookmark: Option, + editing_bookmark: bool, + bm_name_input: String, + bm_address_input: String, + bm_port_input: String, nickname: String, password: String, connected: bool, @@ -113,6 +127,7 @@ struct App { message_input: String, query_address: String, query_port: u16, + query_command: String, query_loading: bool, query_response: Option, query_error: Option, @@ -142,6 +157,10 @@ impl App { }, ], selected_bookmark: None, + editing_bookmark: false, + bm_name_input: String::new(), + bm_address_input: String::new(), + bm_port_input: "9987".to_string(), nickname: String::new(), password: String::new(), connected: false, @@ -158,6 +177,7 @@ impl App { message_input: String::new(), query_address: String::new(), query_port: 10011, + query_command: "help".to_string(), query_loading: false, query_response: None, query_error: None, @@ -171,6 +191,10 @@ impl App { (app, Task::none()) } + fn theme(&self) -> Theme { + theme::dark_theme() + } + fn update(&mut self, message: Message) -> Task { match message { Message::PageChanged(page) => { @@ -179,15 +203,44 @@ impl App { } Message::SelectBookmark(idx) => { self.selected_bookmark = idx; + self.editing_bookmark = false; if let Some(i) = idx { if let Some(b) = self.bookmarks.get(i) { - if let Some(nick) = &b.nickname { - self.nickname = nick.clone(); - } + self.nickname = b.nickname.clone().unwrap_or_default(); } } Task::none() } + Message::AddBookmark => { + self.editing_bookmark = true; + self.bm_name_input.clear(); + self.bm_address_input.clear(); + self.bm_port_input = "9987".to_string(); + Task::none() + } + Message::DeleteBookmark(idx) => { + if idx < self.bookmarks.len() { + self.bookmarks.remove(idx); + if self.selected_bookmark == Some(idx) { + self.selected_bookmark = None; + } else if self.selected_bookmark.is_some_and(|s| s > idx) { + self.selected_bookmark = self.selected_bookmark.map(|s| s - 1); + } + } + Task::none() + } + Message::BookmarkNameChanged(n) => { + self.bm_name_input = n; + Task::none() + } + Message::BookmarkAddressChanged(a) => { + self.bm_address_input = a; + Task::none() + } + Message::BookmarkPortChanged(p) => { + self.bm_port_input = p; + Task::none() + } Message::NicknameChanged(n) => { self.nickname = n; Task::none() @@ -197,6 +250,20 @@ impl App { Task::none() } Message::Connect => { + if self.editing_bookmark { + if !self.bm_name_input.is_empty() && !self.bm_address_input.is_empty() { + self.bookmarks.push(BookmarkInfo { + name: self.bm_name_input.clone(), + address: self.bm_address_input.clone(), + port: self.bm_port_input.parse().unwrap_or(9987), + nickname: None, + }); + self.selected_bookmark = Some(self.bookmarks.len() - 1); + self.editing_bookmark = false; + } + return Task::none(); + } + let bookmark = match self.selected_bookmark.and_then(|i| self.bookmarks.get(i)) { Some(b) => b.clone(), None => return Task::none(), @@ -258,6 +325,27 @@ impl App { }, ) } + Message::JoinChannel(channel_id) => { + let h = self.handle.clone(); + Task::perform( + async move { + let mut guard = h.lock().await; + if let Some(ref mut handle) = *guard { + let _ = handle + .with_connection(move |con| -> Result<(), String> { + let state = con.get_state().map_err(|e| e.to_string())?; + if let Some(client) = state.clients.get(&state.own_client) { + let cmd = client.client_move(channel_id); + cmd.send(con).map_err(|e| e.to_string())?; + } + Ok(()) + }) + .await; + } + }, + |_| Message::Noop, + ) + } Message::Disconnect => { let h = self.handle.clone(); let rx = self.event_rx.clone(); @@ -357,17 +445,17 @@ impl App { async move { let mut guard = h.lock().await; if let Some(ref mut handle) = *guard { - let result = handle - .with_connection(move |con| -> Result<(), String> { - let state = con.get_state().map_err(|e| e.to_string())?; - let cmd = state.send_message(MessageTarget::Channel, &msg); - cmd.send(con).map_err(|e| e.to_string())?; - Ok(()) - }) - .await; - if let Err(e) = result { - return Some(e.to_string()); - } + let result = handle + .with_connection(move |con| -> Result<(), String> { + let state = con.get_state().map_err(|e| e.to_string())?; + let cmd = state.send_message(MessageTarget::Channel, &msg); + cmd.send(con).map_err(|e| e.to_string())?; + Ok(()) + }) + .await; + if let Err(e) = result { + return Some(e.to_string()); + } } None }, @@ -388,6 +476,10 @@ impl App { self.query_port = port.parse().unwrap_or(10011); Task::none() } + Message::QueryCommandChanged(cmd) => { + self.query_command = cmd; + Task::none() + } Message::RunServerQuery => { if self.query_address.is_empty() { self.query_error = Some("Address required".to_string()); @@ -397,6 +489,7 @@ impl App { self.query_error = None; self.query_response = None; let addr = format!("{}:{}", self.query_address, self.query_port); + let cmd = self.query_command.clone(); Task::perform( async move { use tscore::QueryClient; @@ -404,7 +497,7 @@ impl App { .await .map_err(|e| format!("Connect: {e}"))?; let resp = client - .execute("help") + .execute(&cmd) .await .map_err(|e| format!("Query: {e}"))?; Ok::(resp.raw) @@ -431,16 +524,10 @@ impl App { fn handle_book_event(&mut self, event: &Event) { match event { - Event::PropertyAdded { id, invoker: _, .. } => match id { - PropertyId::Server => { - self.refresh_server_state_from_handle(); - } - PropertyId::Client(_cid) => { - self.refresh_clients_from_handle(); - } - PropertyId::Channel(_chid) => { - self.refresh_channels_from_handle(); - } + Event::PropertyAdded { id, .. } => match id { + PropertyId::Server => self.refresh_server_state_from_handle(), + PropertyId::Client(_) => self.refresh_clients_from_handle(), + PropertyId::Channel(_) => self.refresh_channels_from_handle(), _ => {} }, Event::PropertyChanged { id, invoker, .. } => match id { @@ -451,19 +538,9 @@ impl App { } } } - PropertyId::ClientChannel(cid) => { - if let Some(c) = self.clients.iter_mut().find(|c| c.id == *cid) { - if let Some(inv) = invoker { - c.name = inv.name.clone(); - } - } - } - PropertyId::ClientInputMuted(_cid) => { - self.refresh_clients_from_handle(); - } - PropertyId::ChannelName(_chid) => { - self.refresh_channels_from_handle(); - } + PropertyId::ClientChannel(_) => self.refresh_clients_from_handle(), + PropertyId::ClientInputMuted(_) => self.refresh_clients_from_handle(), + PropertyId::ChannelName(_) => self.refresh_channels_from_handle(), _ => {} }, Event::PropertyRemoved { id, .. } => match id { @@ -503,35 +580,25 @@ 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; @@ -553,19 +620,13 @@ 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 }) @@ -586,18 +647,12 @@ 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 }) @@ -628,10 +683,7 @@ 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; } @@ -662,53 +714,111 @@ impl App { nav_button("Query", Page::ServerQuery, self.page), nav_button("Settings", Page::Settings, self.page), ] - .spacing(4) - .padding(8); + .spacing(2) + .padding(12); let content = match self.page { Page::ServerList => self.view_server_list(), - Page::ServerQuery => container(text("ServerQuery panel")).padding(8).into(), - Page::Settings => container(text("Settings panel")).padding(8).into(), + Page::ServerQuery => container(text("").size(12)).padding(8).into(), + Page::Settings => container(text("").size(12)).padding(8).into(), }; - column![nav, horizontal_rule(1), content] - .width(260) - .height(Length::Fill) - .into() + container( + column![nav, theme::separator_line(), content] + .width(280) + .height(Length::Fill), + ) + .style(theme::sidebar_container) + .width(280) + .height(Length::Fill) + .into() } fn view_server_list(&self) -> Element<'_, Message> { - let mut list = column![].spacing(2).padding(8); + let mut list = column![].spacing(4).padding(12); for (i, bookmark) in self.bookmarks.iter().enumerate() { let is_selected = self.selected_bookmark == Some(i); let btn = button( - column![ - text(&bookmark.name).size(14), - text(format!("{}:{}", bookmark.address, bookmark.port)).size(11), + 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)), ] - .spacing(2), + .align_y(iced::Alignment::Center) + .spacing(8), ) .width(Length::Fill) .on_press(Message::SelectBookmark(Some(i))) - .padding(8) + .padding([10, 12]) .style(if is_selected { - button::primary + theme::bookmark_button_selected } else { - button::secondary + theme::bookmark_button }); list = list.push(btn); } + if self.editing_bookmark { + let form = container( + column![ + text_input("Server name", &self.bm_name_input) + .on_input(Message::BookmarkNameChanged) + .style(theme::input_style), + text_input("Address", &self.bm_address_input) + .on_input(Message::BookmarkAddressChanged) + .style(theme::input_style), + text_input("Port", &self.bm_port_input) + .on_input(Message::BookmarkPortChanged) + .style(theme::input_style), + row![ + button(text("Save").size(12)) + .padding([6, 16]) + .style(theme::primary_button) + .on_press(Message::Connect), + button(text("Cancel").size(12)) + .padding([6, 16]) + .style(theme::secondary_button) + .on_press(Message::AddBookmark), + ] + .spacing(8), + ] + .spacing(8) + .padding(12), + ) + .style(theme::card_container) + .padding(8); + + list = list.push(form); + } + column![ - text("Server Bookmarks").size(16), + container( + row![ + text("Servers").size(14), + horizontal_space(), + button(text("+").size(16)) + .padding([2, 8]) + .style(theme::secondary_button) + .on_press(Message::AddBookmark), + ] + .align_y(iced::Alignment::Center), + ) + .padding([0, 12]), vertical_space().height(8), scrollable(list).height(Length::Fill), ] - .spacing(4) - .padding(8) .into() } @@ -724,135 +834,180 @@ impl App { if let Some(idx) = self.selected_bookmark { if let Some(bookmark) = self.bookmarks.get(idx) { let mut connect_form = column![ - text(format!("Connect to {}", bookmark.name)).size(20), - vertical_space().height(12), - row![ - text("Nickname:").width(80), - text_input("Enter nickname", &self.nickname) - .width(Length::Fill) - .on_input(Message::NicknameChanged), - ] - .spacing(8) - .align_y(iced::Alignment::Center), + text(bookmark.name.clone()).size(20), + text(format!("{}:{}", bookmark.address, bookmark.port)) + .size(12) + .style(text::secondary), + vertical_space().height(16), + text_input("Nickname", &self.nickname) + .on_input(Message::NicknameChanged) + .style(theme::input_style), vertical_space().height(8), - row![ - text("Password:").width(80), - text_input("Optional", &self.password) - .width(Length::Fill) - .secure(true) - .on_input(Message::PasswordChanged), - ] - .spacing(8) - .align_y(iced::Alignment::Center), - vertical_space().height(12), + text_input("Password", &self.password) + .secure(true) + .on_input(Message::PasswordChanged) + .style(theme::input_style), + vertical_space().height(16), row![ horizontal_space(), if self.connected { - button(text("Disconnect").size(14)) - .padding([8, 24]) + button(text("Disconnect").size(13)) + .padding([10, 28]) + .style(theme::danger_button) .on_press(Message::Disconnect) } else { - button(text("Connect").size(14)) - .padding([8, 24]) + button(text("Connect").size(13)) + .padding([10, 28]) + .style(theme::primary_button) .on_press(Message::Connect) }, ], ] .spacing(4) - .padding(16); + .padding(20); if let Some(err) = &self.error { - connect_form = connect_form.push(text(format!("Error: {err}")).size(12)); + connect_form = connect_form.push( + container(text(err.clone()).size(12).style(text::danger)) + .padding(10) + .style(theme::elevated_container), + ); } if self.identity_level > 0 { connect_form = connect_form.push( - text(format!( - "Computing identity level {}... this may take a while", - self.identity_level - )) - .size(12), + text(format!("Computing identity level {}...", self.identity_level)) + .size(12) + .style(text::secondary), ); } if self.connected { - let server_info = column![ - text(&self.server_name).size(18), - text(format!("{} / {}", self.server_platform, self.server_version)).size(12), - text(format!("max clients: {}", self.server_max_clients)).size(12), - ] - .spacing(4); + let server_header = container( + column![ + text(&self.server_name).size(18), + text(format!("{} / {} — {}/{} online", + self.server_platform, self.server_version, + self.clients.len(), self.server_max_clients)) + .size(12) + .style(text::secondary), + ] + .spacing(4), + ) + .padding(16) + .style(theme::card_container); - let mut channel_entries: Vec<(ChannelId, String)> = self - .channels - .iter() - .map(|ch| (ch.id, ch.name.clone())) - .collect(); - // Sort by order (use existing order from channels) - let order_map: std::collections::HashMap = - self.channels.iter().map(|ch| (ch.id, ch.order.0)).collect(); - channel_entries.sort_by_key(|(id, _)| order_map.get(id).copied().unwrap_or(0)); - - let mut channels = column![text("Channels").size(14)].spacing(2); - for (ch_id, ch_name) in &channel_entries { - let client_count = self.clients.iter().filter(|c| c.channel == *ch_id).count(); - channels = channels.push( + let mut channel_list = column![].spacing(2); + let mut sorted: Vec<&ChannelEntry> = self.channels.iter().collect(); + sorted.sort_by_key(|ch| ch.order.0); + for ch in &sorted { + let client_count = self.clients.iter().filter(|c| c.channel == ch.id).count(); + let is_current = self.own_client_id.is_some_and(|oid| { + self.clients.iter().any(|c| c.id == oid && c.channel == ch.id) + }); + let ch_row = button( row![ - text(ch_name.clone()).size(12).width(Length::Fill), - text(format!("{}", client_count)).size(11), + text(&ch.name).size(12).width(Length::Fill), + text(format!("{}", client_count)).size(11).style(text::secondary), ] - .spacing(8), + .spacing(8) + .padding([4, 0]), + ) + .width(Length::Fill) + .padding([6, 10]) + .style(if is_current { + theme::bookmark_button_selected + } else { + theme::bookmark_button + }) + .on_press(Message::JoinChannel(ch.id)); + channel_list = channel_list.push(ch_row); + } + + let channels_panel = container( + column![ + text("Channels").size(13), + vertical_space().height(8), + scrollable(channel_list).height(Length::Fill), + ] + .spacing(4) + .padding(12), + ) + .style(theme::card_container) + .height(Length::Fill); + + let mut client_list = column![].spacing(2); + for c in &self.clients { + let mute = if c.input_muted { " 🔇" } else { "" }; + client_list = client_list.push( + text(format!("{}{}", c.name, mute)).size(12), ); } - let mut clients = column![text("Clients").size(14)].spacing(2); - for c in &self.clients { - let mute_indicator = if c.input_muted { " [M]" } else { "" }; - clients = - clients.push(text(format!("{}{}", c.name, mute_indicator)).size(12)); - } + let clients_panel = container( + column![ + text("Clients").size(13), + vertical_space().height(8), + scrollable(client_list).height(Length::Fill), + ] + .spacing(4) + .padding(12), + ) + .style(theme::card_container) + .height(Length::Fill); - let mut messages_col = column![].spacing(4); + let mut messages_col = column![].spacing(6); for msg in &self.messages { messages_col = messages_col.push( - row![ - text(format!("{}:", msg.invoker_name)).size(12), - text(&msg.message).size(12), + column![ + text(&msg.invoker_name).size(11).style(text::secondary), + text(&msg.message).size(13), ] - .spacing(4), + .spacing(2), ); } - let message_input = row![ - text_input("Type a message...", &self.message_input) - .width(Length::Fill) - .on_input(Message::MessageInputChanged) - .on_submit(Message::SendChannelMessage), - button(text("Send").size(12)) - .padding([8, 16]) - .on_press(Message::SendChannelMessage), - ] - .spacing(8); + let messages_panel = container( + column![ + text("Chat").size(13), + vertical_space().height(8), + scrollable(messages_col).height(Length::Fill), + vertical_space().height(8), + row![ + text_input("Type a message...", &self.message_input) + .width(Length::Fill) + .on_input(Message::MessageInputChanged) + .on_submit(Message::SendChannelMessage) + .style(theme::input_style), + button(text("Send").size(12)) + .padding([8, 16]) + .style(theme::primary_button) + .on_press(Message::SendChannelMessage), + ] + .spacing(8), + ] + .spacing(4) + .padding(12), + ) + .style(theme::card_container) + .height(Length::Fill); column![ connect_form, - horizontal_rule(1), - server_info, vertical_space().height(12), - row![ - scrollable(channels).height(200), - scrollable(clients).height(200), - ] - .spacing(16), - horizontal_rule(1), - text("Messages").size(14), - scrollable(messages_col).height(120), - message_input, + server_header, + vertical_space().height(12), + row![channels_panel, clients_panel].spacing(12).height(250), + vertical_space().height(12), + messages_panel, ] - .spacing(8) - .padding(16) + .spacing(0) + .padding(20) .into() } else { - connect_form.into() + container(connect_form) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into() } } else { self.view_welcome() @@ -865,46 +1020,49 @@ impl App { fn view_query_content(&self) -> Element<'_, Message> { let mut content = column![ text("ServerQuery").size(20), - vertical_space().height(12), - row![ - text("Address:").width(80), - text_input("e.g. teamspeak.example.com", &self.query_address) - .width(Length::Fill) - .on_input(Message::QueryAddressChanged), - ] - .spacing(8) - .align_y(iced::Alignment::Center), + vertical_space().height(16), + text_input("Address", &self.query_address) + .on_input(Message::QueryAddressChanged) + .style(theme::input_style), vertical_space().height(8), row![ - text("Port:").width(80), - text_input("10011", &self.query_port.to_string()) - .width(Length::Fill) - .on_input(Message::QueryPortChanged), + text_input("Port", &self.query_port.to_string()) + .on_input(Message::QueryPortChanged) + .style(theme::input_style), + text_input("Command", &self.query_command) + .on_input(Message::QueryCommandChanged) + .style(theme::input_style), ] - .spacing(8) - .align_y(iced::Alignment::Center), + .spacing(8), vertical_space().height(12), row![ horizontal_space(), - button(text(if self.query_loading { "Loading..." } else { "Query" }).size(14)) - .padding([8, 24]) + button(text(if self.query_loading { "Running..." } else { "Execute" }).size(13)) + .padding([10, 24]) + .style(theme::primary_button) .on_press_maybe(if self.query_loading { None } else { Some(Message::RunServerQuery) }), ], ] .spacing(4) - .padding(16); + .padding(20); if let Some(resp) = &self.query_response { content = content.push( - column![ - text("Response:").size(14), - scrollable(text(resp).size(12)).height(300), - ] - .spacing(4), + container( + column![ + text("Response").size(13), + vertical_space().height(8), + scrollable(text(resp).size(12)).height(Length::Fill), + ] + .padding(12), + ) + .style(theme::card_container), ); } if let Some(err) = &self.query_error { - content = content.push(text(format!("Error: {err}")).size(12)); + content = content.push( + text(err.clone()).size(12).style(text::danger), + ); } content.into() @@ -913,11 +1071,27 @@ impl App { fn view_settings_content(&self) -> Element<'_, Message> { container( column![ - text("Settings").size(24), - vertical_space().height(12), - text("Settings will be available in a future update.").size(14), + text("Settings").size(20), + vertical_space().height(16), + container( + column![ + text("Audio Output Device").size(13), + text("System Default").size(12).style(text::secondary), + vertical_space().height(12), + text("Audio Input Device").size(13), + text("System Default").size(12).style(text::secondary), + vertical_space().height(12), + text("Push-to-Talk Key").size(13), + text("Not configured").size(12).style(text::secondary), + ] + .spacing(4) + .padding(16), + ) + .style(theme::card_container) + .width(400), ] - .align_x(iced::Alignment::Center), + .spacing(8) + .padding(20), ) .center_x(Length::Fill) .center_y(Length::Fill) @@ -927,9 +1101,11 @@ impl App { fn view_welcome(&self) -> Element<'_, Message> { container( column![ - text("Welcome to ReTeamSpeak").size(24), - vertical_space().height(12), - text("Select a server bookmark from the sidebar to connect.").size(14), + text("ReTeamSpeak").size(28), + vertical_space().height(8), + text("Select a server from the sidebar to get started.") + .size(14) + .style(text::secondary), ] .align_x(iced::Alignment::Center), ) @@ -950,9 +1126,7 @@ async fn run_connection(con: SyncConnection, event_tx: mpsc::Sender, au tsclientlib::AudioEvent::CanSendAudio(can) => TsEvent::AudioChange(can, true), tsclientlib::AudioEvent::CanReceiveAudio(can) => TsEvent::AudioChange(false, can), }, - Ok(SyncStreamItem::IdentityLevelIncreasing(level)) => { - TsEvent::IdentityLevelIncreasing(level) - } + Ok(SyncStreamItem::IdentityLevelIncreasing(level)) => TsEvent::IdentityLevelIncreasing(level), Ok(SyncStreamItem::IdentityLevelIncreased) => TsEvent::IdentityLevelIncreased, Ok(SyncStreamItem::DisconnectedTemporarily(_)) => TsEvent::DisconnectedTemporarily, Ok(SyncStreamItem::NetworkStatsUpdated) => continue, @@ -981,9 +1155,7 @@ async fn run_connection(con: SyncConnection, event_tx: mpsc::Sender) { tsclientlib::AudioEvent::CanSendAudio(can) => TsEvent::AudioChange(can, true), tsclientlib::AudioEvent::CanReceiveAudio(can) => TsEvent::AudioChange(false, can), }, - Ok(SyncStreamItem::IdentityLevelIncreasing(level)) => { - TsEvent::IdentityLevelIncreasing(level) - } + Ok(SyncStreamItem::IdentityLevelIncreasing(level)) => TsEvent::IdentityLevelIncreasing(level), Ok(SyncStreamItem::IdentityLevelIncreased) => TsEvent::IdentityLevelIncreased, Ok(SyncStreamItem::DisconnectedTemporarily(_)) => TsEvent::DisconnectedTemporarily, Ok(SyncStreamItem::NetworkStatsUpdated) => continue, @@ -999,12 +1171,12 @@ async fn run_connection(con: SyncConnection, event_tx: mpsc::Sender) { fn nav_button(label: &str, page: Page, current: Page) -> Element<'static, Message> { button(text(label.to_string()).size(12)) - .padding([6, 12]) + .padding([8, 16]) .on_press(Message::PageChanged(page)) .style(if current == page { - button::primary + theme::nav_button_active } else { - button::secondary + theme::nav_button_inactive }) .into() } diff --git a/src/iced-app/src/theme.rs b/src/iced-app/src/theme.rs new file mode 100644 index 0000000..c5a5e48 --- /dev/null +++ b/src/iced-app/src/theme.rs @@ -0,0 +1,400 @@ +use iced::widget::{button, container, text_input}; +use iced::{Background, Border, Color, Shadow, Theme}; + +// Apple-inspired dark palette +pub const BG_PRIMARY: Color = Color::from_rgb(0.11, 0.11, 0.118); // #1C1C1E +pub const BG_SECONDARY: Color = Color::from_rgb(0.17, 0.17, 0.18); // #2C2C2E +pub const BG_TERTIARY: Color = Color::from_rgb(0.22, 0.22, 0.235); // #38383A +pub const BG_ELEVATED: Color = Color::from_rgb(0.29, 0.29, 0.305); // #48484A +pub const TEXT_PRIMARY: Color = Color::from_rgb(0.95, 0.95, 0.97); // #F2F2F7 +pub const TEXT_SECONDARY: Color = Color::from_rgb(0.60, 0.60, 0.63); // #98989D +pub const TEXT_TERTIARY: Color = Color::from_rgb(0.42, 0.42, 0.44); // #6B6B70 +pub const ACCENT: Color = Color::from_rgb(0.29, 0.56, 1.0); // #4A90D9 +pub const ACCENT_HOVER: Color = Color::from_rgb(0.36, 0.63, 1.0); // #5CA0FF +pub const SUCCESS: Color = Color::from_rgb(0.29, 0.85, 0.56); // #4AD98F +pub const DANGER: Color = Color::from_rgb(1.0, 0.27, 0.23); // #FF453A +pub const SEPARATOR: Color = Color::from_rgb(0.33, 0.33, 0.35); // #545458 +pub const SIDEBAR_BG: Color = Color::from_rgb(0.09, 0.09, 0.098); // #171719 + +pub fn dark_theme() -> Theme { + Theme::custom( + "ReTeamSpeak".to_string(), + iced::theme::Palette { + background: BG_PRIMARY, + text: TEXT_PRIMARY, + primary: ACCENT, + success: SUCCESS, + danger: DANGER, + }, + ) +} + +pub fn sidebar_container(_theme: &Theme) -> container::Style { + container::Style { + background: Some(Background::Color(SIDEBAR_BG)), + border: Border { + color: SEPARATOR, + width: 0.0, + radius: 0.0.into(), + }, + ..container::Style::default() + } +} + +pub fn card_container(_theme: &Theme) -> container::Style { + container::Style { + background: Some(Background::Color(BG_SECONDARY)), + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 12.0.into(), + }, + shadow: Shadow { + color: Color::from_rgba(0.0, 0.0, 0.0, 0.3), + offset: iced::Vector::new(0.0, 2.0), + blur_radius: 8.0, + }, + ..container::Style::default() + } +} + +pub fn elevated_container(_theme: &Theme) -> container::Style { + container::Style { + background: Some(Background::Color(BG_TERTIARY)), + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 10.0.into(), + }, + ..container::Style::default() + } +} + +pub fn input_style(_theme: &Theme, status: text_input::Status) -> text_input::Style { + match status { + text_input::Status::Active => text_input::Style { + background: Background::Color(BG_TERTIARY), + border: Border { + color: SEPARATOR, + width: 1.0, + radius: 8.0.into(), + }, + icon: TEXT_SECONDARY, + placeholder: TEXT_TERTIARY, + value: TEXT_PRIMARY, + selection: ACCENT, + }, + text_input::Status::Focused => text_input::Style { + background: Background::Color(BG_TERTIARY), + border: Border { + color: ACCENT, + width: 1.5, + radius: 8.0.into(), + }, + icon: TEXT_SECONDARY, + placeholder: TEXT_TERTIARY, + value: TEXT_PRIMARY, + selection: ACCENT, + }, + text_input::Status::Hovered => text_input::Style { + background: Background::Color(BG_TERTIARY), + border: Border { + color: TEXT_TERTIARY, + width: 1.0, + radius: 8.0.into(), + }, + icon: TEXT_SECONDARY, + placeholder: TEXT_TERTIARY, + value: TEXT_PRIMARY, + selection: ACCENT, + }, + _ => text_input::Style { + background: Background::Color(BG_TERTIARY), + border: Border { + color: SEPARATOR, + width: 1.0, + radius: 8.0.into(), + }, + icon: TEXT_SECONDARY, + placeholder: TEXT_TERTIARY, + value: TEXT_PRIMARY, + selection: ACCENT, + }, + } +} + +pub fn primary_button(_theme: &Theme, status: button::Status) -> button::Style { + match status { + button::Status::Active => button::Style { + background: Some(Background::Color(ACCENT)), + text_color: Color::WHITE, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 8.0.into(), + }, + shadow: Shadow { + color: Color::from_rgba(0.29, 0.56, 1.0, 0.3), + offset: iced::Vector::new(0.0, 1.0), + blur_radius: 4.0, + }, + }, + button::Status::Hovered => button::Style { + background: Some(Background::Color(ACCENT_HOVER)), + text_color: Color::WHITE, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 8.0.into(), + }, + shadow: Shadow { + color: Color::from_rgba(0.29, 0.56, 1.0, 0.4), + offset: iced::Vector::new(0.0, 2.0), + blur_radius: 6.0, + }, + }, + button::Status::Pressed => button::Style { + background: Some(Background::Color(Color::from_rgb(0.22, 0.44, 0.80))), + text_color: Color::WHITE, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 8.0.into(), + }, + shadow: Shadow::default(), + }, + button::Status::Disabled => button::Style { + background: Some(Background::Color(BG_ELEVATED)), + text_color: TEXT_TERTIARY, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 8.0.into(), + }, + shadow: Shadow::default(), + }, + } +} + +pub fn secondary_button(_theme: &Theme, status: button::Status) -> button::Style { + match status { + button::Status::Active => button::Style { + background: Some(Background::Color(BG_TERTIARY)), + text_color: TEXT_PRIMARY, + border: Border { + color: SEPARATOR, + width: 1.0, + radius: 8.0.into(), + }, + shadow: Shadow::default(), + }, + button::Status::Hovered => button::Style { + background: Some(Background::Color(BG_ELEVATED)), + text_color: TEXT_PRIMARY, + border: Border { + color: TEXT_TERTIARY, + width: 1.0, + radius: 8.0.into(), + }, + shadow: Shadow::default(), + }, + button::Status::Pressed => button::Style { + background: Some(Background::Color(BG_SECONDARY)), + text_color: TEXT_PRIMARY, + border: Border { + color: SEPARATOR, + width: 1.0, + radius: 8.0.into(), + }, + shadow: Shadow::default(), + }, + _ => button::Style::default(), + } +} + +pub fn danger_button(_theme: &Theme, status: button::Status) -> button::Style { + match status { + button::Status::Active => button::Style { + background: Some(Background::Color(DANGER)), + text_color: Color::WHITE, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 8.0.into(), + }, + shadow: Shadow::default(), + }, + button::Status::Hovered => button::Style { + background: Some(Background::Color(Color::from_rgb(1.0, 0.35, 0.30))), + text_color: Color::WHITE, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 8.0.into(), + }, + shadow: Shadow::default(), + }, + _ => button::Style { + background: Some(Background::Color(DANGER)), + text_color: Color::WHITE, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 8.0.into(), + }, + shadow: Shadow::default(), + }, + } +} + +pub fn nav_button_active(_theme: &Theme, status: button::Status) -> button::Style { + match status { + button::Status::Active => button::Style { + background: Some(Background::Color(BG_TERTIARY)), + text_color: TEXT_PRIMARY, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 8.0.into(), + }, + shadow: Shadow::default(), + }, + button::Status::Hovered => button::Style { + background: Some(Background::Color(BG_ELEVATED)), + text_color: TEXT_PRIMARY, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 8.0.into(), + }, + shadow: Shadow::default(), + }, + _ => button::Style { + background: Some(Background::Color(BG_TERTIARY)), + text_color: TEXT_PRIMARY, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 8.0.into(), + }, + shadow: Shadow::default(), + }, + } +} + +pub fn nav_button_inactive(_theme: &Theme, status: button::Status) -> button::Style { + match status { + button::Status::Active => button::Style { + background: Some(Background::Color(Color::TRANSPARENT)), + text_color: TEXT_SECONDARY, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 8.0.into(), + }, + shadow: Shadow::default(), + }, + button::Status::Hovered => button::Style { + background: Some(Background::Color(BG_SECONDARY)), + text_color: TEXT_PRIMARY, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 8.0.into(), + }, + shadow: Shadow::default(), + }, + _ => button::Style { + background: Some(Background::Color(Color::TRANSPARENT)), + text_color: TEXT_SECONDARY, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 8.0.into(), + }, + shadow: Shadow::default(), + }, + } +} + +pub fn bookmark_button_selected(_theme: &Theme, status: button::Status) -> button::Style { + match status { + button::Status::Active => button::Style { + background: Some(Background::Color(BG_TERTIARY)), + text_color: TEXT_PRIMARY, + border: Border { + color: ACCENT, + width: 1.0, + radius: 10.0.into(), + }, + shadow: Shadow::default(), + }, + button::Status::Hovered => button::Style { + background: Some(Background::Color(BG_ELEVATED)), + text_color: TEXT_PRIMARY, + border: Border { + color: ACCENT, + width: 1.0, + radius: 10.0.into(), + }, + shadow: Shadow::default(), + }, + _ => button::Style { + background: Some(Background::Color(BG_TERTIARY)), + text_color: TEXT_PRIMARY, + border: Border { + color: ACCENT, + width: 1.0, + radius: 10.0.into(), + }, + shadow: Shadow::default(), + }, + } +} + +pub fn bookmark_button(_theme: &Theme, status: button::Status) -> button::Style { + match status { + button::Status::Active => button::Style { + background: Some(Background::Color(Color::TRANSPARENT)), + text_color: TEXT_SECONDARY, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 10.0.into(), + }, + shadow: Shadow::default(), + }, + button::Status::Hovered => button::Style { + background: Some(Background::Color(BG_SECONDARY)), + text_color: TEXT_PRIMARY, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 10.0.into(), + }, + shadow: Shadow::default(), + }, + _ => button::Style { + background: Some(Background::Color(Color::TRANSPARENT)), + text_color: TEXT_SECONDARY, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 10.0.into(), + }, + shadow: Shadow::default(), + }, + } +} + + +pub fn separator_line<'a>() -> iced::widget::Rule<'a, Theme> { + iced::widget::horizontal_rule(1).style(|_theme: &Theme| { + iced::widget::rule::Style { + color: SEPARATOR, + width: 1, + radius: 0.0.into(), + fill_mode: iced::widget::rule::FillMode::Full, + } + }) +}