648 lines
23 KiB
Rust
648 lines
23 KiB
Rust
use crate::core::{CoreController, messages::{CoreCommand, UiEvent}};
|
|
use crate::network::PeerState;
|
|
|
|
use iced::widget::{
|
|
container, column, row, text, button, text_input, scrollable, slider, checkbox, Column, Space,
|
|
};
|
|
use iced::{
|
|
Color, Background, Border, Element, Subscription, Task, Theme, Event, keyboard,
|
|
};
|
|
use iroh::EndpointId;
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, OnceLock};
|
|
use tokio::sync::Mutex;
|
|
|
|
static UI_RX: OnceLock<Mutex<Option<tokio::sync::mpsc::Receiver<UiEvent>>>> = OnceLock::new();
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Screen {
|
|
Home,
|
|
Room,
|
|
Settings,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum AppMessage {
|
|
NicknameChanged(String),
|
|
TicketInputChanged(String),
|
|
JoinPressed,
|
|
CreatePressed,
|
|
LeavePressed,
|
|
ToggleMutePressed,
|
|
ToggleDeafenPressed,
|
|
UiEventReceived(UiEvent),
|
|
CopyToClipboard,
|
|
TogglePtt(bool),
|
|
StartSettingHotkey,
|
|
PeerVolumeChanged(EndpointId, f32),
|
|
InputDeviceChanged(String),
|
|
OutputDeviceChanged(String),
|
|
EventOccurred(Event),
|
|
NavigateToSettings,
|
|
NavigateBack,
|
|
}
|
|
|
|
fn core_subscription() -> impl iced::futures::Stream<Item = UiEvent> {
|
|
iced::stream::channel(100, |mut output: iced::futures::channel::mpsc::Sender<UiEvent>| async move {
|
|
if let Some(rx_lock) = UI_RX.get() {
|
|
let mut guard = rx_lock.lock().await;
|
|
if let Some(mut rx) = guard.take() {
|
|
use iced::futures::sink::SinkExt;
|
|
while let Some(event) = rx.recv().await {
|
|
let _ = output.send(event).await;
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
pub struct AppState {
|
|
name: String,
|
|
ticket_input: String,
|
|
status_message: String,
|
|
self_id: String,
|
|
ticket: String,
|
|
is_muted: bool,
|
|
is_deafened: bool,
|
|
ptt_enabled: bool,
|
|
ptt_active: bool,
|
|
ptt_hotkey: keyboard::Key,
|
|
is_setting_hotkey: bool,
|
|
input_device: String,
|
|
output_device: String,
|
|
peers: HashMap<EndpointId, PeerState>,
|
|
peer_volumes: HashMap<EndpointId, f32>,
|
|
audio_levels: HashMap<EndpointId, f32>,
|
|
controller: Arc<CoreController>,
|
|
current_screen: Screen,
|
|
}
|
|
|
|
impl Default for AppState {
|
|
fn default() -> Self {
|
|
let (ui_tx, ui_rx) = tokio::sync::mpsc::channel(100);
|
|
let controller = Arc::new(CoreController::new(ui_tx));
|
|
let _ = UI_RX.set(Mutex::new(Some(ui_rx)));
|
|
|
|
Self {
|
|
name: "Peer".to_string(),
|
|
ticket_input: "".to_string(),
|
|
status_message: "Ready to connect".to_string(),
|
|
self_id: "".to_string(),
|
|
ticket: "".to_string(),
|
|
is_muted: false,
|
|
is_deafened: false,
|
|
ptt_enabled: false,
|
|
ptt_active: false,
|
|
ptt_hotkey: keyboard::Key::Named(keyboard::key::Named::Space),
|
|
is_setting_hotkey: false,
|
|
input_device: "".to_string(),
|
|
output_device: "".to_string(),
|
|
peers: HashMap::new(),
|
|
peer_volumes: HashMap::new(),
|
|
audio_levels: HashMap::new(),
|
|
controller,
|
|
current_screen: Screen::Home,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn theme(_state: &AppState) -> Theme {
|
|
Theme::Dark
|
|
}
|
|
|
|
pub fn run_gui() -> iced::Result {
|
|
iced::application(AppState::default, update, view)
|
|
.title("PeerSpeak P2P Voice Chat")
|
|
.theme(theme)
|
|
.subscription(subscription)
|
|
.window(iced::window::Settings {
|
|
size: iced::Size::new(900.0, 600.0),
|
|
position: iced::window::Position::Centered,
|
|
exit_on_close_request: true,
|
|
..Default::default()
|
|
})
|
|
.run()
|
|
}
|
|
|
|
fn subscription(state: &AppState) -> Subscription<AppMessage> {
|
|
let core_sub = Subscription::run(core_subscription).map(AppMessage::UiEventReceived);
|
|
let event_sub = iced::event::listen().map(AppMessage::EventOccurred);
|
|
Subscription::batch(vec![core_sub, event_sub])
|
|
}
|
|
|
|
fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|
match message {
|
|
AppMessage::NicknameChanged(val) => {
|
|
state.name = val;
|
|
}
|
|
AppMessage::TicketInputChanged(val) => {
|
|
state.ticket_input = val;
|
|
}
|
|
AppMessage::JoinPressed => {
|
|
state.status_message = "Connecting to room...".to_string();
|
|
let _ = state.controller.send(CoreCommand::Join {
|
|
name: state.name.clone(),
|
|
ticket: state.ticket_input.clone(),
|
|
input_device: if state.input_device.is_empty() { None } else { Some(state.input_device.clone()) },
|
|
output_device: if state.output_device.is_empty() { None } else { Some(state.output_device.clone()) },
|
|
});
|
|
}
|
|
AppMessage::CreatePressed => {
|
|
state.status_message = "Creating room...".to_string();
|
|
let _ = state.controller.send(CoreCommand::Join {
|
|
name: state.name.clone(),
|
|
ticket: "".to_string(),
|
|
input_device: if state.input_device.is_empty() { None } else { Some(state.input_device.clone()) },
|
|
output_device: if state.output_device.is_empty() { None } else { Some(state.output_device.clone()) },
|
|
});
|
|
}
|
|
AppMessage::LeavePressed => {
|
|
let _ = state.controller.send(CoreCommand::Leave);
|
|
}
|
|
AppMessage::ToggleMutePressed => {
|
|
let _ = state.controller.send(CoreCommand::ToggleMute);
|
|
state.is_muted = !state.is_muted;
|
|
}
|
|
AppMessage::ToggleDeafenPressed => {
|
|
let _ = state.controller.send(CoreCommand::ToggleDeafen);
|
|
state.is_deafened = !state.is_deafened;
|
|
}
|
|
AppMessage::UiEventReceived(event) => {
|
|
match event {
|
|
UiEvent::RoomJoined { ticket, self_id } => {
|
|
state.ticket = ticket;
|
|
state.self_id = self_id;
|
|
state.status_message = "Connected".to_string();
|
|
state.current_screen = Screen::Room;
|
|
}
|
|
UiEvent::RoomLeft => {
|
|
state.ticket = "".to_string();
|
|
state.peers.clear();
|
|
state.audio_levels.clear();
|
|
state.status_message = "Ready to connect".to_string();
|
|
state.current_screen = Screen::Home;
|
|
}
|
|
UiEvent::PeerJoined { id, state: peer_state } => {
|
|
state.peers.insert(id, peer_state);
|
|
}
|
|
UiEvent::PeerLeft { id } => {
|
|
state.peers.remove(&id);
|
|
state.audio_levels.remove(&id);
|
|
}
|
|
UiEvent::PeerUpdated { id, state: peer_state } => {
|
|
state.peers.insert(id, peer_state);
|
|
}
|
|
UiEvent::AudioLevels(levels) => {
|
|
for (id, val) in levels {
|
|
state.audio_levels.insert(id, val);
|
|
}
|
|
}
|
|
UiEvent::Error(err) => {
|
|
state.status_message = format!("Error: {}", err);
|
|
}
|
|
}
|
|
}
|
|
AppMessage::CopyToClipboard => {
|
|
if !state.ticket.is_empty() {
|
|
return iced::clipboard::write(state.ticket.clone());
|
|
}
|
|
}
|
|
AppMessage::TogglePtt(enabled) => {
|
|
state.ptt_enabled = enabled;
|
|
let _ = state.controller.send(CoreCommand::SetPttMode(enabled));
|
|
}
|
|
AppMessage::StartSettingHotkey => {
|
|
state.is_setting_hotkey = true;
|
|
}
|
|
AppMessage::PeerVolumeChanged(id, vol) => {
|
|
state.peer_volumes.insert(id, vol);
|
|
let _ = state.controller.send(CoreCommand::SetPeerVolume(id, vol));
|
|
}
|
|
AppMessage::InputDeviceChanged(val) => {
|
|
state.input_device = val;
|
|
}
|
|
AppMessage::OutputDeviceChanged(val) => {
|
|
state.output_device = val;
|
|
}
|
|
AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyPressed { key, .. })) => {
|
|
if state.is_setting_hotkey {
|
|
state.ptt_hotkey = key.clone();
|
|
state.is_setting_hotkey = false;
|
|
} else if state.ptt_enabled && key == state.ptt_hotkey && !state.ptt_active {
|
|
state.ptt_active = true;
|
|
let _ = state.controller.send(CoreCommand::SetPttActive(true));
|
|
}
|
|
}
|
|
AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyReleased { key, .. })) => {
|
|
if state.ptt_enabled && key == state.ptt_hotkey && state.ptt_active {
|
|
state.ptt_active = false;
|
|
let _ = state.controller.send(CoreCommand::SetPttActive(false));
|
|
}
|
|
}
|
|
AppMessage::EventOccurred(_) => {}
|
|
AppMessage::NavigateToSettings => {
|
|
state.current_screen = Screen::Settings;
|
|
}
|
|
AppMessage::NavigateBack => {
|
|
if state.ticket.is_empty() {
|
|
state.current_screen = Screen::Home;
|
|
} else {
|
|
state.current_screen = Screen::Room;
|
|
}
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn horizontal_space() -> iced::widget::Space {
|
|
iced::widget::Space::new().width(iced::Length::Fill)
|
|
}
|
|
|
|
fn vertical_space(height: f32) -> iced::widget::Space {
|
|
iced::widget::Space::new().height(height)
|
|
}
|
|
|
|
fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|
// Theme Colors
|
|
let color_crust = Color::from_rgb8(17, 17, 27);
|
|
let color_mantle = Color::from_rgb8(24, 24, 37);
|
|
let color_base = Color::from_rgb8(30, 30, 46);
|
|
let color_surface = Color::from_rgb8(49, 50, 68);
|
|
let color_text = Color::from_rgb8(205, 214, 244);
|
|
let color_subtext = Color::from_rgb8(166, 173, 200);
|
|
let color_blue = Color::from_rgb8(137, 180, 250);
|
|
let color_lavender = Color::from_rgb8(180, 190, 254);
|
|
let color_red = Color::from_rgb8(243, 139, 168);
|
|
let color_maroon = Color::from_rgb8(233, 146, 160);
|
|
let color_green = Color::from_rgb8(166, 227, 161);
|
|
|
|
// Style Helpers
|
|
let c_style = move |bg: Color, b_color: Color, radius: f32| {
|
|
move |_theme: &Theme| container::Style {
|
|
text_color: Some(color_text),
|
|
background: Some(Background::Color(bg)),
|
|
border: Border {
|
|
color: b_color,
|
|
width: if b_color == Color::TRANSPARENT { 0.0 } else { 1.0 },
|
|
radius: radius.into(),
|
|
},
|
|
..Default::default()
|
|
}
|
|
};
|
|
|
|
let b_style = move |bg: Color, hover_bg: Color, text_c: Color, radius: f32| {
|
|
move |_theme: &Theme, status: button::Status| {
|
|
let active_bg = match status {
|
|
button::Status::Hovered => hover_bg,
|
|
_ => bg,
|
|
};
|
|
button::Style {
|
|
background: Some(Background::Color(active_bg)),
|
|
text_color: text_c,
|
|
border: Border {
|
|
color: Color::TRANSPARENT,
|
|
width: 0.0,
|
|
radius: radius.into(),
|
|
},
|
|
..Default::default()
|
|
}
|
|
}
|
|
};
|
|
|
|
let t_style = move |_theme: &Theme, _status: text_input::Status| {
|
|
text_input::Style {
|
|
background: Background::Color(color_crust),
|
|
border: Border {
|
|
color: color_surface,
|
|
width: 1.0,
|
|
radius: 6.0.into(),
|
|
},
|
|
icon: color_subtext,
|
|
placeholder: Color::from_rgb8(108, 112, 134),
|
|
value: color_text,
|
|
selection: color_blue,
|
|
}
|
|
};
|
|
|
|
let top_bar = row![
|
|
horizontal_space(),
|
|
button(text("⚙ Settings").size(14))
|
|
.on_press(AppMessage::NavigateToSettings)
|
|
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
|
.padding(8)
|
|
].width(iced::Length::Fill).padding(10);
|
|
|
|
if state.current_screen == Screen::Settings {
|
|
let content = column![
|
|
text("Settings").size(32).color(color_text),
|
|
vertical_space(20.0),
|
|
text("Future settings and controls will be added here.").size(16).color(color_subtext),
|
|
vertical_space(40.0),
|
|
button(
|
|
text("Back")
|
|
.size(16)
|
|
.align_x(iced::alignment::Horizontal::Center)
|
|
)
|
|
.on_press(AppMessage::NavigateBack)
|
|
.style(b_style(color_surface, color_blue, color_text, 8.0))
|
|
.padding(14)
|
|
.width(iced::Length::Fixed(150.0))
|
|
]
|
|
.align_x(iced::alignment::Horizontal::Center)
|
|
.spacing(10);
|
|
|
|
return container(content)
|
|
.width(iced::Length::Fill)
|
|
.height(iced::Length::Fill)
|
|
.center_x(iced::Length::Fill)
|
|
.center_y(iced::Length::Fill)
|
|
.style(c_style(color_crust, Color::TRANSPARENT, 0.0))
|
|
.into();
|
|
}
|
|
|
|
if state.current_screen == Screen::Home {
|
|
// --- HOME SCREEN ---
|
|
let logo = text("PEERSPEAK")
|
|
.size(36)
|
|
.color(color_blue);
|
|
|
|
let subtitle = text("NAT-traversing full-mesh voice chat")
|
|
.size(16)
|
|
.color(color_subtext);
|
|
|
|
let nickname_input = column![
|
|
text("Nickname").size(14).color(color_subtext),
|
|
vertical_space(4.0),
|
|
text_input("Enter nickname...", &state.name)
|
|
.on_input(AppMessage::NicknameChanged)
|
|
.style(t_style)
|
|
.padding(10)
|
|
];
|
|
|
|
let create_btn = button(
|
|
text("Create New Room")
|
|
.size(16)
|
|
.align_x(iced::alignment::Horizontal::Center)
|
|
)
|
|
.on_press(AppMessage::CreatePressed)
|
|
.style(b_style(color_blue, color_lavender, color_crust, 8.0))
|
|
.padding(12)
|
|
.width(iced::Length::Fill);
|
|
|
|
let join_group = column![
|
|
text("Join Existing Room").size(14).color(color_subtext),
|
|
vertical_space(4.0),
|
|
text_input("Paste room ticket here...", &state.ticket_input)
|
|
.on_input(AppMessage::TicketInputChanged)
|
|
.style(t_style)
|
|
.padding(10),
|
|
vertical_space(8.0),
|
|
button(
|
|
text("Join Room")
|
|
.size(16)
|
|
.align_x(iced::alignment::Horizontal::Center)
|
|
)
|
|
.on_press(AppMessage::JoinPressed)
|
|
.style(b_style(color_surface, color_blue, color_text, 8.0))
|
|
.padding(12)
|
|
.width(iced::Length::Fill)
|
|
];
|
|
|
|
let status = text(&state.status_message)
|
|
.size(14)
|
|
.color(color_subtext);
|
|
|
|
let content = container(
|
|
column![
|
|
logo,
|
|
subtitle,
|
|
vertical_space(20.0),
|
|
nickname_input,
|
|
vertical_space(16.0),
|
|
create_btn,
|
|
vertical_space(16.0),
|
|
text("— OR —").size(12).color(color_surface).align_x(iced::alignment::Horizontal::Center),
|
|
vertical_space(16.0),
|
|
join_group,
|
|
vertical_space(16.0),
|
|
text("Device Settings (Optional Node Target IDs)").size(14).color(color_subtext),
|
|
row![
|
|
text_input("Input Target", &state.input_device).on_input(AppMessage::InputDeviceChanged).style(t_style.clone()).padding(8),
|
|
horizontal_space(),
|
|
text_input("Output Target", &state.output_device).on_input(AppMessage::OutputDeviceChanged).style(t_style.clone()).padding(8),
|
|
].spacing(10),
|
|
vertical_space(20.0),
|
|
status
|
|
]
|
|
.spacing(10)
|
|
.align_x(iced::alignment::Horizontal::Center)
|
|
)
|
|
.style(c_style(color_mantle, color_surface, 12.0))
|
|
.padding(30)
|
|
.width(420);
|
|
|
|
let scroll = scrollable(content);
|
|
|
|
container(
|
|
column![
|
|
top_bar,
|
|
vertical_space(20.0),
|
|
scroll
|
|
].align_x(iced::alignment::Horizontal::Center)
|
|
)
|
|
.width(iced::Length::Fill)
|
|
.height(iced::Length::Fill)
|
|
.style(c_style(color_crust, Color::TRANSPARENT, 0.0))
|
|
.into()
|
|
} else {
|
|
// --- ROOM SCREEN ---
|
|
let header = row![
|
|
text("PEERSPEAK")
|
|
.size(20)
|
|
.color(color_blue),
|
|
horizontal_space(),
|
|
text(format!("My ID: {}", &state.self_id[..8]))
|
|
.size(14)
|
|
.color(color_subtext),
|
|
horizontal_space(),
|
|
button(text("Copy Ticket").size(12))
|
|
.on_press(AppMessage::CopyToClipboard)
|
|
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
|
.padding(6)
|
|
]
|
|
.align_y(iced::alignment::Vertical::Center);
|
|
|
|
let header_container = container(header)
|
|
.style(c_style(color_mantle, color_surface, 8.0))
|
|
.padding(15)
|
|
.width(iced::Length::Fill);
|
|
|
|
// Peers Column
|
|
let mut peers_list = Column::new().spacing(10);
|
|
|
|
// Add ourselves
|
|
let self_card = container(
|
|
row![
|
|
text(format!("{} (You)", &state.name)).size(16).color(color_text),
|
|
horizontal_space(),
|
|
if state.is_muted {
|
|
text("[Muted]").size(14).color(color_red)
|
|
} else {
|
|
text("[Active]").size(14).color(color_green)
|
|
}
|
|
]
|
|
.align_y(iced::alignment::Vertical::Center)
|
|
)
|
|
.style(c_style(color_base, color_surface, 6.0))
|
|
.padding(12);
|
|
peers_list = peers_list.push(self_card);
|
|
|
|
for (peer_id, peer) in &state.peers {
|
|
let level = state.audio_levels.get(peer_id).copied().unwrap_or(0.0);
|
|
let is_speaking = level > 0.01;
|
|
|
|
let indicator = if peer.is_muted {
|
|
text("[Muted]").size(14).color(color_red)
|
|
} else if is_speaking {
|
|
text("[Speaking]").size(14).color(color_green)
|
|
} else {
|
|
text("[Idle]").size(14).color(color_subtext)
|
|
};
|
|
|
|
let mut card_content = column![
|
|
row![
|
|
column![
|
|
text(&peer.name).size(16).color(color_text),
|
|
text(format!("ID: {}", &peer_id.to_string()[..8])).size(11).color(color_subtext)
|
|
],
|
|
horizontal_space(),
|
|
indicator
|
|
]
|
|
.align_y(iced::alignment::Vertical::Center)
|
|
].spacing(8);
|
|
|
|
// Peer volume slider
|
|
let current_vol = state.peer_volumes.get(peer_id).copied().unwrap_or(1.0);
|
|
let peer_id_clone = *peer_id;
|
|
card_content = card_content.push(
|
|
row![
|
|
text("Vol:").size(12).color(color_subtext),
|
|
slider(0.0..=2.0, current_vol, move |v| AppMessage::PeerVolumeChanged(peer_id_clone, v))
|
|
].spacing(8).align_y(iced::alignment::Vertical::Center)
|
|
);
|
|
|
|
let card = container(card_content)
|
|
.style(c_style(
|
|
if is_speaking { color_base } else { color_mantle },
|
|
if is_speaking { color_green } else { color_surface },
|
|
6.0
|
|
))
|
|
.padding(12);
|
|
|
|
peers_list = peers_list.push(card);
|
|
}
|
|
|
|
let scroll_peers = scrollable(peers_list);
|
|
|
|
let peers_panel = container(
|
|
column![
|
|
text("Room Participants").size(18).color(color_blue),
|
|
vertical_space(10.0),
|
|
scroll_peers
|
|
]
|
|
)
|
|
.style(c_style(color_mantle, Color::TRANSPARENT, 0.0))
|
|
.padding(15)
|
|
.width(iced::Length::FillPortion(2))
|
|
.height(iced::Length::Fill);
|
|
|
|
// Control Panel Column
|
|
let mute_text = if state.is_muted { "Unmute Mic" } else { "Mute Mic" };
|
|
let mute_bg = if state.is_muted { color_red } else { color_surface };
|
|
let mute_hover = if state.is_muted { color_maroon } else { color_blue };
|
|
let mute_fg = if state.is_muted { color_crust } else { color_text };
|
|
|
|
let deafen_text = if state.is_deafened { "Undeafen Audio" } else { "Deafen Audio" };
|
|
let deafen_bg = if state.is_deafened { color_red } else { color_surface };
|
|
let deafen_hover = if state.is_deafened { color_maroon } else { color_blue };
|
|
let deafen_fg = if state.is_deafened { color_crust } else { color_text };
|
|
|
|
let ctrl_buttons = column![
|
|
button(
|
|
text(mute_text)
|
|
.size(16)
|
|
.align_x(iced::alignment::Horizontal::Center)
|
|
)
|
|
.on_press(AppMessage::ToggleMutePressed)
|
|
.style(b_style(mute_bg, mute_hover, mute_fg, 8.0))
|
|
.padding(14)
|
|
.width(iced::Length::Fill),
|
|
vertical_space(10.0),
|
|
button(
|
|
text(deafen_text)
|
|
.size(16)
|
|
.align_x(iced::alignment::Horizontal::Center)
|
|
)
|
|
.on_press(AppMessage::ToggleDeafenPressed)
|
|
.style(b_style(deafen_bg, deafen_hover, deafen_fg, 8.0))
|
|
.padding(14)
|
|
.width(iced::Length::Fill),
|
|
vertical_space(20.0),
|
|
checkbox(state.ptt_enabled).label("Push-to-Talk").on_toggle(AppMessage::TogglePtt),
|
|
vertical_space(10.0),
|
|
if state.ptt_enabled {
|
|
column![
|
|
text(format!("Hotkey: {}", if state.is_setting_hotkey { "Press any key...".to_string() } else { format!("{:?}", state.ptt_hotkey) })).size(14).color(color_subtext),
|
|
button(text("Set Hotkey").size(12).align_x(iced::alignment::Horizontal::Center))
|
|
.on_press(AppMessage::StartSettingHotkey)
|
|
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
|
.padding(8)
|
|
.width(iced::Length::Fill)
|
|
].spacing(8)
|
|
} else {
|
|
column![]
|
|
},
|
|
vertical_space(30.0),
|
|
button(
|
|
text("Leave Room")
|
|
.size(16)
|
|
.align_x(iced::alignment::Horizontal::Center)
|
|
)
|
|
.on_press(AppMessage::LeavePressed)
|
|
.style(b_style(color_red, color_maroon, color_crust, 8.0))
|
|
.padding(14)
|
|
.width(iced::Length::Fill)
|
|
];
|
|
|
|
let control_panel = container(
|
|
column![
|
|
text("Controls").size(18).color(color_blue),
|
|
vertical_space(15.0),
|
|
ctrl_buttons,
|
|
vertical_space(20.0),
|
|
text(&state.status_message).size(12).color(color_subtext)
|
|
]
|
|
)
|
|
.style(c_style(color_mantle, Color::TRANSPARENT, 0.0))
|
|
.padding(15)
|
|
.width(iced::Length::FillPortion(1))
|
|
.height(iced::Length::Fill);
|
|
|
|
let main_layout = row![peers_panel, control_panel].spacing(15);
|
|
|
|
container(
|
|
column![
|
|
top_bar,
|
|
header_container,
|
|
vertical_space(15.0),
|
|
main_layout
|
|
]
|
|
)
|
|
.padding(15)
|
|
.width(iced::Length::Fill)
|
|
.height(iced::Length::Fill)
|
|
.style(c_style(color_crust, Color::TRANSPARENT, 0.0))
|
|
.into()
|
|
}
|
|
}
|