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
This commit is contained in:
ReTeamSpeak
2026-05-13 10:31:07 +09:00
parent 073095b06a
commit 61f2d5bdb6
2 changed files with 813 additions and 241 deletions
+413 -241
View File
@@ -1,7 +1,7 @@
use iced::futures::sink::SinkExt; use iced::futures::sink::SinkExt;
use iced::futures::StreamExt; use iced::futures::StreamExt;
use iced::widget::{button, column, container, horizontal_rule, horizontal_space, row, scrollable, text, text_input, vertical_space}; use iced::widget::{button, column, container, horizontal_space, row, scrollable, text, text_input, vertical_space};
use iced::{Element, Length, Subscription, Task}; use iced::{Element, Length, Subscription, Task, Theme};
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::{mpsc, Mutex}; use tokio::sync::{mpsc, Mutex};
@@ -11,11 +11,14 @@ use tsclientlib::{ChannelId, ClientId, Connection, DisconnectOptions, MessageTar
use tsclientlib::events::{Event, PropertyId}; use tsclientlib::events::{Event, PropertyId};
use tsclientlib::prelude::*; use tsclientlib::prelude::*;
mod theme;
#[cfg(feature = "audio")] #[cfg(feature = "audio")]
mod audio; mod audio;
fn main() -> iced::Result { fn main() -> iced::Result {
iced::application("ReTeamSpeak", App::update, App::view) iced::application("ReTeamSpeak", App::update, App::view)
.theme(App::theme)
.subscription(App::subscription) .subscription(App::subscription)
.run_with(App::new) .run_with(App::new)
} }
@@ -32,15 +35,22 @@ enum Page {
enum Message { enum Message {
PageChanged(Page), PageChanged(Page),
SelectBookmark(Option<usize>), SelectBookmark(Option<usize>),
AddBookmark,
DeleteBookmark(usize),
BookmarkNameChanged(String),
BookmarkAddressChanged(String),
BookmarkPortChanged(String),
NicknameChanged(String), NicknameChanged(String),
PasswordChanged(String), PasswordChanged(String),
Connect, Connect,
JoinChannel(ChannelId),
Disconnect, Disconnect,
TsEvent(TsEvent), TsEvent(TsEvent),
MessageInputChanged(String), MessageInputChanged(String),
SendChannelMessage, SendChannelMessage,
QueryAddressChanged(String), QueryAddressChanged(String),
QueryPortChanged(String), QueryPortChanged(String),
QueryCommandChanged(String),
RunServerQuery, RunServerQuery,
QueryResponse(String), QueryResponse(String),
QueryError(String), QueryError(String),
@@ -97,6 +107,10 @@ struct App {
page: Page, page: Page,
bookmarks: Vec<BookmarkInfo>, bookmarks: Vec<BookmarkInfo>,
selected_bookmark: Option<usize>, selected_bookmark: Option<usize>,
editing_bookmark: bool,
bm_name_input: String,
bm_address_input: String,
bm_port_input: String,
nickname: String, nickname: String,
password: String, password: String,
connected: bool, connected: bool,
@@ -113,6 +127,7 @@ struct App {
message_input: String, message_input: String,
query_address: String, query_address: String,
query_port: u16, query_port: u16,
query_command: String,
query_loading: bool, query_loading: bool,
query_response: Option<String>, query_response: Option<String>,
query_error: Option<String>, query_error: Option<String>,
@@ -142,6 +157,10 @@ impl App {
}, },
], ],
selected_bookmark: None, 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(), nickname: String::new(),
password: String::new(), password: String::new(),
connected: false, connected: false,
@@ -158,6 +177,7 @@ impl App {
message_input: String::new(), message_input: String::new(),
query_address: String::new(), query_address: String::new(),
query_port: 10011, query_port: 10011,
query_command: "help".to_string(),
query_loading: false, query_loading: false,
query_response: None, query_response: None,
query_error: None, query_error: None,
@@ -171,6 +191,10 @@ impl App {
(app, Task::none()) (app, Task::none())
} }
fn theme(&self) -> Theme {
theme::dark_theme()
}
fn update(&mut self, message: Message) -> Task<Message> { fn update(&mut self, message: Message) -> Task<Message> {
match message { match message {
Message::PageChanged(page) => { Message::PageChanged(page) => {
@@ -179,15 +203,44 @@ impl App {
} }
Message::SelectBookmark(idx) => { Message::SelectBookmark(idx) => {
self.selected_bookmark = idx; self.selected_bookmark = idx;
self.editing_bookmark = false;
if let Some(i) = idx { if let Some(i) = idx {
if let Some(b) = self.bookmarks.get(i) { if let Some(b) = self.bookmarks.get(i) {
if let Some(nick) = &b.nickname { self.nickname = b.nickname.clone().unwrap_or_default();
self.nickname = nick.clone();
}
} }
} }
Task::none() 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) => { Message::NicknameChanged(n) => {
self.nickname = n; self.nickname = n;
Task::none() Task::none()
@@ -197,6 +250,20 @@ impl App {
Task::none() Task::none()
} }
Message::Connect => { 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)) { let bookmark = match self.selected_bookmark.and_then(|i| self.bookmarks.get(i)) {
Some(b) => b.clone(), Some(b) => b.clone(),
None => return Task::none(), 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 => { Message::Disconnect => {
let h = self.handle.clone(); let h = self.handle.clone();
let rx = self.event_rx.clone(); let rx = self.event_rx.clone();
@@ -357,17 +445,17 @@ impl App {
async move { async move {
let mut guard = h.lock().await; let mut guard = h.lock().await;
if let Some(ref mut handle) = *guard { if let Some(ref mut handle) = *guard {
let result = handle let result = handle
.with_connection(move |con| -> Result<(), String> { .with_connection(move |con| -> Result<(), String> {
let state = con.get_state().map_err(|e| e.to_string())?; let state = con.get_state().map_err(|e| e.to_string())?;
let cmd = state.send_message(MessageTarget::Channel, &msg); let cmd = state.send_message(MessageTarget::Channel, &msg);
cmd.send(con).map_err(|e| e.to_string())?; cmd.send(con).map_err(|e| e.to_string())?;
Ok(()) Ok(())
}) })
.await; .await;
if let Err(e) = result { if let Err(e) = result {
return Some(e.to_string()); return Some(e.to_string());
} }
} }
None None
}, },
@@ -388,6 +476,10 @@ impl App {
self.query_port = port.parse().unwrap_or(10011); self.query_port = port.parse().unwrap_or(10011);
Task::none() Task::none()
} }
Message::QueryCommandChanged(cmd) => {
self.query_command = cmd;
Task::none()
}
Message::RunServerQuery => { Message::RunServerQuery => {
if self.query_address.is_empty() { if self.query_address.is_empty() {
self.query_error = Some("Address required".to_string()); self.query_error = Some("Address required".to_string());
@@ -397,6 +489,7 @@ impl App {
self.query_error = None; self.query_error = None;
self.query_response = None; self.query_response = None;
let addr = format!("{}:{}", self.query_address, self.query_port); let addr = format!("{}:{}", self.query_address, self.query_port);
let cmd = self.query_command.clone();
Task::perform( Task::perform(
async move { async move {
use tscore::QueryClient; use tscore::QueryClient;
@@ -404,7 +497,7 @@ impl App {
.await .await
.map_err(|e| format!("Connect: {e}"))?; .map_err(|e| format!("Connect: {e}"))?;
let resp = client let resp = client
.execute("help") .execute(&cmd)
.await .await
.map_err(|e| format!("Query: {e}"))?; .map_err(|e| format!("Query: {e}"))?;
Ok::<String, String>(resp.raw) Ok::<String, String>(resp.raw)
@@ -431,16 +524,10 @@ impl App {
fn handle_book_event(&mut self, event: &Event) { fn handle_book_event(&mut self, event: &Event) {
match event { match event {
Event::PropertyAdded { id, invoker: _, .. } => match id { Event::PropertyAdded { id, .. } => match id {
PropertyId::Server => { PropertyId::Server => self.refresh_server_state_from_handle(),
self.refresh_server_state_from_handle(); PropertyId::Client(_) => self.refresh_clients_from_handle(),
} PropertyId::Channel(_) => self.refresh_channels_from_handle(),
PropertyId::Client(_cid) => {
self.refresh_clients_from_handle();
}
PropertyId::Channel(_chid) => {
self.refresh_channels_from_handle();
}
_ => {} _ => {}
}, },
Event::PropertyChanged { id, invoker, .. } => match id { Event::PropertyChanged { id, invoker, .. } => match id {
@@ -451,19 +538,9 @@ impl App {
} }
} }
} }
PropertyId::ClientChannel(cid) => { PropertyId::ClientChannel(_) => self.refresh_clients_from_handle(),
if let Some(c) = self.clients.iter_mut().find(|c| c.id == *cid) { PropertyId::ClientInputMuted(_) => self.refresh_clients_from_handle(),
if let Some(inv) = invoker { PropertyId::ChannelName(_) => self.refresh_channels_from_handle(),
c.name = inv.name.clone();
}
}
}
PropertyId::ClientInputMuted(_cid) => {
self.refresh_clients_from_handle();
}
PropertyId::ChannelName(_chid) => {
self.refresh_channels_from_handle();
}
_ => {} _ => {}
}, },
Event::PropertyRemoved { id, .. } => match id { Event::PropertyRemoved { id, .. } => match id {
@@ -503,35 +580,25 @@ impl App {
state.server.version.clone(), state.server.version.clone(),
state.server.max_clients, state.server.max_clients,
state.own_client, state.own_client,
state state.channels.values().map(|ch| ChannelEntry {
.channels id: ch.id,
.values() name: ch.name.clone(),
.map(|ch| ChannelEntry { parent: ch.parent,
id: ch.id, order: ch.order,
name: ch.name.clone(), }).collect::<Vec<_>>(),
parent: ch.parent, state.clients.values().map(|c| ClientEntry {
order: ch.order, id: c.id,
}) name: c.name.clone(),
.collect::<Vec<_>>(), channel: c.channel,
state input_muted: c.input_muted,
.clients output_muted: c.output_muted,
.values() }).collect::<Vec<_>>(),
.map(|c| ClientEntry {
id: c.id,
name: c.name.clone(),
channel: c.channel,
input_muted: c.input_muted,
output_muted: c.output_muted,
})
.collect::<Vec<_>>(),
)); ));
} }
None None
}) })
.await; .await;
if let Ok(Some((name, platform, version, max_clients, own_id, channels, clients))) = if let Ok(Some((name, platform, version, max_clients, own_id, channels, clients))) = result {
result
{
self.server_name = name; self.server_name = name;
self.server_platform = platform; self.server_platform = platform;
self.server_version = version; self.server_version = version;
@@ -553,19 +620,13 @@ impl App {
let result = handle let result = handle
.with_connection(|con| { .with_connection(|con| {
if let Ok(state) = con.get_state() { if let Ok(state) = con.get_state() {
return Some( return Some(state.clients.values().map(|c| ClientEntry {
state id: c.id,
.clients name: c.name.clone(),
.values() channel: c.channel,
.map(|c| ClientEntry { input_muted: c.input_muted,
id: c.id, output_muted: c.output_muted,
name: c.name.clone(), }).collect::<Vec<_>>());
channel: c.channel,
input_muted: c.input_muted,
output_muted: c.output_muted,
})
.collect::<Vec<_>>(),
);
} }
None None
}) })
@@ -586,18 +647,12 @@ impl App {
let result = handle let result = handle
.with_connection(|con| { .with_connection(|con| {
if let Ok(state) = con.get_state() { if let Ok(state) = con.get_state() {
return Some( return Some(state.channels.values().map(|ch| ChannelEntry {
state id: ch.id,
.channels name: ch.name.clone(),
.values() parent: ch.parent,
.map(|ch| ChannelEntry { order: ch.order,
id: ch.id, }).collect::<Vec<_>>());
name: ch.name.clone(),
parent: ch.parent,
order: ch.order,
})
.collect::<Vec<_>>(),
);
} }
None None
}) })
@@ -628,10 +683,7 @@ impl App {
}; };
match event { match event {
Some(event) => { Some(event) => {
let is_disconnect = matches!( let is_disconnect = matches!(event, TsEvent::Disconnected | TsEvent::Error(_));
event,
TsEvent::Disconnected | TsEvent::Error(_)
);
if sender.send(Message::TsEvent(event)).await.is_err() { if sender.send(Message::TsEvent(event)).await.is_err() {
break; break;
} }
@@ -662,53 +714,111 @@ impl App {
nav_button("Query", Page::ServerQuery, self.page), nav_button("Query", Page::ServerQuery, self.page),
nav_button("Settings", Page::Settings, self.page), nav_button("Settings", Page::Settings, self.page),
] ]
.spacing(4) .spacing(2)
.padding(8); .padding(12);
let content = match self.page { let content = match self.page {
Page::ServerList => self.view_server_list(), Page::ServerList => self.view_server_list(),
Page::ServerQuery => container(text("ServerQuery panel")).padding(8).into(), Page::ServerQuery => container(text("").size(12)).padding(8).into(),
Page::Settings => container(text("Settings panel")).padding(8).into(), Page::Settings => container(text("").size(12)).padding(8).into(),
}; };
column![nav, horizontal_rule(1), content] container(
.width(260) column![nav, theme::separator_line(), content]
.height(Length::Fill) .width(280)
.into() .height(Length::Fill),
)
.style(theme::sidebar_container)
.width(280)
.height(Length::Fill)
.into()
} }
fn view_server_list(&self) -> Element<'_, Message> { 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() { for (i, bookmark) in self.bookmarks.iter().enumerate() {
let is_selected = self.selected_bookmark == Some(i); let is_selected = self.selected_bookmark == Some(i);
let btn = button( let btn = button(
column![ row![
text(&bookmark.name).size(14), column![
text(format!("{}:{}", bookmark.address, bookmark.port)).size(11), 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) .width(Length::Fill)
.on_press(Message::SelectBookmark(Some(i))) .on_press(Message::SelectBookmark(Some(i)))
.padding(8) .padding([10, 12])
.style(if is_selected { .style(if is_selected {
button::primary theme::bookmark_button_selected
} else { } else {
button::secondary theme::bookmark_button
}); });
list = list.push(btn); 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![ 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), vertical_space().height(8),
scrollable(list).height(Length::Fill), scrollable(list).height(Length::Fill),
] ]
.spacing(4)
.padding(8)
.into() .into()
} }
@@ -724,135 +834,180 @@ impl App {
if let Some(idx) = self.selected_bookmark { if let Some(idx) = self.selected_bookmark {
if let Some(bookmark) = self.bookmarks.get(idx) { if let Some(bookmark) = self.bookmarks.get(idx) {
let mut connect_form = column![ let mut connect_form = column![
text(format!("Connect to {}", bookmark.name)).size(20), text(bookmark.name.clone()).size(20),
vertical_space().height(12), text(format!("{}:{}", bookmark.address, bookmark.port))
row![ .size(12)
text("Nickname:").width(80), .style(text::secondary),
text_input("Enter nickname", &self.nickname) vertical_space().height(16),
.width(Length::Fill) text_input("Nickname", &self.nickname)
.on_input(Message::NicknameChanged), .on_input(Message::NicknameChanged)
] .style(theme::input_style),
.spacing(8)
.align_y(iced::Alignment::Center),
vertical_space().height(8), vertical_space().height(8),
row![ text_input("Password", &self.password)
text("Password:").width(80), .secure(true)
text_input("Optional", &self.password) .on_input(Message::PasswordChanged)
.width(Length::Fill) .style(theme::input_style),
.secure(true) vertical_space().height(16),
.on_input(Message::PasswordChanged),
]
.spacing(8)
.align_y(iced::Alignment::Center),
vertical_space().height(12),
row![ row![
horizontal_space(), horizontal_space(),
if self.connected { if self.connected {
button(text("Disconnect").size(14)) button(text("Disconnect").size(13))
.padding([8, 24]) .padding([10, 28])
.style(theme::danger_button)
.on_press(Message::Disconnect) .on_press(Message::Disconnect)
} else { } else {
button(text("Connect").size(14)) button(text("Connect").size(13))
.padding([8, 24]) .padding([10, 28])
.style(theme::primary_button)
.on_press(Message::Connect) .on_press(Message::Connect)
}, },
], ],
] ]
.spacing(4) .spacing(4)
.padding(16); .padding(20);
if let Some(err) = &self.error { 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 { if self.identity_level > 0 {
connect_form = connect_form.push( connect_form = connect_form.push(
text(format!( text(format!("Computing identity level {}...", self.identity_level))
"Computing identity level {}... this may take a while", .size(12)
self.identity_level .style(text::secondary),
))
.size(12),
); );
} }
if self.connected { if self.connected {
let server_info = column![ let server_header = container(
text(&self.server_name).size(18), column![
text(format!("{} / {}", self.server_platform, self.server_version)).size(12), text(&self.server_name).size(18),
text(format!("max clients: {}", self.server_max_clients)).size(12), text(format!("{} / {}{}/{} online",
] self.server_platform, self.server_version,
.spacing(4); 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 let mut channel_list = column![].spacing(2);
.channels let mut sorted: Vec<&ChannelEntry> = self.channels.iter().collect();
.iter() sorted.sort_by_key(|ch| ch.order.0);
.map(|ch| (ch.id, ch.name.clone())) for ch in &sorted {
.collect(); let client_count = self.clients.iter().filter(|c| c.channel == ch.id).count();
// Sort by order (use existing order from channels) let is_current = self.own_client_id.is_some_and(|oid| {
let order_map: std::collections::HashMap<ChannelId, u64> = self.clients.iter().any(|c| c.id == oid && c.channel == ch.id)
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 ch_row = button(
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(
row![ row![
text(ch_name.clone()).size(12).width(Length::Fill), text(&ch.name).size(12).width(Length::Fill),
text(format!("{}", client_count)).size(11), 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); let clients_panel = container(
for c in &self.clients { column![
let mute_indicator = if c.input_muted { " [M]" } else { "" }; text("Clients").size(13),
clients = vertical_space().height(8),
clients.push(text(format!("{}{}", c.name, mute_indicator)).size(12)); 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 { for msg in &self.messages {
messages_col = messages_col.push( messages_col = messages_col.push(
row![ column![
text(format!("{}:", msg.invoker_name)).size(12), text(&msg.invoker_name).size(11).style(text::secondary),
text(&msg.message).size(12), text(&msg.message).size(13),
] ]
.spacing(4), .spacing(2),
); );
} }
let message_input = row![ let messages_panel = container(
text_input("Type a message...", &self.message_input) column![
.width(Length::Fill) text("Chat").size(13),
.on_input(Message::MessageInputChanged) vertical_space().height(8),
.on_submit(Message::SendChannelMessage), scrollable(messages_col).height(Length::Fill),
button(text("Send").size(12)) vertical_space().height(8),
.padding([8, 16]) row![
.on_press(Message::SendChannelMessage), text_input("Type a message...", &self.message_input)
] .width(Length::Fill)
.spacing(8); .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![ column![
connect_form, connect_form,
horizontal_rule(1),
server_info,
vertical_space().height(12), vertical_space().height(12),
row![ server_header,
scrollable(channels).height(200), vertical_space().height(12),
scrollable(clients).height(200), row![channels_panel, clients_panel].spacing(12).height(250),
] vertical_space().height(12),
.spacing(16), messages_panel,
horizontal_rule(1),
text("Messages").size(14),
scrollable(messages_col).height(120),
message_input,
] ]
.spacing(8) .spacing(0)
.padding(16) .padding(20)
.into() .into()
} else { } else {
connect_form.into() container(connect_form)
.center_x(Length::Fill)
.center_y(Length::Fill)
.into()
} }
} else { } else {
self.view_welcome() self.view_welcome()
@@ -865,46 +1020,49 @@ impl App {
fn view_query_content(&self) -> Element<'_, Message> { fn view_query_content(&self) -> Element<'_, Message> {
let mut content = column![ let mut content = column![
text("ServerQuery").size(20), text("ServerQuery").size(20),
vertical_space().height(12), vertical_space().height(16),
row![ text_input("Address", &self.query_address)
text("Address:").width(80), .on_input(Message::QueryAddressChanged)
text_input("e.g. teamspeak.example.com", &self.query_address) .style(theme::input_style),
.width(Length::Fill)
.on_input(Message::QueryAddressChanged),
]
.spacing(8)
.align_y(iced::Alignment::Center),
vertical_space().height(8), vertical_space().height(8),
row![ row![
text("Port:").width(80), text_input("Port", &self.query_port.to_string())
text_input("10011", &self.query_port.to_string()) .on_input(Message::QueryPortChanged)
.width(Length::Fill) .style(theme::input_style),
.on_input(Message::QueryPortChanged), text_input("Command", &self.query_command)
.on_input(Message::QueryCommandChanged)
.style(theme::input_style),
] ]
.spacing(8) .spacing(8),
.align_y(iced::Alignment::Center),
vertical_space().height(12), vertical_space().height(12),
row![ row![
horizontal_space(), horizontal_space(),
button(text(if self.query_loading { "Loading..." } else { "Query" }).size(14)) button(text(if self.query_loading { "Running..." } else { "Execute" }).size(13))
.padding([8, 24]) .padding([10, 24])
.style(theme::primary_button)
.on_press_maybe(if self.query_loading { None } else { Some(Message::RunServerQuery) }), .on_press_maybe(if self.query_loading { None } else { Some(Message::RunServerQuery) }),
], ],
] ]
.spacing(4) .spacing(4)
.padding(16); .padding(20);
if let Some(resp) = &self.query_response { if let Some(resp) = &self.query_response {
content = content.push( content = content.push(
column![ container(
text("Response:").size(14), column![
scrollable(text(resp).size(12)).height(300), text("Response").size(13),
] vertical_space().height(8),
.spacing(4), scrollable(text(resp).size(12)).height(Length::Fill),
]
.padding(12),
)
.style(theme::card_container),
); );
} }
if let Some(err) = &self.query_error { 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() content.into()
@@ -913,11 +1071,27 @@ impl App {
fn view_settings_content(&self) -> Element<'_, Message> { fn view_settings_content(&self) -> Element<'_, Message> {
container( container(
column![ column![
text("Settings").size(24), text("Settings").size(20),
vertical_space().height(12), vertical_space().height(16),
text("Settings will be available in a future update.").size(14), 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_x(Length::Fill)
.center_y(Length::Fill) .center_y(Length::Fill)
@@ -927,9 +1101,11 @@ impl App {
fn view_welcome(&self) -> Element<'_, Message> { fn view_welcome(&self) -> Element<'_, Message> {
container( container(
column![ column![
text("Welcome to ReTeamSpeak").size(24), text("ReTeamSpeak").size(28),
vertical_space().height(12), vertical_space().height(8),
text("Select a server bookmark from the sidebar to connect.").size(14), text("Select a server from the sidebar to get started.")
.size(14)
.style(text::secondary),
] ]
.align_x(iced::Alignment::Center), .align_x(iced::Alignment::Center),
) )
@@ -950,9 +1126,7 @@ async fn run_connection(con: SyncConnection, event_tx: mpsc::Sender<TsEvent>, au
tsclientlib::AudioEvent::CanSendAudio(can) => TsEvent::AudioChange(can, true), tsclientlib::AudioEvent::CanSendAudio(can) => TsEvent::AudioChange(can, true),
tsclientlib::AudioEvent::CanReceiveAudio(can) => TsEvent::AudioChange(false, can), tsclientlib::AudioEvent::CanReceiveAudio(can) => TsEvent::AudioChange(false, can),
}, },
Ok(SyncStreamItem::IdentityLevelIncreasing(level)) => { Ok(SyncStreamItem::IdentityLevelIncreasing(level)) => TsEvent::IdentityLevelIncreasing(level),
TsEvent::IdentityLevelIncreasing(level)
}
Ok(SyncStreamItem::IdentityLevelIncreased) => TsEvent::IdentityLevelIncreased, Ok(SyncStreamItem::IdentityLevelIncreased) => TsEvent::IdentityLevelIncreased,
Ok(SyncStreamItem::DisconnectedTemporarily(_)) => TsEvent::DisconnectedTemporarily, Ok(SyncStreamItem::DisconnectedTemporarily(_)) => TsEvent::DisconnectedTemporarily,
Ok(SyncStreamItem::NetworkStatsUpdated) => continue, Ok(SyncStreamItem::NetworkStatsUpdated) => continue,
@@ -981,9 +1155,7 @@ async fn run_connection(con: SyncConnection, event_tx: mpsc::Sender<TsEvent>) {
tsclientlib::AudioEvent::CanSendAudio(can) => TsEvent::AudioChange(can, true), tsclientlib::AudioEvent::CanSendAudio(can) => TsEvent::AudioChange(can, true),
tsclientlib::AudioEvent::CanReceiveAudio(can) => TsEvent::AudioChange(false, can), tsclientlib::AudioEvent::CanReceiveAudio(can) => TsEvent::AudioChange(false, can),
}, },
Ok(SyncStreamItem::IdentityLevelIncreasing(level)) => { Ok(SyncStreamItem::IdentityLevelIncreasing(level)) => TsEvent::IdentityLevelIncreasing(level),
TsEvent::IdentityLevelIncreasing(level)
}
Ok(SyncStreamItem::IdentityLevelIncreased) => TsEvent::IdentityLevelIncreased, Ok(SyncStreamItem::IdentityLevelIncreased) => TsEvent::IdentityLevelIncreased,
Ok(SyncStreamItem::DisconnectedTemporarily(_)) => TsEvent::DisconnectedTemporarily, Ok(SyncStreamItem::DisconnectedTemporarily(_)) => TsEvent::DisconnectedTemporarily,
Ok(SyncStreamItem::NetworkStatsUpdated) => continue, Ok(SyncStreamItem::NetworkStatsUpdated) => continue,
@@ -999,12 +1171,12 @@ async fn run_connection(con: SyncConnection, event_tx: mpsc::Sender<TsEvent>) {
fn nav_button(label: &str, page: Page, current: Page) -> Element<'static, Message> { fn nav_button(label: &str, page: Page, current: Page) -> Element<'static, Message> {
button(text(label.to_string()).size(12)) button(text(label.to_string()).size(12))
.padding([6, 12]) .padding([8, 16])
.on_press(Message::PageChanged(page)) .on_press(Message::PageChanged(page))
.style(if current == page { .style(if current == page {
button::primary theme::nav_button_active
} else { } else {
button::secondary theme::nav_button_inactive
}) })
.into() .into()
} }
+400
View File
@@ -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,
}
})
}