Files
peerspeak/src/app/mod.rs
T
molluskandClaude Opus 4.8 3d2124ed98 fix(ui): keep the Leave button reachable on a short window (A12)
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>
2026-06-14 15:35:49 -04:00

3123 lines
129 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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));
}
}