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:
+413
-241
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user