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}; use iced::widget::{ container, column, row, text, button, text_input, scrollable, slider, checkbox, pick_list, progress_bar, canvas, Canvas, Column, }; 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>>> = 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; #[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), 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, } fn core_subscription() -> impl iced::futures::Stream { iced::stream::channel(100, |mut output: iced::futures::channel::mpsc::Sender| 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, output_devices: Vec, selected_input: Option, selected_output: Option, config: AppConfig, peers: HashMap, peer_volumes: HashMap, audio_levels: HashMap, /// Peers we've locally muted (their audio isn't mixed into our output). locally_muted: HashSet, /// When we joined the current room, for the in-room call-duration timer. call_started: Option, /// 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, /// Room text-chat history (newest last) and the pending input line. chat_messages: Vec, chat_input: String, /// 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, /// 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, controller: Arc, current_screen: Screen, } 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 config = AppConfig::load(); 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 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 { name: "Peer".to_string(), ticket_input: "".to_string(), status_message: "Ready to connect".to_string(), self_id: "".to_string(), ticket: "".to_string(), is_muted: false, is_deafened: false, ptt_enabled: false, ptt_active: false, ptt_hotkey: keyboard::Key::Named(keyboard::key::Named::Space), is_setting_hotkey: false, input_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(), mic_level: 0.0, mic_test_active: false, connecting: HashSet::new(), ever_connected: HashSet::new(), controller, current_screen: Screen::Home, } } } fn theme(_state: &AppState) -> Theme { Theme::Dark } pub fn run_gui() -> iced::Result { iced::application(AppState::default, update, view) .title("PeerSpeak P2P Voice Chat") .theme(theme) .subscription(subscription) .window(iced::window::Settings { // Taller default so the bottom chat dock doesn't squeeze the controls // column. Layout is responsive (Fill), so resizing still works. size: iced::Size::new(900.0, 760.0), position: iced::window::Position::Centered, exit_on_close_request: true, ..Default::default() }) .run() } fn subscription(_state: &AppState) -> Subscription { 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, ever_connected: &HashSet, id: EndpointId, ) -> Option { 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, ever_connected: &mut HashSet, id: EndpointId, ) -> Option { 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 { 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(); // 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(); // 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::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; 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 } => { push_chat(&mut state.chat_messages, ChatEntry { name, text, mine: false }); } 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::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::ChatSubmit => { let text = state.chat_input.trim().to_string(); 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(_) => {} 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.", } } /// 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}") } } /// 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, 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 Colors let color_crust = Color::from_rgb8(17, 17, 27); let color_mantle = Color::from_rgb8(24, 24, 37); let color_base = Color::from_rgb8(30, 30, 46); let color_surface = Color::from_rgb8(49, 50, 68); let color_text = Color::from_rgb8(205, 214, 244); let color_subtext = Color::from_rgb8(166, 173, 200); let color_blue = Color::from_rgb8(137, 180, 250); let color_lavender = Color::from_rgb8(180, 190, 254); let color_red = Color::from_rgb8(243, 139, 168); let color_maroon = Color::from_rgb8(233, 146, 160); let color_green = Color::from_rgb8(166, 227, 161); let color_yellow = Color::from_rgb8(249, 226, 175); // Style Helpers let c_style = move |bg: Color, b_color: Color, radius: f32| { move |_theme: &Theme| container::Style { text_color: Some(color_text), background: Some(Background::Color(bg)), border: Border { color: b_color, width: if b_color == Color::TRANSPARENT { 0.0 } else { 1.0 }, radius: radius.into(), }, ..Default::default() } }; let b_style = move |bg: Color, hover_bg: Color, text_c: Color, radius: f32| { move |_theme: &Theme, status: button::Status| { let active_bg = match status { button::Status::Hovered => hover_bg, _ => bg, }; button::Style { background: Some(Background::Color(active_bg)), text_color: text_c, border: Border { color: Color::TRANSPARENT, width: 0.0, radius: radius.into(), }, ..Default::default() } } }; let t_style = move |_theme: &Theme, _status: text_input::Status| { text_input::Style { background: Background::Color(color_crust), border: Border { color: color_surface, width: 1.0, radius: 6.0.into(), }, icon: color_subtext, placeholder: Color::from_rgb8(108, 112, 134), value: color_text, selection: color_blue, } }; let top_bar = row![ horizontal_space(), button(text("⚙ Settings").size(14)) .on_press(AppMessage::NavigateToSettings) .style(b_style(color_surface, color_blue, color_text, 6.0)) .padding(8) ].width(iced::Length::Fill).padding(10); if state.current_screen == Screen::Settings { let 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::from_rgb8(180, 180, 180)), 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 (label, bg) = if state.mic_test_active { ("⏹ Stop mic test", color_red) } else { ("🎙 Test mic", color_surface) }; button(text(label).size(12)) .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); let settings_content = scrollable( column![ text("Settings").size(24).color(color_blue), vertical_space(10.0), text("Device Settings").size(14).color(color_subtext), vertical_space(6.0), row![ column![ text("Input Device").size(12).color(Color::from_rgb8(180, 180, 180)), 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(4).width(iced::Length::Fill), column![ text("Output Device").size(12).color(Color::from_rgb8(180, 180, 180)), 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(4).width(iced::Length::Fill), ].spacing(20).align_y(iced::alignment::Vertical::Top).width(iced::Length::Fill), vertical_space(12.0), row![ column![ text("Mic Level & Noise Gate").size(14).color(color_subtext), 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), column![ text("Network Privacy").size(14).color(color_subtext), 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_surface), ].spacing(4).width(iced::Length::Fill), ].spacing(20).align_y(iced::alignment::Vertical::Top).width(iced::Length::Fill), vertical_space(12.0), column![ text("Notification Chimes").size(14).color(color_subtext), checkbox(state.config.notifications_enabled) .label("Enable sound notifications") .on_toggle(AppMessage::ToggleNotifications), ].spacing(8).width(iced::Length::Fill), vertical_space(12.0), text("Custom Chime Files (WAV Paths)").size(14).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), vertical_space(14.0), button( text("Back") .size(16) .align_x(iced::alignment::Horizontal::Center) ) .on_press(AppMessage::NavigateBack) .style(b_style(color_surface, color_blue, color_text, 8.0)) .padding(14) .width(iced::Length::Fixed(150.0)) ] .align_x(iced::alignment::Horizontal::Center) .spacing(10) .width(iced::Length::Fill) ); let settings_box = container(settings_content) .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( text("Create New Room") .size(16) .align_x(iced::alignment::Horizontal::Center) ) .on_press(AppMessage::CreatePressed) .style(b_style(color_blue, color_lavender, color_crust, 8.0)) .padding(12) .width(iced::Length::Fill); let join_group = column![ text("Join Existing Room").size(14).color(color_subtext), vertical_space(4.0), text_input("Paste room ticket here...", &state.ticket_input) .on_input(AppMessage::TicketInputChanged) .style(t_style) .padding(10), vertical_space(8.0), button( text("Join Room") .size(16) .align_x(iced::alignment::Horizontal::Center) ) .on_press(AppMessage::JoinPressed) .style(b_style(color_surface, color_blue, color_text, 8.0)) .padding(12) .width(iced::Length::Fill) ]; let status = text(&state.status_message) .size(14) .color(color_subtext); let content = container( column![ logo, subtitle, vertical_space(20.0), nickname_input, vertical_space(16.0), create_btn, vertical_space(16.0), text("— OR —").size(12).color(color_surface).align_x(iced::alignment::Horizontal::Center), vertical_space(16.0), join_group, vertical_space(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); container( column![ top_bar, vertical_space(20.0), scroll ].align_x(iced::alignment::Horizontal::Center) ) .width(iced::Length::Fill) .height(iced::Length::Fill) .style(c_style(color_crust, Color::TRANSPARENT, 0.0)) .into() } else { // --- ROOM SCREEN --- let 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(), text(format!("👥 {participant_count} in room")) .size(14) .color(color_subtext), text(format!("⏱ {}", format_duration(call_secs))) .size(14) .color(color_subtext), if state.recording { let rec_secs = state.recording_started.map(|t| t.elapsed().as_secs()).unwrap_or(0); container( text(format!("● REC {}", format_duration(rec_secs))) .size(13) .color(color_red) ) .style(c_style(color_crust, color_red, 6.0)) .padding(6) } else { container(text("")).padding(0) }, horizontal_space(), text(format!("My ID: {}", &state.self_id[..8])) .size(14) .color(color_subtext), button(text("Copy Ticket").size(12)) .on_press(AppMessage::CopyToClipboard) .style(b_style(color_surface, color_blue, color_text, 6.0)) .padding(6) ] .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), 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 indicator = if is_connecting { let label = if state.ever_connected.contains(peer_id) { "[Reconnecting…]" } else { "[Connecting…]" }; text(label).size(14).color(color_yellow) } else if peer.is_muted { text("[Muted]").size(14).color(color_red) } else if is_speaking { text("[Speaking]").size(14).color(color_green) } else { text("[Idle]").size(14).color(color_subtext) }; let 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_label, mute_bg, mute_fg) = if is_locally_muted { ("🔇", color_red, color_crust) } else { ("🔊", color_surface, color_text) }; let mute_btn = button(text(mute_label).size(14)) .on_press(AppMessage::TogglePeerMute(peer_id_clone)) .style(b_style(mute_bg, color_blue, mute_fg, 6.0)) .padding(6); // 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: {}", &peer_id.to_string()[..8])).size(11).color(color_subtext) ], horizontal_space(), 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) .width(iced::Length::FillPortion(2)) .height(iced::Length::Fill); // Control Panel Column let mute_text = if state.is_muted { "Unmute Mic" } else { "Mute Mic" }; let mute_bg = if state.is_muted { color_red } else { color_surface }; let mute_hover = if state.is_muted { color_maroon } else { color_blue }; let mute_fg = if state.is_muted { color_crust } else { color_text }; let deafen_text = if state.is_deafened { "Undeafen Audio" } else { "Deafen Audio" }; let deafen_bg = if state.is_deafened { color_red } else { color_surface }; let deafen_hover = if state.is_deafened { color_maroon } else { color_blue }; let deafen_fg = if state.is_deafened { color_crust } else { color_text }; let ctrl_buttons = column![ button( text(mute_text) .size(16) .align_x(iced::alignment::Horizontal::Center) ) .on_press(AppMessage::ToggleMutePressed) .style(b_style(mute_bg, mute_hover, mute_fg, 8.0)) .padding(14) .width(iced::Length::Fill), vertical_space(10.0), button( text(deafen_text) .size(16) .align_x(iced::alignment::Horizontal::Center) ) .on_press(AppMessage::ToggleDeafenPressed) .style(b_style(deafen_bg, deafen_hover, deafen_fg, 8.0)) .padding(14) .width(iced::Length::Fill), vertical_space(20.0), checkbox(state.ptt_enabled).label("Push-to-Talk").on_toggle(AppMessage::TogglePtt), vertical_space(10.0), if state.ptt_enabled { column![ text(format!("Hotkey: {}", if state.is_setting_hotkey { "Press any key...".to_string() } else { format!("{:?}", state.ptt_hotkey) })).size(14).color(color_subtext), button(text("Set Hotkey").size(12).align_x(iced::alignment::Horizontal::Center)) .on_press(AppMessage::StartSettingHotkey) .style(b_style(color_surface, color_blue, color_text, 6.0)) .padding(8) .width(iced::Length::Fill) ].spacing(8) } else { column![] }, vertical_space(20.0), { let (rec_label, rec_bg, rec_hover, rec_fg) = if state.recording { ("⏹ Stop Recording", color_red, color_maroon, color_crust) } else { ("⏺ Record Call", color_surface, color_blue, color_text) }; button( text(rec_label) .size(16) .align_x(iced::alignment::Horizontal::Center) ) .on_press(AppMessage::ToggleRecording) .style(b_style(rec_bg, rec_hover, rec_fg, 8.0)) .padding(14) .width(iced::Length::Fill) }, vertical_space(30.0), button( text("Leave Room") .size(16) .align_x(iced::alignment::Horizontal::Center) ) .on_press(AppMessage::LeavePressed) .style(b_style(color_red, color_maroon, color_crust, 8.0)) .padding(14) .width(iced::Length::Fill) ]; let control_panel = container( column![ text("Controls").size(18).color(color_blue), vertical_space(15.0), ctrl_buttons, vertical_space(20.0), text(&state.status_message).size(12).color(color_subtext) ] ) .style(c_style(color_mantle, Color::TRANSPARENT, 0.0)) .padding(15) .width(iced::Length::FillPortion(1)) .height(iced::Length::Fill); let main_layout = row![peers_panel, control_panel] .spacing(15) .height(iced::Length::Fill); // --- CHAT DOCK (full-width strip along the bottom) --- 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_dock = container( column![ text("Chat").size(16).color(color_blue), chat_scroll, chat_input_row, ] .spacing(8), ) .style(c_style(color_mantle, color_surface, 8.0)) .padding(12) .width(iced::Length::Fill) .height(iced::Length::Fixed(180.0)); container( column![ top_bar, header_container, vertical_space(12.0), main_layout, vertical_space(12.0), chat_dock ] ) .padding(15) .width(iced::Length::Fill) .height(iced::Length::Fill) .style(c_style(color_crust, Color::TRANSPARENT, 0.0)) .into() } } /// 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 for GateMeter { type State = GateMeterState; fn update( &self, state: &mut Self::State, event: &Event, bounds: Rectangle, cursor: mouse::Cursor, ) -> Option> { 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 { 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() } } } #[cfg(test)] mod tests { use super::{format_duration, reconnect_attempt_chime, reconnected_chime, GateMeter, METER_MAX}; #[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 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)); } }