The controls column (mute/deafen/PTT/echo/record/share/Leave) had no scroll, so on a short window the bottom of it — including Leave — was clipped with no way to reach it; the only workaround was enlarging the window. At a small enough size you couldn't exit the call through the UI at all. Fix: pin Leave at the bottom of the control panel and wrap the controls above it in a scrollable (height Fill). The controls now scroll when the window is too short, and Leave (the exit control) is always visible. Applies to all three room layouts (control_panel is Fill-height in every arm). Build + clippy clean. Manual check: create a room, shrink the window — Leave stays put, controls scroll. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3123 lines
129 KiB
Rust
3123 lines
129 KiB
Rust
use crate::core::{CoreController, messages::{CoreCommand, UiEvent}};
|
||
use crate::network::PeerState;
|
||
use crate::notify::{self, Sound};
|
||
use crate::audio::pw_cli::{AudioDevice, enumerate_audio_devices};
|
||
use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout};
|
||
use crate::theme::{AppTheme, Palette};
|
||
|
||
use iced::widget::{
|
||
container, column, row, text, button, text_input, scrollable, slider, checkbox, pick_list,
|
||
radio, tooltip, progress_bar, canvas, Canvas, Column, stack, mouse_area,
|
||
};
|
||
use iced::widget::canvas::{Action, Frame, Geometry, Path, Program};
|
||
use iced::{
|
||
Color, Background, Border, Element, Subscription, Task, Theme, Event, keyboard, mouse,
|
||
Point, Rectangle, Renderer, Size,
|
||
};
|
||
use iroh::EndpointId;
|
||
use std::collections::{HashMap, HashSet};
|
||
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,
|
||
}
|
||
|
||
/// One rendered room-chat line. `mine` distinguishes our own (locally echoed)
|
||
/// messages from peers' for colouring.
|
||
#[derive(Debug, Clone)]
|
||
struct ChatEntry {
|
||
name: String,
|
||
text: String,
|
||
mine: bool,
|
||
}
|
||
|
||
/// Cap on retained chat history so a long call can't grow it without bound.
|
||
const CHAT_HISTORY_MAX: usize = 300;
|
||
|
||
/// Which room-screen divider a drag is resizing.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum DividerKind {
|
||
/// Vertical divider between the Participants and Controls panels (resizes the
|
||
/// Participants panel width).
|
||
Panels,
|
||
/// Horizontal divider between the main row and the Chat dock (resizes the
|
||
/// Chat dock height).
|
||
Chat,
|
||
/// Vertical divider between Chat and Controls in the 3-column layout (resizes
|
||
/// the Controls panel width).
|
||
Controls,
|
||
/// Vertical divider on the left edge of the Chat drawer (resizes the drawer
|
||
/// width) in the drawer layout.
|
||
ChatDrawer,
|
||
}
|
||
|
||
/// Minimum width of the Participants panel (px).
|
||
const PARTICIPANTS_MIN_W: f32 = 200.0;
|
||
/// Minimum width reserved for the Controls panel when resizing Participants (px).
|
||
const CONTROLS_MIN_W: f32 = 220.0;
|
||
/// Minimum height of the Chat dock (px).
|
||
const CHAT_MIN_H: f32 = 110.0;
|
||
/// Minimum height reserved above the Chat dock (header + main row) when resizing
|
||
/// the dock (px).
|
||
const ABOVE_CHAT_MIN_H: f32 = 300.0;
|
||
/// Thickness of a draggable divider (px).
|
||
const DIVIDER_THICKNESS: f32 = 8.0;
|
||
|
||
/// Clamp the Participants panel width so neither it nor the Controls panel drops
|
||
/// below its minimum, given the current window width.
|
||
fn clamp_participants_width(width: f32, window_w: f32) -> f32 {
|
||
let max = (window_w - CONTROLS_MIN_W).max(PARTICIPANTS_MIN_W);
|
||
width.clamp(PARTICIPANTS_MIN_W, max)
|
||
}
|
||
|
||
/// Clamp the Chat dock height so neither it nor the area above it drops below its
|
||
/// minimum, given the current window height.
|
||
fn clamp_chat_height(height: f32, window_h: f32) -> f32 {
|
||
let max = (window_h - ABOVE_CHAT_MIN_H).max(CHAT_MIN_H);
|
||
height.clamp(CHAT_MIN_H, max)
|
||
}
|
||
|
||
/// Minimum width of the Chat column / drawer (px).
|
||
const CHAT_MIN_W: f32 = 200.0;
|
||
|
||
/// Clamp the Controls panel width (3-column layout) so neither it nor the rest of
|
||
/// the row drops below its minimum, given the current window width.
|
||
fn clamp_controls_width(width: f32, window_w: f32) -> f32 {
|
||
// Leave room for the Participants panel + a minimum Chat column.
|
||
let max = (window_w - PARTICIPANTS_MIN_W - CHAT_MIN_W).max(CONTROLS_MIN_W);
|
||
width.clamp(CONTROLS_MIN_W, max)
|
||
}
|
||
|
||
/// Clamp the Chat drawer width (drawer layout) so neither it nor the rest of the
|
||
/// row drops below its minimum, given the current window width.
|
||
fn clamp_chat_drawer_width(width: f32, window_w: f32) -> f32 {
|
||
let max = (window_w - PARTICIPANTS_MIN_W - CONTROLS_MIN_W).max(CHAT_MIN_W);
|
||
width.clamp(CHAT_MIN_W, max)
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub enum AppMessage {
|
||
NicknameChanged(String),
|
||
TicketInputChanged(String),
|
||
JoinPressed,
|
||
CreatePressed,
|
||
LeavePressed,
|
||
ToggleMutePressed,
|
||
ToggleDeafenPressed,
|
||
UiEventReceived(UiEvent),
|
||
CopyToClipboard,
|
||
TogglePtt(bool),
|
||
StartSettingHotkey,
|
||
PeerVolumeChanged(EndpointId, f32),
|
||
/// Toggle local mute of a peer (silence them just for us).
|
||
TogglePeerMute(EndpointId),
|
||
InputDeviceSelected(AudioDevice),
|
||
OutputDeviceSelected(AudioDevice),
|
||
/// Live input-gain drag (applies immediately, persisted on release).
|
||
InputVolumeChanged(f32),
|
||
/// Live output-gain drag (applies immediately, persisted on release).
|
||
OutputVolumeChanged(f32),
|
||
/// Persist the current config to disk (slider release).
|
||
PersistConfig,
|
||
NoiseGateChanged(f32),
|
||
/// Live value while dragging the gate handle on the meter — updates the gate
|
||
/// immediately but does not persist (saved once on release via NoiseGateChanged).
|
||
NoiseGateDragging(f32),
|
||
NetworkModeSelected(NetworkMode),
|
||
RecordingModeSelected(RecordingMode),
|
||
EventOccurred(Event),
|
||
NavigateToSettings,
|
||
NavigateBack,
|
||
ToggleNotifications(bool),
|
||
ToggleEchoCancellation(bool),
|
||
CustomSoundPathChanged(Sound, String),
|
||
ToggleMicTest(bool),
|
||
/// Start/stop recording the call; the core confirms via Recording{Started,Stopped}.
|
||
ToggleRecording,
|
||
/// Live edits to the chat input line.
|
||
ChatInputChanged(String),
|
||
/// Send the current chat input line (Enter or the Send button).
|
||
ChatSubmit,
|
||
/// A room divider was dragged by the given pixel delta along its drag axis
|
||
/// (horizontal for the Panels divider, vertical for the Chat divider).
|
||
DividerDragged(DividerKind, f32),
|
||
/// Open / close the room-layout picker popup.
|
||
OpenLayoutPicker,
|
||
CloseLayoutPicker,
|
||
/// Choose a room layout (applied live + persisted, closes the popup).
|
||
SelectRoomLayout(RoomLayout),
|
||
/// Choose a UI theme (applied live + persisted).
|
||
SelectTheme(AppTheme),
|
||
/// Toggle the Chat drawer open/closed (drawer layout).
|
||
ToggleDrawerChat,
|
||
/// Start/stop sharing our own screen (spawns/kills a pixelpass host).
|
||
ToggleScreenShare,
|
||
/// Watch a peer's screen share, identified by their pixelpass ticket.
|
||
WatchShare(String),
|
||
}
|
||
|
||
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_devices: Vec<AudioDevice>,
|
||
output_devices: Vec<AudioDevice>,
|
||
selected_input: Option<AudioDevice>,
|
||
selected_output: Option<AudioDevice>,
|
||
config: AppConfig,
|
||
peers: HashMap<EndpointId, PeerState>,
|
||
peer_volumes: HashMap<EndpointId, f32>,
|
||
audio_levels: HashMap<EndpointId, f32>,
|
||
/// Peers we've locally muted (their audio isn't mixed into our output).
|
||
locally_muted: HashSet<EndpointId>,
|
||
/// When we joined the current room, for the in-room call-duration timer.
|
||
call_started: Option<std::time::Instant>,
|
||
/// Whether a local call recording is in progress (confirmed by the core).
|
||
recording: bool,
|
||
/// When the current recording started, for the header REC timer.
|
||
recording_started: Option<std::time::Instant>,
|
||
/// Room text-chat history (newest last) and the pending input line.
|
||
chat_messages: Vec<ChatEntry>,
|
||
chat_input: String,
|
||
/// Last known window size, tracked so divider clamps stay valid on resize.
|
||
/// (The divider positions themselves are persisted in `config`.)
|
||
window_size: Size,
|
||
/// Whether the room-layout picker popup is open (launch + in-call screens).
|
||
layout_picker_open: bool,
|
||
/// Whether the Chat drawer is open (drawer layout only).
|
||
drawer_chat_open: bool,
|
||
/// Raw mic level (normalized RMS, `0.0..=1.0`) for the settings meter.
|
||
mic_level: f32,
|
||
/// Whether the standalone (off-call) mic test stream is running.
|
||
mic_test_active: bool,
|
||
/// Peers whose audio link is currently down (initial connect or reconnect).
|
||
connecting: HashSet<EndpointId>,
|
||
/// Peers we've had a live link to at least once — used to say "Reconnecting"
|
||
/// rather than "Connecting" the second time around.
|
||
ever_connected: HashSet<EndpointId>,
|
||
controller: Arc<CoreController>,
|
||
current_screen: Screen,
|
||
/// Whether we're currently sharing our own screen (confirmed by the core).
|
||
self_sharing: bool,
|
||
/// Whether the `pixelpass` binary is available, gating the Share controls.
|
||
pixelpass_available: bool,
|
||
}
|
||
|
||
impl AppState {
|
||
fn custom_sound_path(&self, sound: Sound) -> &str {
|
||
let opt = match sound {
|
||
Sound::SelfJoin => &self.config.custom_sound_self_join,
|
||
Sound::PeerJoin => &self.config.custom_sound_peer_join,
|
||
Sound::PeerLeave => &self.config.custom_sound_peer_leave,
|
||
Sound::ReconnectAttempt => &self.config.custom_sound_reconnect_attempt,
|
||
Sound::Reconnected => &self.config.custom_sound_reconnected,
|
||
Sound::SelfLeave => &self.config.custom_sound_self_leave,
|
||
Sound::MicToggle => &self.config.custom_sound_mic_toggle,
|
||
Sound::ReconnectFailed => &self.config.custom_sound_reconnect_failed,
|
||
};
|
||
opt.as_deref().unwrap_or("")
|
||
}
|
||
}
|
||
|
||
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)));
|
||
|
||
let mut config = AppConfig::load();
|
||
// The window opens at the restored size (see `run_gui`); clamp the
|
||
// persisted divider positions against THAT size, not a hardcoded default,
|
||
// so they stay valid for the window we're actually about to show.
|
||
let (ww, wh) = (config.window_width, config.window_height);
|
||
config.participants_width =
|
||
clamp_participants_width(config.participants_width, ww);
|
||
config.chat_height = clamp_chat_height(config.chat_height, wh);
|
||
config.controls_width = clamp_controls_width(config.controls_width, ww);
|
||
config.chat_drawer_width = clamp_chat_drawer_width(config.chat_drawer_width, ww);
|
||
notify::set_enabled(config.notifications_enabled);
|
||
let _ = controller.send(CoreCommand::SetNoiseGateThreshold(config.noise_gate_threshold));
|
||
let _ = controller.send(CoreCommand::SetInputVolume(config.input_volume));
|
||
let _ = controller.send(CoreCommand::SetOutputVolume(config.output_volume));
|
||
let _ = controller.send(CoreCommand::SetNetworkMode(config.network_mode));
|
||
let _ = controller.send(CoreCommand::SetRecordingMode(config.recording_mode));
|
||
let _ = controller.send(CoreCommand::SetPixelpassPath(config.pixelpass_path.clone()));
|
||
let pixelpass_available =
|
||
crate::screenshare::is_available(config.pixelpass_path.as_deref());
|
||
let all_devices = enumerate_audio_devices();
|
||
let input_devices: Vec<_> = all_devices.iter().filter(|d| d.is_input).cloned().collect();
|
||
let output_devices: Vec<_> = all_devices.iter().filter(|d| !d.is_input).cloned().collect();
|
||
|
||
let selected_input = input_devices.iter().find(|d| d.name == config.input_device).cloned();
|
||
let selected_output = output_devices.iter().find(|d| d.name == config.output_device).cloned();
|
||
|
||
|
||
Self {
|
||
// Pre-fill the nickname with the last one used (or "Peer" by default).
|
||
name: config.username.clone(),
|
||
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_devices,
|
||
output_devices,
|
||
selected_input,
|
||
selected_output,
|
||
config,
|
||
peers: HashMap::new(),
|
||
peer_volumes: HashMap::new(),
|
||
audio_levels: HashMap::new(),
|
||
locally_muted: HashSet::new(),
|
||
call_started: None,
|
||
recording: false,
|
||
recording_started: None,
|
||
chat_messages: Vec::new(),
|
||
chat_input: String::new(),
|
||
window_size: Size::new(ww, wh),
|
||
layout_picker_open: false,
|
||
drawer_chat_open: false,
|
||
mic_level: 0.0,
|
||
mic_test_active: false,
|
||
connecting: HashSet::new(),
|
||
ever_connected: HashSet::new(),
|
||
controller,
|
||
current_screen: Screen::Home,
|
||
self_sharing: false,
|
||
pixelpass_available,
|
||
}
|
||
}
|
||
}
|
||
|
||
fn theme(state: &AppState) -> Theme {
|
||
state.config.theme.base_theme()
|
||
}
|
||
|
||
pub fn run_gui() -> iced::Result {
|
||
// Restore the last window size (saved on close). Position is restored too,
|
||
// but only on X11 — Wayland's xdg-shell gives clients no way to set their own
|
||
// position, so we center there and leave placement to the compositor.
|
||
let saved = AppConfig::load();
|
||
let init_size = iced::Size::new(saved.window_width, saved.window_height);
|
||
let init_position = initial_window_position(saved.window_x, saved.window_y, is_wayland());
|
||
iced::application(AppState::default, update, view)
|
||
.title("PeerSpeak P2P Voice Chat")
|
||
.theme(theme)
|
||
.subscription(subscription)
|
||
.window(iced::window::Settings {
|
||
// Restored from config (defaults 900×760: taller so the bottom chat
|
||
// dock doesn't squeeze the controls column). Layout is responsive.
|
||
size: init_size,
|
||
position: init_position,
|
||
// App/taskbar icon (mainly used on X11/XWayland; native Wayland takes
|
||
// the icon from the .desktop file matched by app_id instead).
|
||
icon: window_icon(),
|
||
// app_id must match the .desktop basename so Wayland compositors
|
||
// (e.g. KWin) attach our launcher icon to the window.
|
||
platform_specific: iced::window::settings::PlatformSpecific {
|
||
application_id: "peerspeak".to_string(),
|
||
..Default::default()
|
||
},
|
||
// We save the final size ourselves on CloseRequested, then exit.
|
||
exit_on_close_request: false,
|
||
..Default::default()
|
||
})
|
||
.run()
|
||
}
|
||
|
||
/// Build the window icon from an embedded 128×128 straight-RGBA blob rendered
|
||
/// from `assets/icons/peerspeak.svg`. Using `from_rgba` (always available) keeps
|
||
/// us off iced's heavy `image` feature — the blob is raw pixels, no decoder.
|
||
fn window_icon() -> Option<iced::window::Icon> {
|
||
const RGBA: &[u8] = include_bytes!("../../assets/icons/peerspeak-128.rgba");
|
||
iced::window::icon::from_rgba(RGBA.to_vec(), 128, 128).ok()
|
||
}
|
||
|
||
/// True when running under a Wayland compositor (winit will use its Wayland
|
||
/// backend). Mirrors winit's own selection: it prefers Wayland when
|
||
/// `WAYLAND_DISPLAY` is set, otherwise falls back to X11 via `DISPLAY`.
|
||
fn is_wayland() -> bool {
|
||
std::env::var_os("WAYLAND_DISPLAY").is_some_and(|v| !v.is_empty())
|
||
}
|
||
|
||
/// Decide the initial window position from the saved coordinates.
|
||
///
|
||
/// Position restore only works on **X11** — Wayland's xdg-shell gives clients no
|
||
/// way to place their own window, so we center there and let the compositor
|
||
/// decide. Returns `Centered` when we're on Wayland or have no saved position.
|
||
fn initial_window_position(
|
||
saved_x: Option<i32>,
|
||
saved_y: Option<i32>,
|
||
is_wayland: bool,
|
||
) -> iced::window::Position {
|
||
match (saved_x, saved_y) {
|
||
(Some(x), Some(y)) if !is_wayland => {
|
||
iced::window::Position::Specific(iced::Point::new(x as f32, y as f32))
|
||
}
|
||
_ => iced::window::Position::Centered,
|
||
}
|
||
}
|
||
|
||
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])
|
||
}
|
||
|
||
/// Reconnect-chime edge trigger for `UiEvent::PeerConnecting`. Marks the peer as
|
||
/// connecting and returns `Some(Sound::ReconnectAttempt)` exactly once per outage:
|
||
/// only when the peer had a live link before (a genuine reconnect, not a first
|
||
/// dial) AND we weren't already in the connecting state (so the supervisor's
|
||
/// repeated redials while still down don't re-chime). Pure so the once-per-
|
||
/// disconnect behavior is unit-testable without a GUI or audio.
|
||
fn reconnect_attempt_chime(
|
||
connecting: &mut HashSet<EndpointId>,
|
||
ever_connected: &HashSet<EndpointId>,
|
||
id: EndpointId,
|
||
) -> Option<Sound> {
|
||
let is_reconnect_attempt = ever_connected.contains(&id);
|
||
let was_already_connecting = connecting.contains(&id);
|
||
connecting.insert(id);
|
||
(is_reconnect_attempt && !was_already_connecting).then_some(Sound::ReconnectAttempt)
|
||
}
|
||
|
||
/// Reconnect-chime edge trigger for `UiEvent::PeerConnected`. Clears the connecting
|
||
/// state, records that we've linked with this peer at least once, and returns
|
||
/// `Some(Sound::Reconnected)` only if it had connected before (a true reconnect, not
|
||
/// the first link). Pure so the logic is unit-testable.
|
||
fn reconnected_chime(
|
||
connecting: &mut HashSet<EndpointId>,
|
||
ever_connected: &mut HashSet<EndpointId>,
|
||
id: EndpointId,
|
||
) -> Option<Sound> {
|
||
let was_reconnect = ever_connected.contains(&id);
|
||
connecting.remove(&id);
|
||
ever_connected.insert(id);
|
||
was_reconnect.then_some(Sound::Reconnected)
|
||
}
|
||
|
||
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 => {
|
||
let input_device = state.selected_input.as_ref().map(|d| d.name.clone());
|
||
let output_device = state.selected_output.as_ref().map(|d| d.name.clone());
|
||
if !state.ticket_input.is_empty() {
|
||
state.status_message = "Joining room...".to_string();
|
||
// Remember this nickname for next launch.
|
||
state.config.username = state.name.clone();
|
||
state.config.save();
|
||
// Core releases any standalone mic monitor on join.
|
||
state.mic_test_active = false;
|
||
let _ = state.controller.send(CoreCommand::Join {
|
||
name: state.name.clone(),
|
||
ticket: state.ticket_input.clone(),
|
||
input_device,
|
||
output_device,
|
||
echo_cancellation: state.config.echo_cancellation_enabled,
|
||
});
|
||
}
|
||
}
|
||
AppMessage::CreatePressed => {
|
||
let input_device = state.selected_input.as_ref().map(|d| d.name.clone());
|
||
let output_device = state.selected_output.as_ref().map(|d| d.name.clone());
|
||
state.status_message = "Creating room...".to_string();
|
||
// Remember this nickname for next launch.
|
||
state.config.username = state.name.clone();
|
||
state.config.save();
|
||
// Core releases any standalone mic monitor on join.
|
||
state.mic_test_active = false;
|
||
let _ = state.controller.send(CoreCommand::Join {
|
||
name: state.name.clone(),
|
||
ticket: "create".to_string(),
|
||
input_device,
|
||
output_device,
|
||
echo_cancellation: state.config.echo_cancellation_enabled,
|
||
});
|
||
}
|
||
AppMessage::LeavePressed => {
|
||
let _ = state.controller.send(CoreCommand::Leave);
|
||
}
|
||
AppMessage::ToggleScreenShare => {
|
||
if state.self_sharing {
|
||
let _ = state.controller.send(CoreCommand::StopScreenShare);
|
||
} else {
|
||
let _ = state.controller.send(CoreCommand::StartScreenShare);
|
||
state.status_message = "Starting screen share…".to_string();
|
||
}
|
||
}
|
||
AppMessage::WatchShare(ticket) => {
|
||
let _ = state.controller.send(CoreCommand::ViewShare(ticket));
|
||
state.status_message = "Opening screen share…".to_string();
|
||
}
|
||
AppMessage::ToggleMutePressed => {
|
||
let _ = state.controller.send(CoreCommand::ToggleMute);
|
||
state.is_muted = !state.is_muted;
|
||
notify::play(Sound::MicToggle, state.config.custom_sound_mic_toggle.as_deref());
|
||
}
|
||
AppMessage::ToggleDeafenPressed => {
|
||
let _ = state.controller.send(CoreCommand::ToggleDeafen);
|
||
state.is_deafened = !state.is_deafened;
|
||
notify::play(Sound::MicToggle, state.config.custom_sound_mic_toggle.as_deref());
|
||
}
|
||
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;
|
||
state.call_started = Some(std::time::Instant::now());
|
||
// The core tore down any standalone mic monitor when joining;
|
||
// the in-call meter now drives mic_level.
|
||
state.mic_test_active = false;
|
||
notify::play(Sound::SelfJoin, state.config.custom_sound_self_join.as_deref());
|
||
}
|
||
UiEvent::RoomLeft => {
|
||
state.ticket = "".to_string();
|
||
state.peers.clear();
|
||
state.audio_levels.clear();
|
||
state.locally_muted.clear();
|
||
state.call_started = None;
|
||
state.recording = false;
|
||
state.recording_started = None;
|
||
state.chat_messages.clear();
|
||
state.chat_input.clear();
|
||
state.connecting.clear();
|
||
state.ever_connected.clear();
|
||
state.status_message = "Ready to connect".to_string();
|
||
state.current_screen = Screen::Home;
|
||
state.mic_level = 0.0;
|
||
state.self_sharing = false;
|
||
notify::play(Sound::SelfLeave, state.config.custom_sound_self_leave.as_deref());
|
||
}
|
||
UiEvent::PeerJoined { id, state: peer_state } => {
|
||
state.peers.insert(id, peer_state);
|
||
notify::play(Sound::PeerJoin, state.config.custom_sound_peer_join.as_deref());
|
||
}
|
||
UiEvent::PeerLeft { id } => {
|
||
state.peers.remove(&id);
|
||
state.audio_levels.remove(&id);
|
||
state.locally_muted.remove(&id);
|
||
state.connecting.remove(&id);
|
||
state.ever_connected.remove(&id);
|
||
notify::play(Sound::PeerLeave, state.config.custom_sound_peer_leave.as_deref());
|
||
}
|
||
UiEvent::PeerConnectionFailed { id } => {
|
||
state.peers.remove(&id);
|
||
state.audio_levels.remove(&id);
|
||
state.locally_muted.remove(&id);
|
||
state.connecting.remove(&id);
|
||
state.ever_connected.remove(&id);
|
||
notify::play(Sound::ReconnectFailed, state.config.custom_sound_reconnect_failed.as_deref());
|
||
}
|
||
UiEvent::PeerUpdated { id, state: peer_state } => {
|
||
state.peers.insert(id, peer_state);
|
||
}
|
||
UiEvent::PeerConnecting { id } => {
|
||
if let Some(sound) =
|
||
reconnect_attempt_chime(&mut state.connecting, &state.ever_connected, id)
|
||
{
|
||
notify::play(sound, state.config.custom_sound_reconnect_attempt.as_deref());
|
||
}
|
||
}
|
||
UiEvent::PeerConnected { id } => {
|
||
if let Some(sound) =
|
||
reconnected_chime(&mut state.connecting, &mut state.ever_connected, id)
|
||
{
|
||
notify::play(sound, state.config.custom_sound_reconnected.as_deref());
|
||
}
|
||
}
|
||
UiEvent::AudioLevels(levels) => {
|
||
for (id, val) in levels {
|
||
state.audio_levels.insert(id, val);
|
||
}
|
||
}
|
||
UiEvent::MicLevel(level) => {
|
||
state.mic_level = level;
|
||
}
|
||
UiEvent::RecordingStarted { path } => {
|
||
state.recording = true;
|
||
state.recording_started = Some(std::time::Instant::now());
|
||
state.status_message = format!("Recording → {path}");
|
||
}
|
||
UiEvent::RecordingStopped { path } => {
|
||
state.recording = false;
|
||
state.recording_started = None;
|
||
state.status_message = format!("Saved recording → {path}");
|
||
}
|
||
UiEvent::ChatMessage { name, text } => {
|
||
// Incoming peer content is untrusted — sanitize name + text.
|
||
let text = sanitize_chat(&text);
|
||
if !text.is_empty() {
|
||
let name = sanitize_chat(&name);
|
||
push_chat(&mut state.chat_messages, ChatEntry { name, text, mine: false });
|
||
}
|
||
}
|
||
UiEvent::ScreenShareStarted => {
|
||
state.self_sharing = true;
|
||
state.status_message = "Sharing your screen".to_string();
|
||
}
|
||
UiEvent::ScreenShareStopped => {
|
||
state.self_sharing = false;
|
||
state.status_message = "Screen share stopped".to_string();
|
||
}
|
||
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::TogglePeerMute(id) => {
|
||
let now_muted = if state.locally_muted.contains(&id) {
|
||
state.locally_muted.remove(&id);
|
||
false
|
||
} else {
|
||
state.locally_muted.insert(id);
|
||
true
|
||
};
|
||
let _ = state.controller.send(CoreCommand::SetPeerMuted(id, now_muted));
|
||
}
|
||
AppMessage::InputDeviceSelected(dev) => {
|
||
state.config.input_device = dev.name.clone();
|
||
state.config.save();
|
||
state.selected_input = Some(dev);
|
||
}
|
||
AppMessage::OutputDeviceSelected(dev) => {
|
||
state.config.output_device = dev.name.clone();
|
||
state.config.save();
|
||
state.selected_output = Some(dev);
|
||
}
|
||
AppMessage::InputVolumeChanged(vol) => {
|
||
// Live apply; disk write deferred to release (PersistConfig).
|
||
state.config.input_volume = vol;
|
||
let _ = state.controller.send(CoreCommand::SetInputVolume(vol));
|
||
}
|
||
AppMessage::OutputVolumeChanged(vol) => {
|
||
state.config.output_volume = vol;
|
||
let _ = state.controller.send(CoreCommand::SetOutputVolume(vol));
|
||
}
|
||
AppMessage::PersistConfig => {
|
||
state.config.save();
|
||
}
|
||
AppMessage::NoiseGateChanged(val) => {
|
||
state.config.noise_gate_threshold = val;
|
||
state.config.save();
|
||
let _ = state.controller.send(CoreCommand::SetNoiseGateThreshold(val));
|
||
}
|
||
AppMessage::NoiseGateDragging(val) => {
|
||
// Live drag: apply immediately, defer the disk write to release.
|
||
state.config.noise_gate_threshold = val;
|
||
let _ = state.controller.send(CoreCommand::SetNoiseGateThreshold(val));
|
||
}
|
||
AppMessage::NetworkModeSelected(mode) => {
|
||
state.config.network_mode = mode;
|
||
state.config.save();
|
||
// Applied on the next join, since the endpoint is rebuilt then.
|
||
let _ = state.controller.send(CoreCommand::SetNetworkMode(mode));
|
||
}
|
||
AppMessage::RecordingModeSelected(mode) => {
|
||
state.config.recording_mode = mode;
|
||
state.config.save();
|
||
// Takes effect on the next recording start.
|
||
let _ = state.controller.send(CoreCommand::SetRecordingMode(mode));
|
||
}
|
||
AppMessage::ToggleNotifications(enabled) => {
|
||
state.config.notifications_enabled = enabled;
|
||
state.config.save();
|
||
notify::set_enabled(enabled);
|
||
}
|
||
AppMessage::ToggleEchoCancellation(enabled) => {
|
||
state.config.echo_cancellation_enabled = enabled;
|
||
state.config.save();
|
||
// Applied on the next join, since the audio graph is rebuilt then.
|
||
}
|
||
AppMessage::CustomSoundPathChanged(sound, path) => {
|
||
let path_opt = if path.trim().is_empty() { None } else { Some(path) };
|
||
match sound {
|
||
Sound::SelfJoin => state.config.custom_sound_self_join = path_opt,
|
||
Sound::PeerJoin => state.config.custom_sound_peer_join = path_opt,
|
||
Sound::PeerLeave => state.config.custom_sound_peer_leave = path_opt,
|
||
Sound::ReconnectAttempt => state.config.custom_sound_reconnect_attempt = path_opt,
|
||
Sound::Reconnected => state.config.custom_sound_reconnected = path_opt,
|
||
Sound::SelfLeave => state.config.custom_sound_self_leave = path_opt,
|
||
Sound::MicToggle => state.config.custom_sound_mic_toggle = path_opt,
|
||
Sound::ReconnectFailed => state.config.custom_sound_reconnect_failed = path_opt,
|
||
}
|
||
}
|
||
AppMessage::ToggleRecording => {
|
||
// Optimistic intent; the core flips `recording` for real via the
|
||
// Recording{Started,Stopped} events (so a failed start won't lie).
|
||
let _ = state.controller.send(CoreCommand::SetRecording(!state.recording));
|
||
}
|
||
AppMessage::ChatInputChanged(val) => {
|
||
state.chat_input = val;
|
||
}
|
||
AppMessage::DividerDragged(kind, delta) => {
|
||
// Apply live; the final position is persisted on drag release (the
|
||
// divider publishes PersistConfig then) to avoid per-pixel disk writes.
|
||
match kind {
|
||
DividerKind::Panels => {
|
||
state.config.participants_width = clamp_participants_width(
|
||
state.config.participants_width + delta,
|
||
state.window_size.width,
|
||
);
|
||
}
|
||
DividerKind::Chat => {
|
||
// Dragging the divider down (positive delta) gives the main row
|
||
// more room and shrinks the dock below it, so subtract.
|
||
state.config.chat_height = clamp_chat_height(
|
||
state.config.chat_height - delta,
|
||
state.window_size.height,
|
||
);
|
||
}
|
||
DividerKind::Controls => {
|
||
// Controls sits on the right; dragging the divider right (positive
|
||
// delta) gives Chat more room and shrinks Controls.
|
||
state.config.controls_width = clamp_controls_width(
|
||
state.config.controls_width - delta,
|
||
state.window_size.width,
|
||
);
|
||
}
|
||
DividerKind::ChatDrawer => {
|
||
// The drawer sits on the right; dragging its left-edge divider
|
||
// left (negative delta) widens the drawer.
|
||
state.config.chat_drawer_width = clamp_chat_drawer_width(
|
||
state.config.chat_drawer_width - delta,
|
||
state.window_size.width,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
AppMessage::OpenLayoutPicker => {
|
||
state.layout_picker_open = true;
|
||
}
|
||
AppMessage::CloseLayoutPicker => {
|
||
state.layout_picker_open = false;
|
||
}
|
||
AppMessage::SelectRoomLayout(layout) => {
|
||
state.config.room_layout = layout;
|
||
state.config.save();
|
||
state.layout_picker_open = false;
|
||
}
|
||
AppMessage::SelectTheme(theme) => {
|
||
state.config.theme = theme;
|
||
state.config.save();
|
||
}
|
||
AppMessage::ToggleDrawerChat => {
|
||
state.drawer_chat_open = !state.drawer_chat_open;
|
||
}
|
||
AppMessage::ChatSubmit => {
|
||
let text = sanitize_chat(&state.chat_input);
|
||
if !text.is_empty() {
|
||
// Local echo (gossip suppresses our own author, so it won't come back).
|
||
push_chat(&mut state.chat_messages, ChatEntry {
|
||
name: format!("{} (You)", state.name),
|
||
text: text.clone(),
|
||
mine: true,
|
||
});
|
||
let _ = state.controller.send(CoreCommand::SendChat(text));
|
||
state.chat_input.clear();
|
||
}
|
||
}
|
||
AppMessage::ToggleMicTest(enabled) => {
|
||
state.mic_test_active = enabled;
|
||
if !enabled {
|
||
state.mic_level = 0.0;
|
||
}
|
||
let input_device = state.selected_input.as_ref().map(|d| d.name.clone());
|
||
let _ = state
|
||
.controller
|
||
.send(CoreCommand::SetMicMonitor { enabled, input_device });
|
||
}
|
||
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(Event::Window(iced::window::Event::Resized(size))) => {
|
||
state.window_size = size;
|
||
// Remember the size in-memory; it's written to disk once on close.
|
||
// Guard against bogus tiny/zero sizes some compositors emit transiently.
|
||
if size.width >= 200.0 && size.height >= 200.0 {
|
||
state.config.window_width = size.width;
|
||
state.config.window_height = size.height;
|
||
}
|
||
// Keep divider positions valid for the new window dimensions. (Saved
|
||
// with the next drag-release or other config write; not worth a disk
|
||
// write on every resize tick.)
|
||
state.config.participants_width =
|
||
clamp_participants_width(state.config.participants_width, size.width);
|
||
state.config.chat_height =
|
||
clamp_chat_height(state.config.chat_height, size.height);
|
||
state.config.controls_width =
|
||
clamp_controls_width(state.config.controls_width, size.width);
|
||
state.config.chat_drawer_width =
|
||
clamp_chat_drawer_width(state.config.chat_drawer_width, size.width);
|
||
}
|
||
AppMessage::EventOccurred(Event::Window(iced::window::Event::Moved(position))) => {
|
||
// Remember the position in-memory; written to disk once on close.
|
||
// Negative coords are valid (a monitor left of/above the primary), so
|
||
// we don't clamp. On Wayland iced doesn't report position, so this
|
||
// arm simply never fires there and window_x/y stay None.
|
||
state.config.window_x = Some(position.x as i32);
|
||
state.config.window_y = Some(position.y as i32);
|
||
}
|
||
AppMessage::EventOccurred(Event::Window(iced::window::Event::CloseRequested)) => {
|
||
// We took over the close path (exit_on_close_request:false) so we can
|
||
// persist the final window size + position before quitting. Both are
|
||
// already mirrored into config by the Resized/Moved handlers above.
|
||
state.config.save();
|
||
return iced::exit();
|
||
}
|
||
AppMessage::EventOccurred(_) => {}
|
||
AppMessage::NavigateToSettings => {
|
||
state.current_screen = Screen::Settings;
|
||
}
|
||
AppMessage::NavigateBack => {
|
||
state.config.save();
|
||
// Release the mic when leaving Settings if the test was running.
|
||
if state.mic_test_active {
|
||
state.mic_test_active = false;
|
||
state.mic_level = 0.0;
|
||
let _ = state.controller.send(CoreCommand::SetMicMonitor {
|
||
enabled: false,
|
||
input_device: None,
|
||
});
|
||
}
|
||
if state.ticket.is_empty() {
|
||
state.current_screen = Screen::Home;
|
||
} else {
|
||
state.current_screen = Screen::Room;
|
||
}
|
||
}
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
/// One-line explanation of a network posture for the settings picker.
|
||
fn network_mode_hint(mode: NetworkMode) -> &'static str {
|
||
match mode {
|
||
NetworkMode::RelayNoDiscovery => "n0 relay for NAT traversal; no presence published to n0 DNS.",
|
||
NetworkMode::N0Full => "n0 relay + DNS discovery. Most reliable, most metadata shared.",
|
||
NetworkMode::DirectOnly => "Fully serverless. May fail behind strict/CGNAT networks.",
|
||
}
|
||
}
|
||
|
||
/// One-line explanation of a recording mode for the settings picker.
|
||
fn recording_mode_hint(mode: RecordingMode) -> &'static str {
|
||
match mode {
|
||
RecordingMode::Mixed => "One WAV: your mic blended with everyone you hear.",
|
||
RecordingMode::Multitrack => "One WAV per person + your mic, sample-aligned — mix it yourself.",
|
||
RecordingMode::Both => "Per-person stems + your mic AND a ready-made mixed WAV.",
|
||
}
|
||
}
|
||
|
||
/// Formats a call duration as `m:ss` (or `h:mm:ss` past an hour).
|
||
fn format_duration(total_secs: u64) -> String {
|
||
let h = total_secs / 3600;
|
||
let m = (total_secs % 3600) / 60;
|
||
let s = total_secs % 60;
|
||
if h > 0 {
|
||
format!("{h}:{m:02}:{s:02}")
|
||
} else {
|
||
format!("{m}:{s:02}")
|
||
}
|
||
}
|
||
|
||
/// First 8 characters of an id string for compact display. Panic-free: takes
|
||
/// chars (not a byte slice), so a short or non-ASCII id can never panic the
|
||
/// render (security finding S1) — ids are long ASCII hex today, but this guards
|
||
/// the slice regardless.
|
||
fn short_id(id: &str) -> String {
|
||
id.chars().take(8).collect()
|
||
}
|
||
|
||
/// Max characters kept for a single chat message after sanitizing.
|
||
const CHAT_MSG_MAX_CHARS: usize = 2000;
|
||
|
||
/// Sanitize a chat string for display, applied to BOTH our outgoing text and
|
||
/// incoming text from peers (peer input is untrusted — a buggy/malicious sender
|
||
/// could include control characters or an enormous payload). Drops control
|
||
/// characters (ANSI escapes, NUL, stray CR/LF/TAB), collapses any whitespace run
|
||
/// to a single space, trims the ends, and caps the length. Returns "" for input
|
||
/// that is empty after cleaning (the caller skips empty messages).
|
||
fn sanitize_chat(input: &str) -> String {
|
||
let no_control: String = input
|
||
.chars()
|
||
.map(|c| if c.is_control() { ' ' } else { c })
|
||
.collect();
|
||
let collapsed = no_control.split_whitespace().collect::<Vec<_>>().join(" ");
|
||
collapsed.chars().take(CHAT_MSG_MAX_CHARS).collect()
|
||
}
|
||
|
||
/// Append a chat line, trimming the oldest once history exceeds the cap so a long
|
||
/// call can't grow the buffer without bound.
|
||
fn push_chat(messages: &mut Vec<ChatEntry>, entry: ChatEntry) {
|
||
messages.push(entry);
|
||
if messages.len() > CHAT_HISTORY_MAX {
|
||
let overflow = messages.len() - CHAT_HISTORY_MAX;
|
||
messages.drain(..overflow);
|
||
}
|
||
}
|
||
|
||
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 colours — sourced from the active palette (see `src/theme.rs`), so
|
||
// all styling below re-themes when the user picks a different theme.
|
||
let pal = state.config.theme.palette();
|
||
let color_crust = pal.crust;
|
||
let color_mantle = pal.mantle;
|
||
let color_base = pal.base;
|
||
let color_surface = pal.surface;
|
||
let color_overlay = pal.overlay;
|
||
let color_text = pal.text;
|
||
let color_subtext = pal.subtext;
|
||
let color_blue = pal.blue;
|
||
let color_lavender = pal.lavender;
|
||
let color_red = pal.red;
|
||
let color_maroon = pal.maroon;
|
||
let color_green = pal.green;
|
||
let color_yellow = pal.yellow;
|
||
|
||
// 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_overlay,
|
||
value: color_text,
|
||
selection: color_blue,
|
||
}
|
||
};
|
||
|
||
let top_bar = row![
|
||
horizontal_space(),
|
||
tooltip(
|
||
button(
|
||
Canvas::new(LayoutIcon { fg: color_text })
|
||
.width(iced::Length::Fixed(18.0))
|
||
.height(iced::Length::Fixed(18.0))
|
||
)
|
||
.on_press(AppMessage::OpenLayoutPicker)
|
||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||
.padding(8),
|
||
container(text("Room layout").size(11).color(color_text))
|
||
.padding(8)
|
||
.style(c_style(color_crust, color_surface, 6.0)),
|
||
iced::widget::tooltip::Position::Bottom,
|
||
)
|
||
.gap(8),
|
||
button(
|
||
row![
|
||
icon(IconKind::Settings, 15.0, color_text),
|
||
text("Settings").size(14),
|
||
]
|
||
.spacing(6)
|
||
.align_y(iced::alignment::Vertical::Center)
|
||
)
|
||
.on_press(AppMessage::NavigateToSettings)
|
||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||
.padding(8)
|
||
].width(iced::Length::Fill).padding(10).spacing(8);
|
||
|
||
if state.current_screen == Screen::Settings {
|
||
let path_field = |label: &'static str, sound: Sound| {
|
||
let path = state.custom_sound_path(sound);
|
||
let validation_widget = match notify::validate_custom_path(path) {
|
||
None => text(""),
|
||
Some(true) => text("✓ File found").size(10).color(color_green),
|
||
Some(false) => text("✗ File not found").size(10).color(color_red),
|
||
};
|
||
|
||
column![
|
||
row![
|
||
text(label).size(12).color(color_subtext),
|
||
horizontal_space(),
|
||
validation_widget,
|
||
].align_y(iced::alignment::Vertical::Center),
|
||
text_input("Default (embedded)...", path)
|
||
.on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val))
|
||
.style(t_style)
|
||
.padding(8)
|
||
].spacing(4).width(iced::Length::Fill)
|
||
};
|
||
|
||
// Live mic level meter for gate calibration. Shares the gate slider's
|
||
// 0..0.1 scale so you can read your voice against the threshold directly.
|
||
// During a call the in-call meter feeds it; otherwise a "Test mic" toggle
|
||
// spins up a standalone capture stream.
|
||
let in_call = !state.ticket.is_empty();
|
||
let mic_test_control: Element<'_, AppMessage> = if in_call {
|
||
text("Live (in call)").size(11).color(color_green).into()
|
||
} else {
|
||
let (mic_kind, label, bg) = if state.mic_test_active {
|
||
(IconKind::Stop, "Stop mic test", color_red)
|
||
} else {
|
||
(IconKind::Mic, "Test mic", color_surface)
|
||
};
|
||
button(
|
||
row![
|
||
icon(mic_kind, 13.0, color_text),
|
||
text(label).size(12),
|
||
]
|
||
.spacing(5)
|
||
.align_y(iced::alignment::Vertical::Center)
|
||
)
|
||
.on_press(AppMessage::ToggleMicTest(!state.mic_test_active))
|
||
.style(b_style(bg, color_blue, color_text, 6.0))
|
||
.padding(6)
|
||
.into()
|
||
};
|
||
// Unified meter + draggable gate (Discord/OBS-style): the live mic level
|
||
// fills the bar and the yellow handle is the gate threshold, dragged
|
||
// directly on the same axis. Green fill = above the gate (transmitting),
|
||
// dim = below it (muted). Live status word reinforces the colour.
|
||
let gate_thresh = state.config.noise_gate_threshold;
|
||
let speaking = state.mic_level >= 0.001;
|
||
let passing = speaking && state.mic_level >= gate_thresh;
|
||
let (status_label, status_color) = if !speaking {
|
||
("○ Idle", color_subtext)
|
||
} else if passing {
|
||
("● Transmitting", color_green)
|
||
} else {
|
||
("● Muted by gate", color_red)
|
||
};
|
||
let gate_meter = Canvas::new(GateMeter {
|
||
level: state.mic_level,
|
||
threshold: gate_thresh,
|
||
track: color_crust,
|
||
border: color_surface,
|
||
fill_on: color_green,
|
||
fill_off: color_surface,
|
||
handle: Color::from_rgb8(255, 40, 40),
|
||
handle_edge: color_crust,
|
||
})
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fixed(20.0));
|
||
let mic_meter = column![
|
||
gate_meter,
|
||
row![
|
||
text(status_label).size(12).color(status_color),
|
||
horizontal_space(),
|
||
text(format!("gate {:.1}%", gate_thresh * 100.0)).size(11).color(color_subtext),
|
||
horizontal_space(),
|
||
mic_test_control,
|
||
].align_y(iced::alignment::Vertical::Center).spacing(8),
|
||
].spacing(6).width(iced::Length::Fill);
|
||
|
||
// Inline room-layout chooser (Settings shows the thumbnails outright, no
|
||
// popup button). Same SelectRoomLayout message, applied live + persisted.
|
||
let layout_choice = |layout: RoomLayout, label: &'static str| -> Element<'_, AppMessage> {
|
||
let selected = state.config.room_layout == layout;
|
||
let tile = Canvas::new(LayoutThumb {
|
||
layout,
|
||
selected,
|
||
base: color_base,
|
||
surface: color_surface,
|
||
overlay: color_overlay,
|
||
border: if selected { color_blue } else { color_surface },
|
||
})
|
||
.width(iced::Length::Fixed(132.0))
|
||
.height(iced::Length::Fixed(86.0));
|
||
column![
|
||
button(tile)
|
||
.on_press(AppMessage::SelectRoomLayout(layout))
|
||
.padding(2)
|
||
.style(b_style(Color::TRANSPARENT, color_surface, color_text, 8.0)),
|
||
text(label).size(11).color(if selected { color_blue } else { color_subtext }),
|
||
]
|
||
.spacing(4)
|
||
.align_x(iced::alignment::Horizontal::Center)
|
||
.into()
|
||
};
|
||
|
||
// Inline theme chooser — a clickable palette-preview swatch per theme.
|
||
// Same SelectTheme message, applied live + persisted.
|
||
let theme_choice = |t: AppTheme| -> Element<'_, AppMessage> {
|
||
let selected = state.config.theme == t;
|
||
let tile = Canvas::new(ThemeSwatch {
|
||
palette: t.palette(),
|
||
selected,
|
||
border: if selected { color_blue } else { color_surface },
|
||
})
|
||
.width(iced::Length::Fixed(120.0))
|
||
.height(iced::Length::Fixed(64.0));
|
||
column![
|
||
button(tile)
|
||
.on_press(AppMessage::SelectTheme(t))
|
||
.padding(2)
|
||
.style(b_style(Color::TRANSPARENT, color_surface, color_text, 8.0)),
|
||
text(t.label())
|
||
.size(11)
|
||
.color(if selected { color_blue } else { color_subtext }),
|
||
]
|
||
.spacing(4)
|
||
.align_x(iced::alignment::Horizontal::Center)
|
||
.into()
|
||
};
|
||
// 10 themes laid out as two rows of five (no flex-wrap in iced 0.14).
|
||
let theme_row1: Vec<Element<'_, AppMessage>> =
|
||
AppTheme::ALL[0..5].iter().map(|&t| theme_choice(t)).collect();
|
||
let theme_row2: Vec<Element<'_, AppMessage>> =
|
||
AppTheme::ALL[5..].iter().map(|&t| theme_choice(t)).collect();
|
||
// Title comes from the "Theme" section header (added below), so this body
|
||
// is just the swatch rows + hint.
|
||
let theme_section = column![
|
||
iced::widget::Row::with_children(theme_row1).spacing(12),
|
||
iced::widget::Row::with_children(theme_row2).spacing(12),
|
||
text("Colour theme for the whole UI. Applies live.")
|
||
.size(11)
|
||
.color(color_subtext),
|
||
]
|
||
.spacing(10)
|
||
.width(iced::Length::Fill);
|
||
|
||
// Reusable category header: a coloured title with a thin full-width
|
||
// divider beneath, so each class of settings reads as its own section.
|
||
let section_header = |title: &'static str| -> Element<'_, AppMessage> {
|
||
column![
|
||
text(title).size(16).color(color_blue),
|
||
container(text(""))
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fixed(1.0))
|
||
.style(c_style(color_surface, Color::TRANSPARENT, 0.0)),
|
||
]
|
||
.spacing(6)
|
||
.width(iced::Length::Fill)
|
||
.into()
|
||
};
|
||
|
||
// Spacing between one category and the next.
|
||
let section_gap = 18.0;
|
||
|
||
// One recording-mode radio with a hover tooltip explaining it. (iced's
|
||
// pick_list can't host per-option tooltips, so the modes are radios.)
|
||
let mode_radio = |mode: RecordingMode, label: &'static str| -> Element<'_, AppMessage> {
|
||
tooltip(
|
||
radio(label, mode, Some(state.config.recording_mode), AppMessage::RecordingModeSelected),
|
||
container(text(recording_mode_hint(mode)).size(11).color(color_text))
|
||
.padding(8)
|
||
.max_width(300.0)
|
||
.style(c_style(color_crust, color_surface, 6.0)),
|
||
iced::widget::tooltip::Position::Right,
|
||
)
|
||
.gap(8)
|
||
.into()
|
||
};
|
||
|
||
let settings_content = scrollable(
|
||
column![
|
||
// --- Audio Devices ---
|
||
section_header("Audio Devices"),
|
||
row![
|
||
column![
|
||
text("Input Device").size(12).color(color_subtext),
|
||
pick_list(
|
||
&state.input_devices[..],
|
||
state.selected_input.as_ref(),
|
||
AppMessage::InputDeviceSelected,
|
||
).width(iced::Length::Fill),
|
||
text(format!("Input Volume (mic): {:.0}%", state.config.input_volume * 100.0)).size(11).color(color_subtext),
|
||
slider(0.0..=2.0, state.config.input_volume, AppMessage::InputVolumeChanged)
|
||
.step(0.05)
|
||
.on_release(AppMessage::PersistConfig),
|
||
].spacing(8).width(iced::Length::Fill),
|
||
column![
|
||
text("Output Device").size(12).color(color_subtext),
|
||
pick_list(
|
||
&state.output_devices[..],
|
||
state.selected_output.as_ref(),
|
||
AppMessage::OutputDeviceSelected,
|
||
).width(iced::Length::Fill),
|
||
text(format!("Output Volume: {:.0}%", state.config.output_volume * 100.0)).size(11).color(color_subtext),
|
||
slider(0.0..=2.0, state.config.output_volume, AppMessage::OutputVolumeChanged)
|
||
.step(0.05)
|
||
.on_release(AppMessage::PersistConfig),
|
||
].spacing(8).width(iced::Length::Fill),
|
||
].spacing(20).align_y(iced::alignment::Vertical::Top).width(iced::Length::Fill),
|
||
vertical_space(section_gap),
|
||
|
||
// --- Microphone ---
|
||
section_header("Microphone"),
|
||
column![
|
||
mic_meter,
|
||
text("Drag the yellow handle to set the gate. While talking, place it just above your quiet-room level so silence is muted but your voice passes through.").size(11).color(color_subtext),
|
||
vertical_space(4.0),
|
||
checkbox(state.config.echo_cancellation_enabled)
|
||
.label("Echo cancellation")
|
||
.on_toggle(AppMessage::ToggleEchoCancellation),
|
||
text("Cancels speaker echo + suppresses noise (PipeWire). Takes effect on your next room join.").size(11).color(color_subtext),
|
||
].spacing(8).width(iced::Length::Fill),
|
||
vertical_space(section_gap),
|
||
|
||
// --- Recording ---
|
||
section_header("Recording"),
|
||
column![
|
||
mode_radio(RecordingMode::Mixed, "Mixed (single file)"),
|
||
mode_radio(RecordingMode::Multitrack, "Multitrack (per-peer stems)"),
|
||
mode_radio(RecordingMode::Both, "Both (stems + mixed)"),
|
||
vertical_space(2.0),
|
||
text("Hover an option for what it does. Saved to ~/peerspeak-recordings/ — Multitrack/Both as a timestamped folder of tracks, Mixed as a single file. Applies to your next recording.").size(11).color(color_subtext),
|
||
].spacing(8).width(iced::Length::Fill),
|
||
vertical_space(section_gap),
|
||
|
||
// --- Network & Privacy ---
|
||
section_header("Network & Privacy"),
|
||
column![
|
||
pick_list(
|
||
&NetworkMode::ALL[..],
|
||
Some(state.config.network_mode),
|
||
AppMessage::NetworkModeSelected,
|
||
).width(iced::Length::Fill),
|
||
text(network_mode_hint(state.config.network_mode)).size(11).color(color_subtext),
|
||
text("Takes effect on your next room join.").size(11).color(color_subtext),
|
||
].spacing(4).width(iced::Length::Fill),
|
||
vertical_space(section_gap),
|
||
|
||
// --- Room Layout ---
|
||
section_header("Room Layout"),
|
||
column![
|
||
row![
|
||
layout_choice(RoomLayout::ThreeColumn, "3-Column"),
|
||
layout_choice(RoomLayout::BottomDock, "Bottom Dock"),
|
||
layout_choice(RoomLayout::Drawer, "Drawer"),
|
||
].spacing(16),
|
||
text("How the in-call room is arranged. Applies live.").size(11).color(color_subtext),
|
||
].spacing(8).width(iced::Length::Fill),
|
||
vertical_space(section_gap),
|
||
|
||
// --- Theme ---
|
||
section_header("Theme"),
|
||
theme_section,
|
||
vertical_space(section_gap),
|
||
|
||
// --- Notifications & Sounds ---
|
||
section_header("Notifications & Sounds"),
|
||
column![
|
||
checkbox(state.config.notifications_enabled)
|
||
.label("Enable sound notifications")
|
||
.on_toggle(AppMessage::ToggleNotifications),
|
||
vertical_space(6.0),
|
||
text("Custom chime files (WAV paths) — leave blank for the built-in sounds.").size(12).color(color_subtext),
|
||
row![
|
||
path_field("Self Join", Sound::SelfJoin),
|
||
path_field("Peer Join", Sound::PeerJoin),
|
||
].spacing(20).width(iced::Length::Fill),
|
||
row![
|
||
path_field("Self Leave", Sound::SelfLeave),
|
||
path_field("Peer Leave", Sound::PeerLeave),
|
||
].spacing(20).width(iced::Length::Fill),
|
||
row![
|
||
path_field("Reconnect Attempt", Sound::ReconnectAttempt),
|
||
path_field("Reconnected", Sound::Reconnected),
|
||
].spacing(20).width(iced::Length::Fill),
|
||
row![
|
||
path_field("Mic Toggle", Sound::MicToggle),
|
||
path_field("Reconnect Failed", Sound::ReconnectFailed),
|
||
].spacing(20).width(iced::Length::Fill),
|
||
].spacing(8).width(iced::Length::Fill),
|
||
]
|
||
.spacing(10)
|
||
.width(iced::Length::Fill)
|
||
)
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill);
|
||
|
||
// Sticky header bar: stays fixed above the scrollable content so the Back
|
||
// button is always reachable. The "Settings" title is centered by flanking
|
||
// it with two equal-width Fill segments — the Back button lives in the left
|
||
// one (left-aligned) and the right one is an empty balance, so the title is
|
||
// mathematically centered regardless of the Back button's rendered width.
|
||
let settings_header = container(
|
||
row![
|
||
container(
|
||
button(
|
||
row![
|
||
text("←").size(16),
|
||
text("Back").size(14),
|
||
]
|
||
.spacing(6)
|
||
.align_y(iced::alignment::Vertical::Center)
|
||
)
|
||
.on_press(AppMessage::NavigateBack)
|
||
.style(b_style(color_surface, color_blue, color_text, 8.0))
|
||
.padding([8, 14])
|
||
)
|
||
.width(iced::Length::Fill),
|
||
text("Settings").size(20).color(color_blue),
|
||
// Equal-width balance spacer keeps the title centered.
|
||
container(text("")).width(iced::Length::Fill),
|
||
]
|
||
.align_y(iced::alignment::Vertical::Center)
|
||
.width(iced::Length::Fill),
|
||
)
|
||
.padding([12, 16])
|
||
.width(iced::Length::Fill)
|
||
.style(c_style(color_crust, color_surface, 8.0));
|
||
|
||
let settings_box = container(
|
||
column![
|
||
settings_header,
|
||
vertical_space(12.0),
|
||
settings_content,
|
||
]
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill),
|
||
)
|
||
.style(c_style(color_mantle, color_surface, 12.0))
|
||
.padding(24)
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill);
|
||
|
||
return container(settings_box)
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill)
|
||
.padding(24)
|
||
.center_x(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(btn_content(IconKind::Create, "Create New Room", color_crust))
|
||
.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(10.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);
|
||
|
||
let home = 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));
|
||
|
||
with_layout_picker(home.into(), state)
|
||
} else {
|
||
// --- ROOM SCREEN ---
|
||
let participant_count = state.peers.len() + 1; // peers + you
|
||
let call_secs = state.call_started.map(|t| t.elapsed().as_secs()).unwrap_or(0);
|
||
let header = row![
|
||
text("PEERSPEAK")
|
||
.size(20)
|
||
.color(color_blue),
|
||
horizontal_space(),
|
||
row![
|
||
icon(IconKind::People, 15.0, color_subtext),
|
||
text(format!("{participant_count} in room")).size(14).color(color_subtext),
|
||
]
|
||
.spacing(5)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
row![
|
||
icon(IconKind::Clock, 15.0, color_subtext),
|
||
text(format_duration(call_secs)).size(14).color(color_subtext),
|
||
]
|
||
.spacing(5)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
if state.recording {
|
||
let rec_secs = state.recording_started.map(|t| t.elapsed().as_secs()).unwrap_or(0);
|
||
container(
|
||
row![
|
||
icon(IconKind::Record, 12.0, color_red),
|
||
text(format!("REC {}", format_duration(rec_secs))).size(13).color(color_red),
|
||
]
|
||
.spacing(5)
|
||
.align_y(iced::alignment::Vertical::Center)
|
||
)
|
||
.style(c_style(color_crust, color_red, 6.0))
|
||
.padding(6)
|
||
} else {
|
||
container(text("")).padding(0)
|
||
},
|
||
horizontal_space(),
|
||
text(format!("My ID: {}", short_id(&state.self_id)))
|
||
.size(14)
|
||
.color(color_subtext),
|
||
button(
|
||
row![
|
||
icon(IconKind::Copy, 14.0, color_text),
|
||
text("Copy Ticket").size(12),
|
||
]
|
||
.spacing(5)
|
||
.align_y(iced::alignment::Vertical::Center)
|
||
)
|
||
.on_press(AppMessage::CopyToClipboard)
|
||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||
.padding(6),
|
||
// Drawer layout: a chat toggle (the drawer is collapsed by default).
|
||
{
|
||
let el: Element<'_, AppMessage> =
|
||
if state.config.room_layout == RoomLayout::Drawer {
|
||
let (lbl, bg, fg) = if state.drawer_chat_open {
|
||
("Hide chat", color_blue, color_crust)
|
||
} else {
|
||
("Chat", color_surface, color_text)
|
||
};
|
||
button(
|
||
row![
|
||
icon(IconKind::Chat, 14.0, fg),
|
||
text(lbl).size(12),
|
||
]
|
||
.spacing(5)
|
||
.align_y(iced::alignment::Vertical::Center)
|
||
)
|
||
.on_press(AppMessage::ToggleDrawerChat)
|
||
.style(b_style(bg, color_blue, fg, 6.0))
|
||
.padding(6)
|
||
.into()
|
||
} else {
|
||
iced::widget::Space::new().width(0.0).height(0.0).into()
|
||
};
|
||
el
|
||
}
|
||
]
|
||
.spacing(16)
|
||
.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 — name/status row plus a live mic meter so you can
|
||
// confirm you're being picked up (and see mute / PTT / gate at work).
|
||
let transmitting = !state.is_muted && (!state.ptt_enabled || state.ptt_active);
|
||
let self_mic_color = if transmitting { color_green } else { color_subtext };
|
||
let self_card = container(
|
||
column![
|
||
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),
|
||
// Live "you're sharing" badge — only present while sharing.
|
||
{
|
||
let el: Element<'_, AppMessage> = if state.self_sharing {
|
||
row![
|
||
icon(IconKind::Live, 14.0, color_red),
|
||
text("Sharing your screen").size(13).color(color_red),
|
||
]
|
||
.spacing(6)
|
||
.align_y(iced::alignment::Vertical::Center)
|
||
.into()
|
||
} else {
|
||
iced::widget::Space::new().width(0.0).height(0.0).into()
|
||
};
|
||
el
|
||
},
|
||
progress_bar(0.0..=0.3, state.mic_level)
|
||
.girth(8.0)
|
||
.style(move |_t: &Theme| iced::widget::progress_bar::Style {
|
||
background: Background::Color(color_crust),
|
||
bar: Background::Color(self_mic_color),
|
||
border: Border { color: color_surface, width: 1.0, radius: 4.0.into() },
|
||
}),
|
||
].spacing(8)
|
||
)
|
||
.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_connecting = state.connecting.contains(peer_id);
|
||
let is_speaking = !is_connecting && level > 0.01;
|
||
|
||
let (ind_label, ind_color): (&str, Color) = if is_connecting {
|
||
let label = if state.ever_connected.contains(peer_id) {
|
||
"[Reconnecting…]"
|
||
} else {
|
||
"[Connecting…]"
|
||
};
|
||
(label, color_yellow)
|
||
} else if peer.is_muted {
|
||
("[Muted]", color_red)
|
||
} else if is_speaking {
|
||
("[Speaking]", color_green)
|
||
} else {
|
||
("[Idle]", color_subtext)
|
||
};
|
||
// Fixed-width, right-aligned slot so the label changing (e.g. Idle→
|
||
// Speaking) doesn't reflow the row and shift the mute button (A10).
|
||
// Width covers the longest label, "[Reconnecting…]".
|
||
let indicator = container(text(ind_label).size(14).color(ind_color))
|
||
.width(iced::Length::Fixed(124.0))
|
||
.align_x(iced::alignment::Horizontal::Right);
|
||
|
||
let peer_id_clone = *peer_id;
|
||
let is_locally_muted = state.locally_muted.contains(peer_id);
|
||
|
||
// Local-mute toggle (silences this peer for us only).
|
||
let (mute_kind, mute_bg, mute_fg) = if is_locally_muted {
|
||
(IconKind::SpeakerOff, color_red, color_crust)
|
||
} else {
|
||
(IconKind::Speaker, color_surface, color_text)
|
||
};
|
||
let mute_btn = button(icon(mute_kind, 16.0, mute_fg))
|
||
.on_press(AppMessage::TogglePeerMute(peer_id_clone))
|
||
.style(b_style(mute_bg, color_blue, mute_fg, 6.0))
|
||
.padding(6);
|
||
|
||
// Screen-share "Live" badge + Watch button when this peer is sharing.
|
||
// Watch is enabled only if pixelpass is installed locally.
|
||
let share_el: Element<'_, AppMessage> = if let Some(ticket) = peer.sharing.clone() {
|
||
let mut watch_btn = button(
|
||
row![
|
||
icon(IconKind::Eye, 14.0, color_crust),
|
||
text("Watch").size(13),
|
||
]
|
||
.spacing(5)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
)
|
||
.style(b_style(color_blue, color_lavender, color_crust, 6.0))
|
||
.padding(6);
|
||
if state.pixelpass_available {
|
||
watch_btn = watch_btn.on_press(AppMessage::WatchShare(ticket));
|
||
}
|
||
row![
|
||
row![
|
||
icon(IconKind::Live, 13.0, color_red),
|
||
text("Live").size(13).color(color_red),
|
||
]
|
||
.spacing(5)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
watch_btn,
|
||
]
|
||
.spacing(6)
|
||
.align_y(iced::alignment::Vertical::Center)
|
||
.into()
|
||
} else {
|
||
iced::widget::Space::new().width(0.0).height(0.0).into()
|
||
};
|
||
|
||
// VU meter colour: dim when locally muted (you don't hear them),
|
||
// green while speaking, faint otherwise.
|
||
let vu_color = if is_locally_muted {
|
||
color_subtext
|
||
} else if is_speaking {
|
||
color_green
|
||
} else {
|
||
color_surface
|
||
};
|
||
|
||
let mut card_content = column![
|
||
row![
|
||
column![
|
||
text(&peer.name).size(16).color(color_text),
|
||
text(format!("ID: {}", short_id(&peer_id.to_string()))).size(11).color(color_subtext)
|
||
],
|
||
horizontal_space(),
|
||
share_el,
|
||
mute_btn,
|
||
indicator
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
progress_bar(0.0..=0.3, level)
|
||
.girth(8.0)
|
||
.style(move |_t: &Theme| iced::widget::progress_bar::Style {
|
||
background: Background::Color(color_crust),
|
||
bar: Background::Color(vu_color),
|
||
border: Border { color: color_surface, width: 1.0, radius: 4.0.into() },
|
||
}),
|
||
].spacing(8);
|
||
|
||
// Peer volume slider
|
||
let current_vol = state.peer_volumes.get(peer_id).copied().unwrap_or(1.0);
|
||
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_connecting { color_yellow } else 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)
|
||
.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 mute_kind = if state.is_muted { IconKind::MicOff } else { IconKind::Mic };
|
||
let deafen_kind = if state.is_deafened { IconKind::Deafen } else { IconKind::Headphones };
|
||
let ctrl_buttons = column![
|
||
button(btn_content(mute_kind, mute_text, mute_fg))
|
||
.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(btn_content(deafen_kind, deafen_text, deafen_fg))
|
||
.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(20.0),
|
||
// Echo cancellation — same flag + message as the Settings checkbox, so
|
||
// toggling here and there stay in sync automatically (single source of
|
||
// truth: config.echo_cancellation_enabled). Tooltip is explicit that it
|
||
// applies on the NEXT join (the PipeWire-module AEC is wired at join
|
||
// time, not hot-swappable mid-call).
|
||
tooltip(
|
||
checkbox(state.config.echo_cancellation_enabled)
|
||
.label("Echo cancellation")
|
||
.on_toggle(AppMessage::ToggleEchoCancellation),
|
||
container(
|
||
text("Cancels speaker echo + suppresses noise. Applies on your next room join.")
|
||
.size(11)
|
||
.color(color_text),
|
||
)
|
||
.padding(8)
|
||
.max_width(260.0)
|
||
.style(c_style(color_crust, color_surface, 6.0)),
|
||
iced::widget::tooltip::Position::Top,
|
||
)
|
||
.gap(8),
|
||
vertical_space(20.0),
|
||
{
|
||
let (rec_kind, rec_label, rec_bg, rec_hover, rec_fg) = if state.recording {
|
||
(IconKind::Stop, "Stop Recording", color_red, color_maroon, color_crust)
|
||
} else {
|
||
(IconKind::Record, "Record Call", color_surface, color_blue, color_text)
|
||
};
|
||
button(btn_content(rec_kind, rec_label, rec_fg))
|
||
.on_press(AppMessage::ToggleRecording)
|
||
.style(b_style(rec_bg, rec_hover, rec_fg, 8.0))
|
||
.padding(14)
|
||
.width(iced::Length::Fill)
|
||
},
|
||
vertical_space(20.0),
|
||
{
|
||
// Screen share. Disabled (no on_press) when pixelpass is absent,
|
||
// with the label saying so — a normal, handled state.
|
||
let (share_kind, share_label, share_bg, share_hover, share_fg) =
|
||
if !state.pixelpass_available {
|
||
(IconKind::Monitor, "Needs pixelpass", color_surface, color_surface, color_subtext)
|
||
} else if state.self_sharing {
|
||
(IconKind::Stop, "Stop Sharing", color_red, color_maroon, color_crust)
|
||
} else {
|
||
(IconKind::Monitor, "Share Screen", color_surface, color_blue, color_text)
|
||
};
|
||
let mut share_btn = button(btn_content(share_kind, share_label, share_fg))
|
||
.style(b_style(share_bg, share_hover, share_fg, 8.0))
|
||
.padding(14)
|
||
.width(iced::Length::Fill);
|
||
if state.pixelpass_available {
|
||
share_btn = share_btn.on_press(AppMessage::ToggleScreenShare);
|
||
}
|
||
share_btn
|
||
}
|
||
];
|
||
|
||
// Leave is the exit control, so it's pinned below the scrolling controls
|
||
// (built separately, outside `ctrl_buttons`) — see the panel assembly (A12).
|
||
let leave_btn = button(btn_content(IconKind::Leave, "Leave Room", color_crust))
|
||
.on_press(AppMessage::LeavePressed)
|
||
.style(b_style(color_red, color_maroon, color_crust, 8.0))
|
||
.padding(14)
|
||
.width(iced::Length::Fill);
|
||
|
||
// Controls panel — width is set per layout below. The controls SCROLL when
|
||
// the window is too short, and Leave stays pinned at the bottom so the exit
|
||
// control is always reachable instead of being clipped off-screen (A12).
|
||
let control_panel = container(
|
||
column![
|
||
text("Controls").size(18).color(color_blue),
|
||
vertical_space(15.0),
|
||
scrollable(ctrl_buttons)
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill),
|
||
vertical_space(12.0),
|
||
leave_btn,
|
||
vertical_space(8.0),
|
||
text(&state.status_message).size(12).color(color_subtext)
|
||
]
|
||
)
|
||
.style(c_style(color_mantle, Color::TRANSPARENT, 0.0))
|
||
.padding(15)
|
||
.height(iced::Length::Fill);
|
||
|
||
// Reusable chat body (title + bottom-anchored scrollback + input row),
|
||
// wrapped differently by each layout.
|
||
let mut chat_col = Column::new().spacing(4).width(iced::Length::Fill);
|
||
if state.chat_messages.is_empty() {
|
||
chat_col = chat_col.push(
|
||
text("No messages yet — say hi to the room.")
|
||
.size(12)
|
||
.color(color_subtext),
|
||
);
|
||
} else {
|
||
for m in &state.chat_messages {
|
||
let name_color = if m.mine { color_green } else { color_lavender };
|
||
chat_col = chat_col.push(
|
||
row![
|
||
text(format!("{}:", m.name)).size(12).color(name_color),
|
||
text(&m.text).size(13).color(color_text).width(iced::Length::Fill),
|
||
]
|
||
.spacing(8),
|
||
);
|
||
}
|
||
}
|
||
let chat_scroll = scrollable(chat_col)
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill)
|
||
.anchor_bottom();
|
||
let chat_input_row = row![
|
||
text_input("Message the room…", &state.chat_input)
|
||
.on_input(AppMessage::ChatInputChanged)
|
||
.on_submit(AppMessage::ChatSubmit)
|
||
.style(t_style)
|
||
.padding(8),
|
||
button(text("Send").size(13))
|
||
.on_press(AppMessage::ChatSubmit)
|
||
.style(b_style(color_blue, color_lavender, color_crust, 6.0))
|
||
.padding(8),
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::alignment::Vertical::Center);
|
||
let chat_inner = column![
|
||
text("Chat").size(16).color(color_blue),
|
||
chat_scroll,
|
||
chat_input_row,
|
||
]
|
||
.spacing(8);
|
||
|
||
// Divider constructors (fresh widget per call).
|
||
let vdiv = |kind| {
|
||
Canvas::new(Divider { kind, vertical: true, line: color_surface, grip: color_lavender })
|
||
.width(iced::Length::Fixed(DIVIDER_THICKNESS))
|
||
.height(iced::Length::Fill)
|
||
};
|
||
let hdiv = || {
|
||
Canvas::new(Divider {
|
||
kind: DividerKind::Chat,
|
||
vertical: false,
|
||
line: color_surface,
|
||
grip: color_lavender,
|
||
})
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fixed(DIVIDER_THICKNESS))
|
||
};
|
||
|
||
// Assemble the body per the chosen room layout. `chat_inner` is moved into
|
||
// exactly one arm (allowed across mutually-exclusive match arms).
|
||
let pw = state.config.participants_width;
|
||
let body: Element<'_, AppMessage> = match state.config.room_layout {
|
||
RoomLayout::BottomDock => {
|
||
// Cap Participants so the Fill Controls panel keeps its minimum.
|
||
let avail = state.window_size.width - 30.0;
|
||
let pwb = pw.min((avail - CONTROLS_MIN_W - DIVIDER_THICKNESS).max(PARTICIPANTS_MIN_W));
|
||
let main = row![
|
||
peers_panel.width(iced::Length::Fixed(pwb)),
|
||
vdiv(DividerKind::Panels),
|
||
control_panel.width(iced::Length::Fill),
|
||
]
|
||
.spacing(0)
|
||
.height(iced::Length::Fill);
|
||
let chat = container(chat_inner)
|
||
.style(c_style(color_mantle, color_surface, 8.0))
|
||
.padding(12)
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fixed(state.config.chat_height));
|
||
column![main, hdiv(), chat].into()
|
||
}
|
||
RoomLayout::ThreeColumn => {
|
||
// Participants is fixed and Controls is fixed, so cap Participants
|
||
// (shared with the 2-panel layouts, where it's much wider) to leave
|
||
// the centre Chat column at least a minimum width.
|
||
let avail = state.window_size.width - 30.0; // outer padding
|
||
let pw3 = pw.min(
|
||
(avail - state.config.controls_width - CHAT_MIN_W - 2.0 * DIVIDER_THICKNESS)
|
||
.max(PARTICIPANTS_MIN_W),
|
||
);
|
||
let chat = container(chat_inner)
|
||
.style(c_style(color_mantle, color_surface, 8.0))
|
||
.padding(12)
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill);
|
||
row![
|
||
peers_panel.width(iced::Length::Fixed(pw3)),
|
||
vdiv(DividerKind::Panels),
|
||
chat,
|
||
vdiv(DividerKind::Controls),
|
||
control_panel.width(iced::Length::Fixed(state.config.controls_width)),
|
||
]
|
||
.spacing(0)
|
||
.height(iced::Length::Fill)
|
||
.into()
|
||
}
|
||
RoomLayout::Drawer => {
|
||
let avail = state.window_size.width - 30.0;
|
||
if state.drawer_chat_open {
|
||
// Participants + Chat drawer are both fixed; cap Participants so
|
||
// the Fill Controls panel between them keeps its minimum.
|
||
let pwd = pw.min(
|
||
(avail - state.config.chat_drawer_width - CONTROLS_MIN_W - 2.0 * DIVIDER_THICKNESS)
|
||
.max(PARTICIPANTS_MIN_W),
|
||
);
|
||
let chat = container(chat_inner)
|
||
.style(c_style(color_mantle, color_surface, 8.0))
|
||
.padding(12)
|
||
.width(iced::Length::Fixed(state.config.chat_drawer_width))
|
||
.height(iced::Length::Fill);
|
||
row![
|
||
peers_panel.width(iced::Length::Fixed(pwd)),
|
||
vdiv(DividerKind::Panels),
|
||
control_panel.width(iced::Length::Fill),
|
||
vdiv(DividerKind::ChatDrawer),
|
||
chat,
|
||
]
|
||
.spacing(0)
|
||
.height(iced::Length::Fill)
|
||
.into()
|
||
} else {
|
||
let pwd = pw.min((avail - CONTROLS_MIN_W - DIVIDER_THICKNESS).max(PARTICIPANTS_MIN_W));
|
||
row![
|
||
peers_panel.width(iced::Length::Fixed(pwd)),
|
||
vdiv(DividerKind::Panels),
|
||
control_panel.width(iced::Length::Fill),
|
||
]
|
||
.spacing(0)
|
||
.height(iced::Length::Fill)
|
||
.into()
|
||
}
|
||
}
|
||
};
|
||
|
||
let room = container(
|
||
column![top_bar, header_container, vertical_space(12.0), body]
|
||
)
|
||
.padding(15)
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill)
|
||
.style(c_style(color_crust, Color::TRANSPARENT, 0.0));
|
||
|
||
with_layout_picker(room.into(), state)
|
||
}
|
||
}
|
||
|
||
/// Full-scale of the meter's RMS axis. Speech RMS runs to ~0.3 normalized, so
|
||
/// this keeps a normal voice off the ceiling while leaving the gate threshold
|
||
/// (usually a few percent) draggable across the lower part of the bar.
|
||
const METER_MAX: f32 = 0.3;
|
||
|
||
/// A unified mic-level meter with a draggable noise-gate handle (Discord/OBS
|
||
/// style). The bar fills to the live mic level; the yellow handle marks the gate
|
||
/// threshold on the same axis and can be dragged to set it. The fill turns green
|
||
/// when the level is above the gate (transmitting), dim when below it (muted).
|
||
struct GateMeter {
|
||
level: f32,
|
||
threshold: f32,
|
||
track: Color,
|
||
border: Color,
|
||
fill_on: Color,
|
||
fill_off: Color,
|
||
/// Bright core of the gate handle.
|
||
handle: Color,
|
||
/// Dark outline behind the handle, so it stays visible over the green fill.
|
||
handle_edge: Color,
|
||
}
|
||
|
||
impl GateMeter {
|
||
/// Maps a cursor x (relative to the bar) to a gate threshold on the meter axis.
|
||
fn x_to_threshold(x: f32, width: f32) -> f32 {
|
||
(x / width.max(1.0)).clamp(0.0, 1.0) * METER_MAX
|
||
}
|
||
}
|
||
|
||
#[derive(Default)]
|
||
struct GateMeterState {
|
||
dragging: bool,
|
||
}
|
||
|
||
impl Program<AppMessage> for GateMeter {
|
||
type State = GateMeterState;
|
||
|
||
fn update(
|
||
&self,
|
||
state: &mut Self::State,
|
||
event: &Event,
|
||
bounds: Rectangle,
|
||
cursor: mouse::Cursor,
|
||
) -> Option<Action<AppMessage>> {
|
||
match event {
|
||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
||
if let Some(p) = cursor.position_in(bounds) {
|
||
state.dragging = true;
|
||
let t = Self::x_to_threshold(p.x, bounds.width);
|
||
return Some(Action::publish(AppMessage::NoiseGateDragging(t)).and_capture());
|
||
}
|
||
}
|
||
// Track moves anywhere on screen so the drag survives leaving the bar.
|
||
Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => {
|
||
if let Some(p) = cursor.position() {
|
||
let t = Self::x_to_threshold(p.x - bounds.x, bounds.width);
|
||
return Some(Action::publish(AppMessage::NoiseGateDragging(t)).and_capture());
|
||
}
|
||
}
|
||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) if state.dragging => {
|
||
state.dragging = false;
|
||
let x = cursor.position().map(|p| p.x - bounds.x).unwrap_or(0.0);
|
||
let t = Self::x_to_threshold(x, bounds.width);
|
||
// Persist the final value on release.
|
||
return Some(Action::publish(AppMessage::NoiseGateChanged(t)).and_capture());
|
||
}
|
||
_ => {}
|
||
}
|
||
None
|
||
}
|
||
|
||
fn draw(
|
||
&self,
|
||
_state: &Self::State,
|
||
renderer: &Renderer,
|
||
_theme: &Theme,
|
||
bounds: Rectangle,
|
||
_cursor: mouse::Cursor,
|
||
) -> Vec<Geometry> {
|
||
let mut frame = Frame::new(renderer, bounds.size());
|
||
let w = bounds.width;
|
||
let h = bounds.height;
|
||
|
||
// Track.
|
||
frame.fill_rectangle(Point::ORIGIN, Size::new(w, h), self.track);
|
||
|
||
// Level fill, coloured by whether we're above the gate.
|
||
let level_frac = (self.level / METER_MAX).clamp(0.0, 1.0);
|
||
let fill = if self.level >= self.threshold { self.fill_on } else { self.fill_off };
|
||
if level_frac > 0.0 {
|
||
frame.fill_rectangle(Point::ORIGIN, Size::new(w * level_frac, h), fill);
|
||
}
|
||
|
||
// Gate handle: a bright vertical line + grip caps, each backed by a dark
|
||
// edge so the handle stays legible even when the green level sweeps past it.
|
||
let thr_frac = (self.threshold / METER_MAX).clamp(0.0, 1.0);
|
||
let x = (w * thr_frac).clamp(3.0, (w - 3.0).max(3.0));
|
||
// Dark edge (slightly larger), then bright core.
|
||
frame.fill(&Path::rectangle(Point::new(x - 3.0, 0.0), Size::new(6.0, h)), self.handle_edge);
|
||
frame.fill(&Path::rectangle(Point::new(x - 1.0, 0.0), Size::new(2.0, h)), self.handle);
|
||
// Grip caps top and bottom.
|
||
frame.fill(&Path::rectangle(Point::new(x - 5.5, 0.0), Size::new(11.0, 6.0)), self.handle_edge);
|
||
frame.fill(&Path::rectangle(Point::new(x - 4.0, 1.0), Size::new(8.0, 4.0)), self.handle);
|
||
frame.fill(&Path::rectangle(Point::new(x - 5.5, h - 6.0), Size::new(11.0, 6.0)), self.handle_edge);
|
||
frame.fill(&Path::rectangle(Point::new(x - 4.0, h - 5.0), Size::new(8.0, 4.0)), self.handle);
|
||
|
||
// Border.
|
||
frame.stroke(
|
||
&Path::rectangle(Point::ORIGIN, Size::new(w, h)),
|
||
canvas::Stroke::default().with_color(self.border).with_width(1.0),
|
||
);
|
||
|
||
vec![frame.into_geometry()]
|
||
}
|
||
|
||
fn mouse_interaction(
|
||
&self,
|
||
state: &Self::State,
|
||
bounds: Rectangle,
|
||
cursor: mouse::Cursor,
|
||
) -> mouse::Interaction {
|
||
if state.dragging || cursor.is_over(bounds) {
|
||
mouse::Interaction::ResizingHorizontally
|
||
} else {
|
||
mouse::Interaction::default()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A draggable splitter between two panels. Reports drag motion along its axis as
|
||
/// `AppMessage::DividerDragged(kind, delta_px)`; the parent applies + clamps it.
|
||
/// `vertical` = a vertical bar dragged horizontally (resizes width); otherwise a
|
||
/// horizontal bar dragged vertically (resizes height). Modeled on [`GateMeter`]'s
|
||
/// drag handling: the drag is tracked off the global cursor so it survives the
|
||
/// pointer leaving the thin divider strip.
|
||
struct Divider {
|
||
kind: DividerKind,
|
||
/// True = vertical bar (horizontal drag); false = horizontal bar (vertical drag).
|
||
vertical: bool,
|
||
/// Centre line colour.
|
||
line: Color,
|
||
/// Grip-dot colour.
|
||
grip: Color,
|
||
}
|
||
|
||
/// Drag state: the last cursor coordinate along the drag axis while dragging.
|
||
#[derive(Default)]
|
||
struct DividerState {
|
||
last: Option<f32>,
|
||
}
|
||
|
||
impl Program<AppMessage> for Divider {
|
||
type State = DividerState;
|
||
|
||
fn update(
|
||
&self,
|
||
state: &mut Self::State,
|
||
event: &Event,
|
||
bounds: Rectangle,
|
||
cursor: mouse::Cursor,
|
||
) -> Option<Action<AppMessage>> {
|
||
// Cursor coordinate along the drag axis (x for a vertical bar, else y).
|
||
let axis = |p: Point| if self.vertical { p.x } else { p.y };
|
||
match event {
|
||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
||
if cursor.is_over(bounds)
|
||
&& let Some(p) = cursor.position()
|
||
{
|
||
// Record the absolute start coordinate; subsequent moves yield
|
||
// deltas. Capture with a zero-delta no-op.
|
||
state.last = Some(axis(p));
|
||
return Some(
|
||
Action::publish(AppMessage::DividerDragged(self.kind, 0.0)).and_capture(),
|
||
);
|
||
}
|
||
}
|
||
// Track globally so the drag continues past the thin strip's bounds.
|
||
Event::Mouse(mouse::Event::CursorMoved { .. }) if state.last.is_some() => {
|
||
if let Some(p) = cursor.position() {
|
||
let cur = axis(p);
|
||
let last = state.last.unwrap();
|
||
state.last = Some(cur);
|
||
return Some(
|
||
Action::publish(AppMessage::DividerDragged(self.kind, cur - last))
|
||
.and_capture(),
|
||
);
|
||
}
|
||
}
|
||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
|
||
if state.last.is_some() =>
|
||
{
|
||
state.last = None;
|
||
// Persist the final position once, on release (not per pixel).
|
||
return Some(Action::publish(AppMessage::PersistConfig).and_capture());
|
||
}
|
||
_ => {}
|
||
}
|
||
None
|
||
}
|
||
|
||
fn draw(
|
||
&self,
|
||
_state: &Self::State,
|
||
renderer: &Renderer,
|
||
_theme: &Theme,
|
||
bounds: Rectangle,
|
||
_cursor: mouse::Cursor,
|
||
) -> Vec<Geometry> {
|
||
let mut frame = Frame::new(renderer, bounds.size());
|
||
let w = bounds.width;
|
||
let h = bounds.height;
|
||
if self.vertical {
|
||
let x = w / 2.0;
|
||
frame.fill(&Path::rectangle(Point::new(x - 1.0, 0.0), Size::new(2.0, h)), self.line);
|
||
let cy = h / 2.0;
|
||
for i in -1..=1 {
|
||
let dy = cy + i as f32 * 6.0;
|
||
frame.fill(
|
||
&Path::rectangle(Point::new(x - 1.5, dy - 1.5), Size::new(3.0, 3.0)),
|
||
self.grip,
|
||
);
|
||
}
|
||
} else {
|
||
let y = h / 2.0;
|
||
frame.fill(&Path::rectangle(Point::new(0.0, y - 1.0), Size::new(w, 2.0)), self.line);
|
||
let cx = w / 2.0;
|
||
for i in -1..=1 {
|
||
let dx = cx + i as f32 * 6.0;
|
||
frame.fill(
|
||
&Path::rectangle(Point::new(dx - 1.5, y - 1.5), Size::new(3.0, 3.0)),
|
||
self.grip,
|
||
);
|
||
}
|
||
}
|
||
vec![frame.into_geometry()]
|
||
}
|
||
|
||
fn mouse_interaction(
|
||
&self,
|
||
state: &Self::State,
|
||
bounds: Rectangle,
|
||
cursor: mouse::Cursor,
|
||
) -> mouse::Interaction {
|
||
if state.last.is_some() || cursor.is_over(bounds) {
|
||
if self.vertical {
|
||
mouse::Interaction::ResizingHorizontally
|
||
} else {
|
||
mouse::Interaction::ResizingVertically
|
||
}
|
||
} else {
|
||
mouse::Interaction::default()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Wrap a base screen with the room-layout picker popup when it's open: a dimmed,
|
||
/// click-to-dismiss backdrop plus a centered gallery of clickable layout
|
||
/// thumbnails. Returns the base unchanged when the picker is closed. Used by both
|
||
/// the launch and in-call screens (the Settings screen shows thumbnails inline).
|
||
fn with_layout_picker<'a>(
|
||
base: Element<'a, AppMessage>,
|
||
state: &'a AppState,
|
||
) -> Element<'a, AppMessage> {
|
||
if !state.layout_picker_open {
|
||
return base;
|
||
}
|
||
let pal = state.config.theme.palette();
|
||
let crust = pal.crust;
|
||
let mantle = pal.mantle;
|
||
let base_c = pal.base;
|
||
let surface = pal.surface;
|
||
let overlay = pal.overlay;
|
||
let text_c = pal.text;
|
||
let subtext = pal.subtext;
|
||
let blue = pal.blue;
|
||
let green = pal.green;
|
||
|
||
let backdrop = mouse_area(
|
||
container(horizontal_space())
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill)
|
||
.style(move |_t: &Theme| container::Style {
|
||
background: Some(Background::Color(Color { a: 0.55, ..crust })),
|
||
..Default::default()
|
||
}),
|
||
)
|
||
.on_press(AppMessage::CloseLayoutPicker);
|
||
|
||
let thumb = |layout: RoomLayout, label: &'static str| -> Element<'a, AppMessage> {
|
||
let selected = state.config.room_layout == layout;
|
||
let tile = Canvas::new(LayoutThumb {
|
||
layout,
|
||
selected,
|
||
base: base_c,
|
||
surface,
|
||
overlay,
|
||
border: if selected { blue } else { surface },
|
||
})
|
||
.width(iced::Length::Fixed(168.0))
|
||
.height(iced::Length::Fixed(112.0));
|
||
let btn = button(tile)
|
||
.on_press(AppMessage::SelectRoomLayout(layout))
|
||
.padding(2)
|
||
.style(move |_t: &Theme, status: button::Status| button::Style {
|
||
background: Some(Background::Color(match status {
|
||
button::Status::Hovered => surface,
|
||
_ => Color::TRANSPARENT,
|
||
})),
|
||
text_color: text_c,
|
||
border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 8.0.into() },
|
||
..Default::default()
|
||
});
|
||
let (lbl_color, marker): (Color, Element<'a, AppMessage>) = if selected {
|
||
(blue, text("● current").size(10).color(green).into())
|
||
} else {
|
||
(text_c, vertical_space(0.0).into())
|
||
};
|
||
column![btn, text(label).size(13).color(lbl_color), marker]
|
||
.spacing(4)
|
||
.align_x(iced::alignment::Horizontal::Center)
|
||
.into()
|
||
};
|
||
|
||
let gallery = container(
|
||
column![
|
||
row![
|
||
text("Choose room layout").size(16).color(blue),
|
||
horizontal_space(),
|
||
button(text("✕").size(16).color(subtext))
|
||
.on_press(AppMessage::CloseLayoutPicker)
|
||
.style(|_t: &Theme, _s: button::Status| button::Style {
|
||
background: None,
|
||
..Default::default()
|
||
})
|
||
.padding(2),
|
||
]
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
row![
|
||
thumb(RoomLayout::ThreeColumn, "3-Column"),
|
||
thumb(RoomLayout::BottomDock, "Bottom Dock"),
|
||
thumb(RoomLayout::Drawer, "Drawer"),
|
||
]
|
||
.spacing(20),
|
||
text("Click a layout to apply it instantly.").size(11).color(subtext),
|
||
]
|
||
.spacing(16),
|
||
)
|
||
.style(move |_t: &Theme| container::Style {
|
||
text_color: Some(text_c),
|
||
background: Some(Background::Color(mantle)),
|
||
border: Border { color: surface, width: 1.0, radius: 12.0.into() },
|
||
..Default::default()
|
||
})
|
||
.padding(20)
|
||
.width(iced::Length::Fixed(600.0));
|
||
|
||
stack![
|
||
base,
|
||
backdrop,
|
||
container(gallery)
|
||
.center_x(iced::Length::Fill)
|
||
.center_y(iced::Length::Fill),
|
||
]
|
||
.into()
|
||
}
|
||
|
||
/// A small two-pane glyph for the square layout-picker button in the top bar.
|
||
struct LayoutIcon {
|
||
fg: Color,
|
||
}
|
||
|
||
impl Program<AppMessage> for LayoutIcon {
|
||
type State = ();
|
||
fn draw(
|
||
&self,
|
||
_state: &(),
|
||
renderer: &Renderer,
|
||
_theme: &Theme,
|
||
bounds: Rectangle,
|
||
_cursor: mouse::Cursor,
|
||
) -> Vec<Geometry> {
|
||
let mut f = Frame::new(renderer, bounds.size());
|
||
let (w, h) = (bounds.width, bounds.height);
|
||
f.stroke(
|
||
&Path::rectangle(Point::new(1.0, 1.5), Size::new(w - 2.0, h - 3.0)),
|
||
canvas::Stroke::default().with_color(self.fg).with_width(1.5),
|
||
);
|
||
// Vertical split into two panes.
|
||
f.fill(
|
||
&Path::rectangle(Point::new(w * 0.5 - 0.75, 1.5), Size::new(1.5, h - 3.0)),
|
||
self.fg,
|
||
);
|
||
vec![f.into_geometry()]
|
||
}
|
||
}
|
||
|
||
/// A schematic thumbnail of a [`RoomLayout`] (colored boxes for each panel),
|
||
/// drawn so the picker stays in sync with the theme and needs no image assets.
|
||
struct LayoutThumb {
|
||
layout: RoomLayout,
|
||
selected: bool,
|
||
base: Color,
|
||
surface: Color,
|
||
/// Accent shade for the chat panel, to distinguish it from the others.
|
||
overlay: Color,
|
||
/// Border colour (blue when selected, surface otherwise).
|
||
border: Color,
|
||
}
|
||
|
||
impl LayoutThumb {
|
||
fn pane(f: &mut Frame, x: f32, y: f32, w: f32, h: f32, color: Color) {
|
||
f.fill(&Path::rectangle(Point::new(x, y), Size::new(w, h)), color);
|
||
}
|
||
}
|
||
|
||
impl Program<AppMessage> for LayoutThumb {
|
||
type State = ();
|
||
fn draw(
|
||
&self,
|
||
_state: &(),
|
||
renderer: &Renderer,
|
||
_theme: &Theme,
|
||
bounds: Rectangle,
|
||
_cursor: mouse::Cursor,
|
||
) -> Vec<Geometry> {
|
||
let mut f = Frame::new(renderer, bounds.size());
|
||
let (w, h) = (bounds.width, bounds.height);
|
||
f.fill(&Path::rectangle(Point::ORIGIN, Size::new(w, h)), self.base);
|
||
|
||
let pad = 9.0;
|
||
let (ix, iy) = (pad, pad);
|
||
let (iw, ih) = (w - 2.0 * pad, h - 2.0 * pad);
|
||
let g = 4.0;
|
||
match self.layout {
|
||
RoomLayout::ThreeColumn => {
|
||
let c1 = iw * 0.34;
|
||
let c3 = iw * 0.24;
|
||
let c2 = iw - c1 - c3 - 2.0 * g;
|
||
Self::pane(&mut f, ix, iy, c1, ih, self.surface);
|
||
Self::pane(&mut f, ix + c1 + g, iy, c2, ih, self.overlay);
|
||
Self::pane(&mut f, ix + c1 + g + c2 + g, iy, c3, ih, self.surface);
|
||
}
|
||
RoomLayout::BottomDock => {
|
||
let toph = ih * 0.6;
|
||
let both = ih - toph - g;
|
||
let lw = iw * 0.66;
|
||
Self::pane(&mut f, ix, iy, lw, toph, self.surface);
|
||
Self::pane(&mut f, ix + lw + g, iy, iw - lw - g, toph, self.surface);
|
||
Self::pane(&mut f, ix, iy + toph + g, iw, both, self.overlay);
|
||
}
|
||
RoomLayout::Drawer => {
|
||
let dw = iw * 0.18;
|
||
let main = iw - dw - g;
|
||
let lw = main * 0.62;
|
||
Self::pane(&mut f, ix, iy, lw, ih, self.surface);
|
||
Self::pane(&mut f, ix + lw + g, iy, main - lw - g, ih, self.surface);
|
||
Self::pane(&mut f, ix + main + g, iy, dw, ih, self.overlay);
|
||
}
|
||
}
|
||
|
||
// Border on top (thicker + blue when selected).
|
||
let bw: f32 = if self.selected { 2.0 } else { 1.0 };
|
||
f.stroke(
|
||
&Path::rectangle(Point::new(bw / 2.0, bw / 2.0), Size::new(w - bw, h - bw)),
|
||
canvas::Stroke::default().with_color(self.border).with_width(bw),
|
||
);
|
||
vec![f.into_geometry()]
|
||
}
|
||
}
|
||
|
||
/// A small palette-preview tile for the theme picker (modeled on `LayoutThumb`):
|
||
/// the theme's background, a surface panel with two "text" lines to preview
|
||
/// legibility, and a stack of accent colours. Border highlights the selection.
|
||
struct ThemeSwatch {
|
||
palette: Palette,
|
||
selected: bool,
|
||
border: Color,
|
||
}
|
||
|
||
impl Program<AppMessage> for ThemeSwatch {
|
||
type State = ();
|
||
fn draw(
|
||
&self,
|
||
_state: &(),
|
||
renderer: &Renderer,
|
||
_theme: &Theme,
|
||
bounds: Rectangle,
|
||
_cursor: mouse::Cursor,
|
||
) -> Vec<Geometry> {
|
||
let mut f = Frame::new(renderer, bounds.size());
|
||
let (w, h) = (bounds.width, bounds.height);
|
||
let p = &self.palette;
|
||
let pad = 7.0;
|
||
|
||
// Background fill.
|
||
f.fill(&Path::rectangle(Point::ORIGIN, Size::new(w, h)), p.base);
|
||
|
||
// Surface panel on the left half, with two text-colour lines on it.
|
||
let panel_w = (w - 2.0 * pad) * 0.52;
|
||
f.fill(
|
||
&Path::rectangle(Point::new(pad, pad), Size::new(panel_w, h - 2.0 * pad)),
|
||
p.surface,
|
||
);
|
||
let mut line = |y: f32, ww: f32, col: Color| {
|
||
f.fill(
|
||
&Path::rectangle(Point::new(pad + 6.0, y), Size::new(ww, 3.0)),
|
||
col,
|
||
);
|
||
};
|
||
line(pad + 9.0, panel_w * 0.66, p.text);
|
||
line(pad + 17.0, panel_w * 0.48, p.subtext);
|
||
|
||
// Accent swatches stacked on the right.
|
||
let ax = pad + panel_w + 6.0;
|
||
let aw = (w - pad - ax).max(2.0);
|
||
let accents = [p.blue, p.green, p.yellow, p.red, p.lavender];
|
||
let gap = 3.0;
|
||
let ah = ((h - 2.0 * pad) - gap * (accents.len() as f32 - 1.0)) / accents.len() as f32;
|
||
for (i, c) in accents.iter().enumerate() {
|
||
let ay = pad + i as f32 * (ah + gap);
|
||
f.fill(&Path::rectangle(Point::new(ax, ay), Size::new(aw, ah)), *c);
|
||
}
|
||
|
||
// Border (thicker + accent when selected).
|
||
let bw: f32 = if self.selected { 2.0 } else { 1.0 };
|
||
f.stroke(
|
||
&Path::rectangle(Point::new(bw / 2.0, bw / 2.0), Size::new(w - bw, h - bw)),
|
||
canvas::Stroke::default().with_color(self.border).with_width(bw),
|
||
);
|
||
vec![f.into_geometry()]
|
||
}
|
||
}
|
||
|
||
/// The PeerSpeak icon set, drawn on a `canvas` so it needs no image/font asset
|
||
/// and recolors with the theme (consistent with `LayoutThumb`/`GateMeter`).
|
||
/// Each icon is authored in a 24×24 space and scaled to the widget size.
|
||
#[derive(Clone, Copy, PartialEq)]
|
||
enum IconKind {
|
||
Mic,
|
||
MicOff,
|
||
Headphones,
|
||
Deafen,
|
||
Speaker,
|
||
SpeakerOff,
|
||
Monitor,
|
||
Eye,
|
||
Record,
|
||
Stop,
|
||
Chat,
|
||
People,
|
||
Clock,
|
||
Settings,
|
||
Copy,
|
||
Leave,
|
||
Create,
|
||
Live,
|
||
}
|
||
|
||
struct Icon {
|
||
kind: IconKind,
|
||
color: Color,
|
||
/// Stroke weight in the 24-unit authoring space (scaled with the widget).
|
||
weight: f32,
|
||
}
|
||
|
||
/// Build an icon element at `size` px in `color`. Used throughout the room/
|
||
/// settings UI in place of emoji.
|
||
fn icon<'a>(kind: IconKind, size: f32, color: Color) -> Element<'a, AppMessage> {
|
||
Canvas::new(Icon { kind, color, weight: 2.0 })
|
||
.width(size)
|
||
.height(size)
|
||
.into()
|
||
}
|
||
|
||
/// Centered "icon + label" content for a full-width control-panel button. The
|
||
/// icon takes the button's foreground colour so it matches the label.
|
||
fn btn_content<'a>(kind: IconKind, label: &'a str, color: Color) -> Element<'a, AppMessage> {
|
||
container(
|
||
row![icon(kind, 18.0, color), text(label).size(16)]
|
||
.spacing(8)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
)
|
||
.center_x(iced::Length::Fill)
|
||
.into()
|
||
}
|
||
|
||
impl Program<AppMessage> for Icon {
|
||
type State = ();
|
||
fn draw(
|
||
&self,
|
||
_state: &(),
|
||
renderer: &Renderer,
|
||
_theme: &Theme,
|
||
bounds: Rectangle,
|
||
_cursor: mouse::Cursor,
|
||
) -> Vec<Geometry> {
|
||
use std::f32::consts::PI;
|
||
let mut f = Frame::new(renderer, bounds.size());
|
||
let s = bounds.width.min(bounds.height) / 24.0;
|
||
let col = self.color;
|
||
let sw = (self.weight * s).max(1.0);
|
||
let p = |x: f32, y: f32| Point::new(x * s, y * s);
|
||
let stk = || {
|
||
canvas::Stroke::default()
|
||
.with_color(col)
|
||
.with_width(sw)
|
||
.with_line_cap(canvas::LineCap::Round)
|
||
.with_line_join(canvas::LineJoin::Round)
|
||
};
|
||
// A stroked polyline (or polygon when `closed`).
|
||
let poly = |pts: &[(f32, f32)], closed: bool| {
|
||
Path::new(|b| {
|
||
for (i, q) in pts.iter().enumerate() {
|
||
let pt = p(q.0, q.1);
|
||
if i == 0 {
|
||
b.move_to(pt);
|
||
} else {
|
||
b.line_to(pt);
|
||
}
|
||
}
|
||
if closed {
|
||
b.close();
|
||
}
|
||
})
|
||
};
|
||
// A stroked circular arc from a0..a1 radians (0 = +x, sweeps toward +y).
|
||
let arcp = |cx: f32, cy: f32, r: f32, a0: f32, a1: f32| {
|
||
Path::new(|b| {
|
||
b.arc(iced::widget::canvas::path::Arc {
|
||
center: p(cx, cy),
|
||
radius: r * s,
|
||
start_angle: iced::Radians(a0),
|
||
end_angle: iced::Radians(a1),
|
||
});
|
||
})
|
||
};
|
||
let rrect = |x: f32, y: f32, w: f32, h: f32, r: f32| {
|
||
Path::rounded_rectangle(p(x, y), Size::new(w * s, h * s), (r * s).into())
|
||
};
|
||
|
||
match self.kind {
|
||
IconKind::Mic => {
|
||
f.stroke(&rrect(9.0, 2.5, 6.0, 11.0, 3.0), stk());
|
||
f.stroke(&arcp(12.0, 11.0, 6.5, 0.0, PI), stk());
|
||
f.stroke(&poly(&[(12.0, 17.5), (12.0, 21.0)], false), stk());
|
||
f.stroke(&poly(&[(8.5, 21.0), (15.5, 21.0)], false), stk());
|
||
}
|
||
IconKind::MicOff => {
|
||
f.stroke(&rrect(9.0, 2.5, 6.0, 11.0, 3.0), stk());
|
||
f.stroke(&arcp(12.0, 11.0, 6.5, 0.0, PI), stk());
|
||
f.stroke(&poly(&[(12.0, 17.5), (12.0, 21.0)], false), stk());
|
||
f.stroke(&poly(&[(8.5, 21.0), (15.5, 21.0)], false), stk());
|
||
f.stroke(&poly(&[(3.5, 3.5), (20.5, 20.5)], false), stk());
|
||
}
|
||
IconKind::Headphones => {
|
||
f.stroke(&arcp(12.0, 12.5, 8.0, PI, 2.0 * PI), stk());
|
||
f.stroke(&rrect(3.0, 13.0, 4.5, 7.0, 2.25), stk());
|
||
f.stroke(&rrect(16.5, 13.0, 4.5, 7.0, 2.25), stk());
|
||
}
|
||
IconKind::Deafen => {
|
||
f.stroke(&arcp(12.0, 12.5, 8.0, PI, 2.0 * PI), stk());
|
||
f.stroke(&rrect(3.0, 13.0, 4.5, 7.0, 2.25), stk());
|
||
f.stroke(&rrect(16.5, 13.0, 4.5, 7.0, 2.25), stk());
|
||
f.stroke(&poly(&[(3.0, 3.0), (21.0, 21.0)], false), stk());
|
||
}
|
||
IconKind::Speaker => {
|
||
f.stroke(
|
||
&poly(
|
||
&[
|
||
(4.0, 9.5),
|
||
(7.5, 9.5),
|
||
(13.0, 5.0),
|
||
(13.0, 19.0),
|
||
(7.5, 14.5),
|
||
(4.0, 14.5),
|
||
],
|
||
true,
|
||
),
|
||
stk(),
|
||
);
|
||
f.stroke(&arcp(14.0, 12.0, 4.0, -0.5, 0.5), stk());
|
||
f.stroke(&arcp(14.0, 12.0, 7.5, -0.65, 0.65), stk());
|
||
}
|
||
IconKind::SpeakerOff => {
|
||
f.stroke(
|
||
&poly(
|
||
&[
|
||
(4.0, 9.5),
|
||
(7.5, 9.5),
|
||
(13.0, 5.0),
|
||
(13.0, 19.0),
|
||
(7.5, 14.5),
|
||
(4.0, 14.5),
|
||
],
|
||
true,
|
||
),
|
||
stk(),
|
||
);
|
||
f.stroke(&poly(&[(16.5, 9.5), (21.5, 14.5)], false), stk());
|
||
f.stroke(&poly(&[(21.5, 9.5), (16.5, 14.5)], false), stk());
|
||
}
|
||
IconKind::Monitor => {
|
||
f.stroke(&rrect(3.0, 4.0, 18.0, 12.0, 2.0), stk());
|
||
f.stroke(&poly(&[(9.0, 20.0), (15.0, 20.0)], false), stk());
|
||
f.stroke(&poly(&[(12.0, 16.0), (12.0, 20.0)], false), stk());
|
||
// up-arrow inside (the "share" cue)
|
||
f.stroke(&poly(&[(12.0, 13.0), (12.0, 7.5)], false), stk());
|
||
f.stroke(&poly(&[(9.5, 10.0), (12.0, 7.5), (14.5, 10.0)], false), stk());
|
||
}
|
||
IconKind::Eye => {
|
||
f.stroke(
|
||
&poly(
|
||
&[
|
||
(2.5, 12.0),
|
||
(6.0, 8.5),
|
||
(12.0, 7.0),
|
||
(18.0, 8.5),
|
||
(21.5, 12.0),
|
||
(18.0, 15.5),
|
||
(12.0, 17.0),
|
||
(6.0, 15.5),
|
||
],
|
||
true,
|
||
),
|
||
stk(),
|
||
);
|
||
f.stroke(&Path::circle(p(12.0, 12.0), 3.0 * s), stk());
|
||
}
|
||
IconKind::Record => {
|
||
f.fill(&Path::circle(p(12.0, 12.0), 6.0 * s), col);
|
||
}
|
||
IconKind::Stop => {
|
||
f.fill(&rrect(6.0, 6.0, 12.0, 12.0, 2.5), col);
|
||
}
|
||
IconKind::Chat => {
|
||
f.stroke(&rrect(3.0, 4.0, 18.0, 12.0, 3.0), stk());
|
||
f.stroke(&poly(&[(8.0, 16.0), (8.0, 20.5), (12.5, 16.0)], false), stk());
|
||
}
|
||
IconKind::People => {
|
||
f.stroke(&Path::circle(p(9.0, 9.0), 3.2 * s), stk());
|
||
f.stroke(&arcp(9.0, 20.0, 5.3, PI, 2.0 * PI), stk());
|
||
f.stroke(&Path::circle(p(16.5, 8.0), 2.7 * s), stk());
|
||
f.stroke(&arcp(16.5, 20.0, 4.6, PI, 2.0 * PI), stk());
|
||
}
|
||
IconKind::Clock => {
|
||
f.stroke(&Path::circle(p(12.0, 12.0), 8.5 * s), stk());
|
||
f.stroke(&poly(&[(12.0, 7.0), (12.0, 12.0)], false), stk());
|
||
f.stroke(&poly(&[(12.0, 12.0), (15.5, 14.0)], false), stk());
|
||
}
|
||
IconKind::Settings => {
|
||
f.stroke(&poly(&[(4.0, 7.0), (20.0, 7.0)], false), stk());
|
||
f.stroke(&poly(&[(4.0, 12.0), (20.0, 12.0)], false), stk());
|
||
f.stroke(&poly(&[(4.0, 17.0), (20.0, 17.0)], false), stk());
|
||
f.fill(&Path::circle(p(15.0, 7.0), 2.4 * s), col);
|
||
f.fill(&Path::circle(p(9.0, 12.0), 2.4 * s), col);
|
||
f.fill(&Path::circle(p(16.0, 17.0), 2.4 * s), col);
|
||
}
|
||
IconKind::Copy => {
|
||
f.stroke(&rrect(8.0, 8.0, 12.0, 12.0, 2.0), stk());
|
||
f.stroke(&rrect(4.0, 4.0, 12.0, 12.0, 2.0), stk());
|
||
}
|
||
IconKind::Leave => {
|
||
f.stroke(
|
||
&poly(&[(9.0, 4.0), (6.0, 4.0), (6.0, 20.0), (9.0, 20.0)], false),
|
||
stk(),
|
||
);
|
||
f.stroke(&poly(&[(9.5, 12.0), (20.0, 12.0)], false), stk());
|
||
f.stroke(&poly(&[(15.5, 8.0), (20.0, 12.0), (15.5, 16.0)], false), stk());
|
||
}
|
||
IconKind::Create => {
|
||
f.stroke(&Path::circle(p(12.0, 12.0), 8.5 * s), stk());
|
||
f.stroke(&poly(&[(12.0, 8.0), (12.0, 16.0)], false), stk());
|
||
f.stroke(&poly(&[(8.0, 12.0), (16.0, 12.0)], false), stk());
|
||
}
|
||
IconKind::Live => {
|
||
f.fill(&Path::circle(p(12.0, 12.0), 3.0 * s), col);
|
||
f.stroke(&arcp(12.0, 12.0, 5.0, -0.4 * PI, 0.4 * PI), stk());
|
||
f.stroke(&arcp(12.0, 12.0, 5.0, 0.6 * PI, 1.4 * PI), stk());
|
||
f.stroke(&arcp(12.0, 12.0, 8.0, -0.32 * PI, 0.32 * PI), stk());
|
||
f.stroke(&arcp(12.0, 12.0, 8.0, 0.68 * PI, 1.32 * PI), stk());
|
||
}
|
||
}
|
||
vec![f.into_geometry()]
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::{
|
||
format_duration, initial_window_position, reconnect_attempt_chime, reconnected_chime,
|
||
GateMeter, METER_MAX,
|
||
};
|
||
|
||
#[test]
|
||
fn x11_restores_saved_window_position() {
|
||
// On X11 (is_wayland = false) a saved position becomes Specific(x, y).
|
||
match initial_window_position(Some(120), Some(-40), false) {
|
||
iced::window::Position::Specific(p) => {
|
||
assert_eq!(p.x, 120.0);
|
||
assert_eq!(p.y, -40.0); // negative coords (left/above primary) preserved
|
||
}
|
||
other => panic!("expected Specific, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn wayland_always_centers_even_with_saved_position() {
|
||
assert!(matches!(
|
||
initial_window_position(Some(120), Some(40), true),
|
||
iced::window::Position::Centered
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn missing_or_partial_saved_position_centers() {
|
||
assert!(matches!(
|
||
initial_window_position(None, None, false),
|
||
iced::window::Position::Centered
|
||
));
|
||
// A half-saved position (one axis missing) is not enough to restore.
|
||
assert!(matches!(
|
||
initial_window_position(Some(10), None, false),
|
||
iced::window::Position::Centered
|
||
));
|
||
assert!(matches!(
|
||
initial_window_position(None, Some(10), false),
|
||
iced::window::Position::Centered
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn format_duration_renders_mss_and_hmmss() {
|
||
assert_eq!(format_duration(0), "0:00");
|
||
assert_eq!(format_duration(5), "0:05");
|
||
assert_eq!(format_duration(59), "0:59");
|
||
assert_eq!(format_duration(65), "1:05");
|
||
assert_eq!(format_duration(600), "10:00");
|
||
assert_eq!(format_duration(3599), "59:59");
|
||
// Past an hour switches to h:mm:ss with zero-padded minutes/seconds.
|
||
assert_eq!(format_duration(3600), "1:00:00");
|
||
assert_eq!(format_duration(3661), "1:01:01");
|
||
assert_eq!(format_duration(3725), "1:02:05");
|
||
}
|
||
use super::{clamp_chat_height, clamp_participants_width, CHAT_MIN_H, PARTICIPANTS_MIN_W};
|
||
|
||
#[test]
|
||
fn participants_width_clamps_to_min_and_leaves_room_for_controls() {
|
||
let window_w = 900.0;
|
||
// Mid-range value passes through unchanged.
|
||
assert_eq!(clamp_participants_width(500.0, window_w), 500.0);
|
||
// Below the minimum snaps up to it.
|
||
assert_eq!(clamp_participants_width(50.0, window_w), PARTICIPANTS_MIN_W);
|
||
// Too wide leaves at least CONTROLS_MIN_W (220) for the controls panel.
|
||
assert_eq!(clamp_participants_width(window_w, window_w), window_w - 220.0);
|
||
}
|
||
|
||
#[test]
|
||
fn chat_height_clamps_to_min_and_leaves_room_above() {
|
||
let window_h = 760.0;
|
||
assert_eq!(clamp_chat_height(200.0, window_h), 200.0);
|
||
assert_eq!(clamp_chat_height(10.0, window_h), CHAT_MIN_H);
|
||
// Too tall leaves at least ABOVE_CHAT_MIN_H (300) above the dock.
|
||
assert_eq!(clamp_chat_height(window_h, window_h), window_h - 300.0);
|
||
}
|
||
|
||
#[test]
|
||
fn sanitize_chat_strips_control_chars_and_collapses_whitespace() {
|
||
use super::sanitize_chat;
|
||
// Plain text is unchanged.
|
||
assert_eq!(sanitize_chat("hello world"), "hello world");
|
||
// Leading/trailing whitespace trimmed; interior runs collapsed.
|
||
assert_eq!(sanitize_chat(" hi there "), "hi there");
|
||
// Control chars (NUL, CR, LF, TAB, ANSI ESC) become spaces, then collapse.
|
||
// The ESC of an ANSI sequence is stripped; the printable "[31m" remains inert text.
|
||
assert_eq!(sanitize_chat("a\u{0}b\r\nc\td\u{1b}[31m"), "a b c d [31m");
|
||
// An all-control / all-whitespace message sanitizes to empty.
|
||
assert_eq!(sanitize_chat("\u{0}\r\n\t "), "");
|
||
// Unicode/emoji text is preserved.
|
||
assert_eq!(sanitize_chat("héllo 🎙 世界"), "héllo 🎙 世界");
|
||
}
|
||
|
||
#[test]
|
||
fn sanitize_chat_caps_length() {
|
||
use super::{sanitize_chat, CHAT_MSG_MAX_CHARS};
|
||
let long = "x".repeat(CHAT_MSG_MAX_CHARS + 500);
|
||
assert_eq!(sanitize_chat(&long).chars().count(), CHAT_MSG_MAX_CHARS);
|
||
}
|
||
|
||
#[test]
|
||
fn short_id_is_panic_free_on_short_and_unicode_ids() {
|
||
use super::short_id;
|
||
// Normal long hex id → first 8 chars.
|
||
assert_eq!(short_id("abcdef0123456789"), "abcdef01");
|
||
// Shorter-than-8 id must not panic (the old `[..8]` slice would).
|
||
assert_eq!(short_id("abc"), "abc");
|
||
assert_eq!(short_id(""), "");
|
||
// Multi-byte chars: take 8 *chars*, never split a byte boundary.
|
||
assert_eq!(short_id("héllo 世界 more"), "héllo 世界");
|
||
}
|
||
|
||
#[test]
|
||
fn divider_clamps_are_finite_on_a_tiny_window() {
|
||
// A window smaller than the reserves must not produce NaN/inverted ranges.
|
||
let pw = clamp_participants_width(300.0, 100.0);
|
||
assert!(pw.is_finite() && pw >= PARTICIPANTS_MIN_W);
|
||
let ch = clamp_chat_height(300.0, 100.0);
|
||
assert!(ch.is_finite() && ch >= CHAT_MIN_H);
|
||
}
|
||
|
||
#[test]
|
||
fn controls_and_drawer_width_clamps() {
|
||
use super::{clamp_chat_drawer_width, clamp_controls_width, CHAT_MIN_W, CONTROLS_MIN_W};
|
||
let window_w = 1000.0;
|
||
// Mid-range passes through.
|
||
assert_eq!(clamp_controls_width(300.0, window_w), 300.0);
|
||
assert_eq!(clamp_chat_drawer_width(320.0, window_w), 320.0);
|
||
// Below minimum snaps up.
|
||
assert_eq!(clamp_controls_width(10.0, window_w), CONTROLS_MIN_W);
|
||
assert_eq!(clamp_chat_drawer_width(10.0, window_w), CHAT_MIN_W);
|
||
// Tiny window stays finite and at/above the minimum (no inverted range).
|
||
let c = clamp_controls_width(400.0, 100.0);
|
||
assert!(c.is_finite() && c >= CONTROLS_MIN_W);
|
||
let d = clamp_chat_drawer_width(400.0, 100.0);
|
||
assert!(d.is_finite() && d >= CHAT_MIN_W);
|
||
}
|
||
|
||
use crate::notify::Sound;
|
||
use iroh::EndpointId;
|
||
use std::collections::HashSet;
|
||
|
||
const W: f32 = 200.0;
|
||
|
||
#[test]
|
||
fn gate_drag_maps_left_edge_to_zero() {
|
||
assert_eq!(GateMeter::x_to_threshold(0.0, W), 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn gate_drag_maps_right_edge_to_full_scale() {
|
||
assert!((GateMeter::x_to_threshold(W, W) - METER_MAX).abs() < 1e-6);
|
||
}
|
||
|
||
#[test]
|
||
fn gate_drag_maps_midpoint_to_half_scale() {
|
||
assert!((GateMeter::x_to_threshold(W / 2.0, W) - METER_MAX / 2.0).abs() < 1e-6);
|
||
}
|
||
|
||
#[test]
|
||
fn gate_drag_clamps_out_of_bounds() {
|
||
// Dragging past either edge clamps to the axis ends (no overshoot).
|
||
assert_eq!(GateMeter::x_to_threshold(-50.0, W), 0.0);
|
||
assert!((GateMeter::x_to_threshold(W + 80.0, W) - METER_MAX).abs() < 1e-6);
|
||
}
|
||
|
||
#[test]
|
||
fn gate_drag_zero_width_is_finite() {
|
||
// A degenerate bound (pre-layout) must not divide by zero / produce NaN.
|
||
let t = GateMeter::x_to_threshold(10.0, 0.0);
|
||
assert!(t.is_finite());
|
||
assert!((0.0..=METER_MAX).contains(&t));
|
||
}
|
||
|
||
/// A distinct, real `EndpointId` (via the same path the network tests use).
|
||
fn id() -> EndpointId {
|
||
iroh::EndpointAddr::from(iroh::SecretKey::generate().public()).id
|
||
}
|
||
|
||
#[test]
|
||
fn first_dial_does_not_chime() {
|
||
let mut connecting = HashSet::new();
|
||
let ever = HashSet::new(); // never connected
|
||
let peer = id();
|
||
// A peer we've never linked with is just an initial connect, not a reconnect.
|
||
assert_eq!(reconnect_attempt_chime(&mut connecting, &ever, peer), None);
|
||
assert!(connecting.contains(&peer)); // but it is now marked connecting
|
||
}
|
||
|
||
#[test]
|
||
fn first_connected_does_not_chime() {
|
||
let mut connecting = HashSet::from([id()]);
|
||
let mut ever = HashSet::new();
|
||
let peer = id();
|
||
connecting.insert(peer);
|
||
// First successful link: record it, but no "reconnected" chime.
|
||
assert_eq!(reconnected_chime(&mut connecting, &mut ever, peer), None);
|
||
assert!(ever.contains(&peer));
|
||
assert!(!connecting.contains(&peer)); // connecting state cleared
|
||
}
|
||
|
||
#[test]
|
||
fn reconnect_attempt_chimes_once_then_stays_silent_on_redials() {
|
||
let peer = id();
|
||
let mut connecting = HashSet::new();
|
||
let ever = HashSet::from([peer]); // previously connected
|
||
|
||
// First drop → one ReconnectAttempt chime.
|
||
assert_eq!(
|
||
reconnect_attempt_chime(&mut connecting, &ever, peer),
|
||
Some(Sound::ReconnectAttempt)
|
||
);
|
||
// The supervisor redials repeatedly while still down — must NOT re-chime.
|
||
assert_eq!(reconnect_attempt_chime(&mut connecting, &ever, peer), None);
|
||
assert_eq!(reconnect_attempt_chime(&mut connecting, &ever, peer), None);
|
||
}
|
||
|
||
#[test]
|
||
fn full_outage_cycle_chimes_attempt_then_reconnected_each_time() {
|
||
let peer = id();
|
||
let mut connecting = HashSet::new();
|
||
let mut ever = HashSet::new();
|
||
|
||
// Initial connect: silent, records the peer.
|
||
assert_eq!(reconnected_chime(&mut connecting, &mut ever, peer), None);
|
||
|
||
// Outage 1: attempt chimes once, recovery chimes "reconnected".
|
||
assert_eq!(
|
||
reconnect_attempt_chime(&mut connecting, &ever, peer),
|
||
Some(Sound::ReconnectAttempt)
|
||
);
|
||
assert_eq!(
|
||
reconnected_chime(&mut connecting, &mut ever, peer),
|
||
Some(Sound::Reconnected)
|
||
);
|
||
|
||
// Outage 2: a fresh disconnect chimes again (per-outage, not once-ever).
|
||
assert_eq!(
|
||
reconnect_attempt_chime(&mut connecting, &ever, peer),
|
||
Some(Sound::ReconnectAttempt)
|
||
);
|
||
assert_eq!(
|
||
reconnected_chime(&mut connecting, &mut ever, peer),
|
||
Some(Sound::Reconnected)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_push_chat_single() {
|
||
use super::{push_chat, ChatEntry};
|
||
let mut messages = Vec::new();
|
||
let entry = ChatEntry {
|
||
name: "Alice".to_string(),
|
||
text: "Hello".to_string(),
|
||
mine: true,
|
||
};
|
||
push_chat(&mut messages, entry);
|
||
assert_eq!(messages.len(), 1);
|
||
assert_eq!(messages[0].name, "Alice");
|
||
assert_eq!(messages[0].text, "Hello");
|
||
assert!(messages[0].mine);
|
||
}
|
||
|
||
#[test]
|
||
fn test_push_chat_below_cap() {
|
||
use super::{push_chat, ChatEntry, CHAT_HISTORY_MAX};
|
||
let mut messages = Vec::new();
|
||
for i in 0..CHAT_HISTORY_MAX - 10 {
|
||
push_chat(
|
||
&mut messages,
|
||
ChatEntry {
|
||
name: format!("User{}", i),
|
||
text: format!("Msg{}", i),
|
||
mine: i % 2 == 0,
|
||
},
|
||
);
|
||
}
|
||
assert_eq!(messages.len(), CHAT_HISTORY_MAX - 10);
|
||
assert_eq!(messages[0].name, "User0");
|
||
assert_eq!(messages[0].text, "Msg0");
|
||
assert_eq!(messages[messages.len() - 1].name, format!("User{}", CHAT_HISTORY_MAX - 11));
|
||
assert_eq!(messages[messages.len() - 1].text, format!("Msg{}", CHAT_HISTORY_MAX - 11));
|
||
}
|
||
|
||
#[test]
|
||
fn test_push_chat_above_cap() {
|
||
use super::{push_chat, ChatEntry, CHAT_HISTORY_MAX};
|
||
let mut messages = Vec::new();
|
||
let total_pushes = CHAT_HISTORY_MAX + 5;
|
||
for i in 0..total_pushes {
|
||
push_chat(
|
||
&mut messages,
|
||
ChatEntry {
|
||
name: format!("User{}", i),
|
||
text: format!("Msg{}", i),
|
||
mine: i % 2 == 0,
|
||
},
|
||
);
|
||
}
|
||
assert_eq!(messages.len(), CHAT_HISTORY_MAX);
|
||
// The first 5 should be dropped. First remaining should be index 5.
|
||
assert_eq!(messages[0].name, "User5");
|
||
assert_eq!(messages[0].text, "Msg5");
|
||
// The last remaining should be index total_pushes - 1.
|
||
assert_eq!(messages[messages.len() - 1].name, format!("User{}", total_pushes - 1));
|
||
assert_eq!(messages[messages.len() - 1].text, format!("Msg{}", total_pushes - 1));
|
||
}
|
||
}
|