use crate::core::{CoreController, messages::{CoreCommand, UiEvent}}; use crate::network::PeerState; use crate::notify::{self, Sound}; use crate::audio::eq::{EqSettings, EQ_GAIN_DB_MAX, EQ_GAIN_DB_MIN}; use crate::audio::clip_player::{ ClipPlayer, SharedClipStatus, format_time as format_clip_time, progress as clip_progress, seek_target, status_snapshot, }; use crate::audio::{AudioDevice, enumerate_audio_devices}; use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout}; use crate::hotkeys::{format_binding, HotkeyAction, HotkeyContext, KeyBinding}; use crate::presence::PresenceMode; use crate::theme::{AppTheme, Palette}; use crate::widget::context_input::{context_input, locked_value}; use crate::widget::selectable_text::selectable_rich_text; use iced::widget::{ container, column, row, text, button, scrollable, slider, checkbox, pick_list, radio, tooltip, progress_bar, canvas, Canvas, Column, stack, mouse_area, span, responsive, }; use iced::widget::text_input; 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, VecDeque}; 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, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SettingsCategory { Audio, Hotkeys, Recording, Profile, Appearance, Network, Notifications, Games, } impl SettingsCategory { const ALL: [SettingsCategory; 8] = [ SettingsCategory::Audio, SettingsCategory::Hotkeys, SettingsCategory::Recording, SettingsCategory::Profile, SettingsCategory::Appearance, SettingsCategory::Network, SettingsCategory::Notifications, SettingsCategory::Games, ]; fn label(self) -> &'static str { match self { SettingsCategory::Audio => "Audio", SettingsCategory::Hotkeys => "Hotkeys", SettingsCategory::Recording => "Recording", SettingsCategory::Profile => "Profile", SettingsCategory::Appearance => "Appearance", SettingsCategory::Network => "Network", SettingsCategory::Notifications => "Notifications", SettingsCategory::Games => "Games", } } fn hint(self) -> &'static str { match self { SettingsCategory::Audio => "Devices, mic gate, echo", SettingsCategory::Hotkeys => "Focused keyboard shortcuts", SettingsCategory::Recording => "Mixed and stem capture", SettingsCategory::Profile => "Avatar and identity", SettingsCategory::Appearance => "Layout and theme", SettingsCategory::Network => "Relay and privacy mode", SettingsCategory::Notifications => "Chimes and sounds", SettingsCategory::Games => "Detection, presence, backgrounds", } } } impl std::fmt::Display for SettingsCategory { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.label()) } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum HomeLayoutMode { FocusedEmpty, ThreeColumn, Stacked, } fn home_layout_mode(width: f32, has_recents: bool, has_friends: bool) -> HomeLayoutMode { if width < 900.0 { HomeLayoutMode::Stacked } else if !has_recents && !has_friends { HomeLayoutMode::FocusedEmpty } else if width >= 1280.0 { HomeLayoutMode::ThreeColumn } else { HomeLayoutMode::Stacked } } /// 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, /// Sender's node id string, used to key their avatar colour (W4). `None` only /// for any future system-generated lines. from: Option, /// Optional file attachment descriptor. The bytes (if fetched) live in /// `AppState.attachments` keyed by `(author, attachment.id)`; the entry only /// holds the descriptor so history stays cheap. attachment: Option, } /// Fetch state of a chat attachment's bytes (session-only). #[derive(Debug, Clone)] enum AttachmentState { /// Bytes in hand (image decoded-valid, or a file ready to save). Ready(Vec), /// Fetch or decode failed; carries a short reason for the UI. Failed(String), } /// Identifies one fetched attachment by BOTH the authoring peer and the /// attachment id. The id is attacker-chosen, so a malicious peer can reuse a /// victim's id to alias displayed/saved content; keying on the author too means /// each chat line resolves only its own sender's bytes (Tier C F-12). type AttachmentKey = (EndpointId, crate::files::AttachmentId); /// Cap on retained attachment results so an insider streaming distinct /// attachments can't grow the cache without bound (Tier C F-02). Sized well /// above any realistic on-screen image working set. const ATTACHMENT_CACHE_CAP: usize = 64; /// One cached attachment result: its fetch state plus, for ready images, the /// pre-built iced image handle (built once on arrival, not per redraw — the /// e917c53 flicker fix). `handle` is `None` for files and failures. #[derive(Debug, Clone)] struct AttachmentEntry { state: AttachmentState, handle: Option, } /// Bounded store of fetched chat-attachment results, keyed by [`AttachmentKey`]. /// /// Eviction is insertion-order (oldest first), NOT true LRU: iced's `view` /// borrows `&self`, so the render read path cannot reorder an access-ordered /// cache. With a generous cap the newest entries — the ones actually on screen — /// are always retained, so a normal session never evicts; only an abusive stream /// of distinct attachments hits the bound (Tier C F-02). /// /// Construct via [`AttachmentCache::new`] — there is deliberately no `Default`, /// because a zero cap would make `insert` evict endlessly. #[derive(Debug)] struct AttachmentCache { entries: HashMap, /// Keys in insertion order; the front is the eviction candidate. Holds /// exactly the present keys (one entry each), so it is bounded by `cap`. order: VecDeque, cap: usize, } impl AttachmentCache { fn new(cap: usize) -> Self { Self { entries: HashMap::new(), order: VecDeque::new(), cap: cap.max(1) } } /// Insert or replace an entry. A brand-new key evicts the oldest entries /// until there is room; replacing an existing key keeps its position (and so /// its age), only updating the value. fn insert(&mut self, key: AttachmentKey, state: AttachmentState, handle: Option) { if !self.entries.contains_key(&key) { while self.entries.len() >= self.cap { match self.order.pop_front() { Some(old) => { self.entries.remove(&old); } None => break, } } self.order.push_back(key); } self.entries.insert(key, AttachmentEntry { state, handle }); } fn get(&self, key: &AttachmentKey) -> Option<&AttachmentState> { self.entries.get(key).map(|e| &e.state) } fn handle(&self, key: &AttachmentKey) -> Option<&iced::widget::image::Handle> { self.entries.get(key).and_then(|e| e.handle.as_ref()) } fn is_ready(&self, key: &AttachmentKey) -> bool { matches!(self.entries.get(key), Some(AttachmentEntry { state: AttachmentState::Ready(_), .. })) } fn clear(&mut self) { self.entries.clear(); self.order.clear(); } #[cfg(test)] fn len(&self) -> usize { self.entries.len() } } /// 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, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EqBand { Low, Mid, High, } /// 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; /// Upper bound for waiting on orderly core shutdown before letting the window exit. const SHUTDOWN_TIMEOUT_SECS: u64 = 5; /// How long a room-level clock-skew warning remains visible without dismissal. const CLOCK_SKEW_WARNING_VISIBLE_SECS: u64 = 12; /// 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), RoomNameChanged(String), JoinPressed, CreatePressed, LeavePressed, ToggleMutePressed, ToggleDeafenPressed, UiEventReceived(UiEvent), CopyToClipboard, /// Copy an arbitrary string to the clipboard (e.g. the full node ID). CopyText(String), /// No-op message for controlled read-only selectable fields. Noop, TogglePtt(bool), StartHotkeyCapture(HotkeyAction), ClearHotkey(HotkeyAction), PeerVolumeChanged(EndpointId, f32), PeerPanChanged(EndpointId, f32), PeerGateChanged(EndpointId, f32), PeerEqChanged(EndpointId, EqBand, 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), /// Choose the friends presence posture (W7): invisible / normal / discoverable. PresenceModeSelected(PresenceMode), /// Friends list (W7 P5): add-form edits, add, remove, and local rename. FriendAddIdChanged(String), FriendAddNameChanged(String), AddFriend, /// Add a peer you're in a call with to your friends list (W7), using their /// live presence name + address so they're reachable immediately. AddFriendFromRoom(EndpointId), RemoveFriend(EndpointId), RenameFriend(EndpointId, String), /// Join the gathering a friend is in (W7 B2), via the member ticket their /// presence reply carried. Mirrors a manual ticket join. JoinFriendRoom(String), /// Rejoin a room from the recents list (W7 P5), via its stored ticket. Mirrors /// a manual ticket join; best-effort (works only while the room is still live). JoinRecent(String), /// Drop a room from the recents list (W7 P5), the × on a recent entry. RemoveRecent(String), EventOccurred(Event), NavigateToSettings, NavigateBack, SelectSettingsCategory(SettingsCategory), ToggleNotifications(bool), ToggleEchoCancellation(bool), CustomSoundPathChanged(Sound, String), /// Toggle the per-sound enable flag for a single chime (W6). ToggleSoundEnabled(Sound, bool), /// Open / cancel the "Regenerate identity?" confirm modal (W7). OpenRegenerateIdentityConfirm, CloseRegenerateIdentityConfirm, /// Confirmed: mint a fresh persistent identity, discarding the old one. ConfirmRegenerateIdentity, ToggleMicTest(bool), /// Start/stop recording the call; the core confirms via Recording{Started,Stopped}. ToggleRecording, /// Live edits to the chat input line. ChatInputChanged(String), /// Open the native picker to attach a file to the chat. PickAttachmentFile, /// Result of the attach picker: (filename, bytes), or None if cancelled. AttachmentFilePicked(Option<(String, Vec)>), /// Save (downloading first if needed) a received attachment to disk. Carries /// the full `(author, id)` key so the correct sender's bytes are fetched and /// saved even if another peer reused the same attachment id (Tier C F-12). SaveAttachment(AttachmentKey), /// Result of the async save dialog: a status line to show, or None if cancelled. AttachmentSaved(Option), /// Fetch (if needed) and start an inline audio attachment. Carries the full /// `(author, id)` key (see [`AppMessage::SaveAttachment`]). PlayAudio(AttachmentKey), PauseAudio, ResumeAudio, SeekAudio(crate::files::AttachmentId, f32), /// Adjust the universal inline-clip playback volume (`1.0` = unity) from the /// master slider. Persisted to config; applied live only in universal mode. SetClipVolume(f32), /// Adjust volume from a clip's own row slider. In universal mode this drives /// the shared level; otherwise it sets just that clip's in-memory level. SetClipVolumeFor(crate::files::AttachmentId, f32), /// Toggle whether one universal level governs every clip (checked) or each /// clip keeps its own level (unchecked). ToggleUniversalClipVolume(bool), /// Redraw cadence while an inline clip is active. AudioTick, /// Send the current chat input line (Enter or the Send button). ChatSubmit, /// Open a clicked chat link in the system browser (A13). OpenUrl(String), /// 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, /// Open / close the live hotkey reference popup. OpenHotkeyInfo, CloseHotkeyInfo, /// Open / close the "screen sharing needs pixelpass" explainer popup (A11). OpenPixelpassHelp, ClosePixelpassHelp, /// Dismiss the room-level clock-skew warning banner. DismissClockSkewWarning, /// Auto-clear cadence while the clock-skew warning banner is visible. ClockSkewWarningTick, /// Choose a room layout (applied live + persisted, closes the popup). SelectRoomLayout(RoomLayout), /// Choose a UI theme (applied live + persisted). SelectTheme(AppTheme), /// Choose our avatar (monogram or a preset); applied live + persisted (W4). SelectAvatar(crate::avatar::Avatar), /// Open the native file picker to choose a custom avatar image (W4 Phase 3). PickAvatarFile, /// Result of the avatar file picker: the chosen file's raw bytes, or `None` /// if the user cancelled. AvatarFilePicked(Option>), /// Open the native file picker to choose a custom UI background image (W16). PickBackgroundFile, /// Result of the background file picker: the chosen file's raw bytes, or /// `None` if the user cancelled. BackgroundFilePicked(Option>), /// Clear the custom background, reverting to the theme background (W16). RemoveBackground, /// Set the background legibility scrim strength (0.0..=1.0) (W16). SetBackgroundDim(f32), /// Toggle the Chat drawer open/closed (drawer layout). ToggleDrawerChat, /// Start/stop sharing our own screen. When not sharing, opens the audio /// picker (A23) instead of starting immediately; when sharing, stops. ToggleScreenShare, /// Close the screen-share audio picker without sharing. CloseSharePicker, /// Select which app's audio to share in the picker: `Some(name)` for one app, /// `None` for the whole desktop ("All system audio"). SelectShareAudioApp(Option), /// Confirm the picker: start the share with the currently selected audio app. ConfirmShareScreen, /// Watch a peer's screen share, identified by their pixelpass ticket. WatchShare(String), /// Result of asynchronously enqueueing the core shutdown command. ShutdownCommandSent(bool), /// Fallback close if the core does not acknowledge shutdown promptly. ShutdownTimeout, // --- Game detection (W17/W18) --- /// Toggle broadcasting the detected game to peers (opt-in, default off). ToggleGamePresence(bool), /// Choose the manual detection override (Auto / None / the current game). GameOverrideSelected(GameOverrideChoice), /// Add-mapping form edits (executable basename → display name). GameMapExeChanged(String), GameMapNameChanged(String), /// Commit the add-mapping form into the process map. AddGameMapping, /// Remove a process→name mapping by its executable key. RemoveGameMapping(String), /// Open the native picker to set a per-game background for the given game id. PickGameBackground(String), /// Result of the per-game background picker: (game id, chosen bytes or None). GameBackgroundPicked(String, Option>), /// Clear a per-game background mapping by game id. RemoveGameBackground(String), } /// The manual game-detection override as shown in the Settings picker. Maps to a /// [`crate::game::ManualOverride`] using the app's currently-detected game for the /// `Current` choice (so "force this game" carries the live id + name). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GameOverrideChoice { /// Trust auto-detection (default). Auto, /// Force "not playing" — never broadcast a game. None, /// Pin the game currently detected (only offered while something is detected). Current, } impl std::fmt::Display for GameOverrideChoice { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { GameOverrideChoice::Auto => "Auto-detect", GameOverrideChoice::None => "None (don't show a game)", GameOverrideChoice::Current => "Pin current game", }) } } 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; } } } }) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct ClockSkewBanner { skew_secs: u64, peer_ahead: bool, expires_at: std::time::Instant, } pub struct AppState { name: String, ticket_input: String, /// The optional cosmetic room label typed on the home "Create" card (W7). /// Carried in the minted ticket so joiners inherit it; empty = unnamed room. room_name_input: String, status_message: String, self_id: String, ticket: String, is_muted: bool, is_deafened: bool, ptt_enabled: bool, ptt_active: bool, hotkey_capture: Option, input_devices: Vec, output_devices: Vec, selected_input: Option, selected_output: Option, config: AppConfig, /// Decoded bytes of the custom background image (W16), cached so `view()` /// doesn't read the file from disk on every redraw. Loaded on startup and /// refreshed when the background is changed/removed. `None` = no custom bg. background_image: Option, /// The locally-detected running game (game detection), as reported by core. /// Drives the per-game background (W18) and a local "Playing …" indicator. /// `None` = nothing detected. Independent of whether we broadcast it to peers /// (that's `config.game_presence_enabled`). current_game: Option, /// Add-mapping form state in the Games settings: the executable basename and /// display name being entered for a new process→name mapping. game_map_exe_input: String, game_map_name_input: String, /// Current manual-override selection shown in the Games settings picker. /// Session-only (not persisted); defaults to Auto each launch. game_override: GameOverrideChoice, peers: 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, /// Fetched/failed bytes + cached image handles for chat attachments, keyed by /// `(author, id)` and bounded. Session-only (cleared on leave); never /// persisted. (Tier C F-02 bound + F-12 author keying.) attachments: AttachmentCache, /// Attachments the user asked to save before the bytes arrived; when the /// fetch completes a save dialog is opened for them. Keyed by `(author, id)` /// so a same-id attachment from a different sender can't trigger the save. pending_saves: HashSet, /// Clip ids waiting for the existing attachment fetch path to return bytes. pending_plays: HashSet, /// Filename-hinted audio whose fetched bytes or decoder validation failed; /// these entries fall back to the normal file chip. invalid_audio: HashSet, /// Independent system-default-device player for chat clips. It never enters /// the call capture/mixer path. clip_player: ClipPlayer, clip_status: SharedClipStatus, /// Per-clip playback gain used when universal clip volume is disabled /// (`config.clip_volume_universal == false`). In-memory only; absent clips /// default to unity. Universal mode ignores this and uses `config.clip_volume`. clip_volumes: HashMap, /// 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 hotkey reference popup is open. hotkey_info_open: bool, /// Whether the pixelpass screen-share explainer popup is open (A11). pixelpass_help_open: bool, /// Whether the screen-share audio picker is open (A23). Opened by Share /// Screen when not already sharing; lets the user capture one app's audio /// instead of the whole desktop (which echoes the call back to viewers). share_picker_open: bool, /// Apps currently producing audio, shown in the share picker. Populated from /// `UiEvent::AudioAppsListed` after the picker requests an enumeration. share_audio_apps: Vec, /// The picker's current selection: `Some(name)` = capture that app's audio, /// `None` = "All system audio" (whole desktop; may echo the call). share_audio_selection: Option, /// A share start is in flight: `ConfirmShareScreen` was sent but the core /// hasn't yet replied with `ScreenShareStarted`/an error. Blocks reopening /// the picker (and re-confirming) during that startup window. Cleared on /// `ScreenShareStarted`, `ScreenShareStopped`, or any `Error`. share_starting: bool, /// While sharing a specific app's audio (A23 strict mode): `true` when that /// app's audio has stopped (or hasn't started yet), so viewers currently hear /// silence. Drives a transient warning. Always `false` for whole-desktop /// shares (pixelpass emits no `app_audio` events then) and when not sharing. share_audio_dropped: bool, /// Whether the current share is a specific-app capture (vs whole-desktop). /// Set from the confirmed selection on `ScreenShareStarted`, cleared on /// stop/reset. Gates applying `ShareAudioActive`, so a late event from a /// just-killed host can't flip the warning on a new whole-desktop share or /// after stop (audit P3, unscoped events). share_audio_app_active: bool, /// Whether the resolved pixelpass supports `--strict-audio` (per-app audio). /// `false` ⇒ the picker offers whole-desktop only, because a per-app share /// would pass a flag an older pixelpass rejects (audit P2). Optimistic `true` /// until the core's `AudioAppsListed` reports otherwise. share_app_audio_supported: bool, /// Room-level warning for a validly signed peer whose gossip timestamp falls /// outside the replay freshness window. The peer is not yet in the roster, so /// this is not attached to a participant card. clock_skew_warning: Option, /// 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, /// 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, settings_category: SettingsCategory, /// 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, /// Our persistent node id (W7), known from startup regardless of room state /// (distinct from `self_id`, which is room-scoped). `None` until the core /// reports it via `IdentityStatus`. self_node_id: Option, /// Whether our identity is persisted to disk. `false` = degraded ephemeral /// fallback (the key file couldn't be read/written) → the UI shows a warning, /// because the id won't survive the next launch and friends will stop /// recognising us. Defaults `true` (optimistic until told otherwise). identity_persisted: bool, /// The reason the identity isn't persisted, for the warning explainer. identity_error: Option, /// Whether the "Regenerate identity?" confirm modal is open. regenerate_identity_confirm_open: bool, /// Saved friends (W7) — a READ-ONLY MIRROR of the core-owned store, refreshed by /// `UiEvent::FriendsUpdated`. The GUI no longer loads/saves it; add/remove/rename /// go to core as commands. friends: crate::friends::FriendStore, /// True when core couldn't load `friends.json` (malformed) and is in a degraded /// read-only state — the GUI disables edits + warns so we don't clobber it (A16). friends_read_only: bool, /// Latest live presence per friend (W7 B2), from `UiEvent::FriendPresence`. A /// missing entry = treat as offline/unknown. friend_presence: std::collections::HashMap, /// "Add friend" form inputs: their node id (hex) and an optional name. friend_add_id: String, friend_add_name: String, /// Inline feedback for the add-friend form (e.g. a bad id), cleared on edit. friend_add_error: Option, /// Window close has been requested and the GUI is waiting for core teardown. closing: bool, } impl AppState { fn reset_room_state(&mut self) { self.clip_player.stop(); self.peers.clear(); self.audio_levels.clear(); self.locally_muted.clear(); self.chat_messages.clear(); self.chat_input.clear(); self.attachments.clear(); self.pending_saves.clear(); self.pending_plays.clear(); self.invalid_audio.clear(); self.connecting.clear(); self.ever_connected.clear(); self.recording = false; self.recording_started = None; self.call_started = None; self.mic_level = 0.0; self.self_sharing = false; self.share_picker_open = false; self.share_audio_apps.clear(); self.share_audio_selection = None; self.share_starting = false; self.share_audio_dropped = false; self.share_audio_app_active = false; self.share_app_audio_supported = true; self.clock_skew_warning = None; } 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); for sound in Sound::ALL { notify::set_sound_enabled(sound, config.sound_enabled(sound)); } 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 _ = controller.send(CoreCommand::SetPresenceMode(config.presence_mode)); // Game detection (W17/W18): seed the opt-in broadcast flag + the user's // process→name mappings from config. The manual override starts at Auto. let _ = controller.send(CoreCommand::SetGamePresenceEnabled(config.game_presence_enabled)); let _ = controller.send(CoreCommand::SetGameProcessMap(config.game_process_map.clone())); for (peer, settings) in &config.peer_eq { if let Ok(id) = peer.parse::() { let _ = controller.send(CoreCommand::SetPeerEq(id, *settings)); } } for (peer, pan) in &config.peer_pan { if let Ok(id) = peer.parse::() { let _ = controller.send(CoreCommand::SetPeerPan(id, *pan)); } } for (peer, volume) in &config.peer_volume { if let Ok(id) = peer.parse::() { let _ = controller.send(CoreCommand::SetPeerVolume(id, *volume)); } } for (peer, threshold) in &config.peer_gate { if let Ok(id) = peer.parse::() { let _ = controller.send(CoreCommand::SetPeerGate(id, *threshold)); } } 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(); let background_image = load_background_bytes(&config); let (clip_player, clip_status) = ClipPlayer::new(config.clip_volume); Self { // Pre-fill the nickname with the last one used (or "Peer" by default). name: config.username.clone(), ticket_input: "".to_string(), room_name_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, hotkey_capture: None, input_devices, output_devices, selected_input, selected_output, config, background_image, current_game: None, game_map_exe_input: String::new(), game_map_name_input: String::new(), game_override: GameOverrideChoice::Auto, peers: HashMap::new(), audio_levels: HashMap::new(), locally_muted: HashSet::new(), call_started: None, recording: false, recording_started: None, chat_messages: Vec::new(), attachments: AttachmentCache::new(ATTACHMENT_CACHE_CAP), pending_saves: HashSet::new(), pending_plays: HashSet::new(), invalid_audio: HashSet::new(), clip_player, clip_status, clip_volumes: HashMap::new(), chat_input: String::new(), window_size: Size::new(ww, wh), layout_picker_open: false, hotkey_info_open: false, pixelpass_help_open: false, share_picker_open: false, share_audio_apps: Vec::new(), share_audio_selection: None, share_starting: false, share_audio_dropped: false, share_audio_app_active: false, share_app_audio_supported: true, clock_skew_warning: None, drawer_chat_open: false, mic_level: 0.0, mic_test_active: false, connecting: HashSet::new(), ever_connected: HashSet::new(), controller, current_screen: Screen::Home, settings_category: SettingsCategory::Audio, self_sharing: false, pixelpass_available, self_node_id: None, identity_persisted: true, identity_error: None, regenerate_identity_confirm_open: false, // Core owns the friends store now; the GUI starts empty and fills in // from the FriendsUpdated event core emits at startup. friends: crate::friends::FriendStore::default(), friends_read_only: false, friend_presence: std::collections::HashMap::new(), friend_add_id: String::new(), friend_add_name: String::new(), friend_add_error: None, closing: false, } } } fn theme(state: &AppState) -> Theme { state.config.theme.base_theme() } /// Read the custom background PNG (W16) from disk into memory, if one is set and /// readable. Called once on startup and whenever the background changes, so the /// per-frame `view()` never touches the filesystem. A missing/unreadable file /// silently yields `None` (the UI falls back to the theme background). fn load_background_bytes(config: &AppConfig) -> Option { let path = config.background.as_deref()?; std::fs::read(path).ok().map(bytes::Bytes::from) } /// The effective background for the current state: the per-game override (W18) when /// the running `game` has a mapping in `config.game_backgrounds`, otherwise the /// single custom background (W16). A mapped-but-missing/unreadable per-game file /// falls back to the default WITHOUT forgetting the mapping (the file may return). fn effective_background_bytes( config: &AppConfig, game: Option<&crate::game::DetectedGame>, ) -> Option { if let Some(g) = game && let Some(path) = config.game_backgrounds.get(&g.id) && let Ok(bytes) = std::fs::read(path) { return Some(bytes::Bytes::from(bytes)); } load_background_bytes(config) } 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_with_background) .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. The field is // Linux-only in iced (X11/Wayland); see platform_specific_settings(). platform_specific: platform_specific_settings(), // We save the final size ourselves on CloseRequested, then exit. exit_on_close_request: false, ..Default::default() }) .run() } /// Window `PlatformSpecific` settings. `application_id` (used by X11/Wayland to /// match our `.desktop` launcher icon) only exists in iced on Linux, so it is /// set there and left at defaults on Windows. #[cfg(target_os = "linux")] fn platform_specific_settings() -> iced::window::settings::PlatformSpecific { iced::window::settings::PlatformSpecific { application_id: "peerspeak".to_string(), ..Default::default() } } #[cfg(not(target_os = "linux"))] fn platform_specific_settings() -> iced::window::settings::PlatformSpecific { iced::window::settings::PlatformSpecific::default() } /// 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 { 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, saved_y: Option, 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 { let core_sub = Subscription::run(core_subscription).map(AppMessage::UiEventReceived); let event_sub = iced::event::listen().map(AppMessage::EventOccurred); let audio_sub = if status_snapshot(&state.clip_status).playing_id.is_some() { iced::time::every(std::time::Duration::from_millis(250)).map(|_| AppMessage::AudioTick) } else { Subscription::none() }; let clock_skew_sub = if state.clock_skew_warning.is_some() { iced::time::every(std::time::Duration::from_secs(1)) .map(|_| AppMessage::ClockSkewWarningTick) } else { Subscription::none() }; Subscription::batch(vec![core_sub, event_sub, audio_sub, clock_skew_sub]) } fn shutdown_timeout_task() -> Task { Task::perform( async { let (tx, rx) = iced::futures::channel::oneshot::channel(); std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_secs(SHUTDOWN_TIMEOUT_SECS)); let _ = tx.send(()); }); let _ = rx.await; }, |_| AppMessage::ShutdownTimeout, ) } /// 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 in_call(state: &AppState) -> bool { !state.ticket.is_empty() } fn toggle_mute(state: &mut AppState) { if !in_call(state) { return; } let _ = state.controller.send(CoreCommand::ToggleMute); state.is_muted = !state.is_muted; notify::play(Sound::MicToggle, state.config.custom_sound_mic_toggle.as_deref()); } fn toggle_deafen(state: &mut AppState) { if !in_call(state) { return; } let _ = state.controller.send(CoreCommand::ToggleDeafen); state.is_deafened = !state.is_deafened; notify::play(Sound::MicToggle, state.config.custom_sound_mic_toggle.as_deref()); } fn handle_hotkey_pressed(state: &mut AppState, action: HotkeyAction) { match action { HotkeyAction::ToggleMute => toggle_mute(state), HotkeyAction::ToggleDeafen => toggle_deafen(state), HotkeyAction::OpenSettings => { state.current_screen = Screen::Settings; state.hotkey_info_open = false; state.layout_picker_open = false; } HotkeyAction::PushToTalk => { if in_call(state) && state.ptt_enabled && !state.ptt_active { state.ptt_active = true; let _ = state.controller.send(CoreCommand::SetPttActive(true)); } } HotkeyAction::LeaveRoom => { if in_call(state) { let _ = state.controller.send(CoreCommand::Leave); } } } } fn set_peer_pan_config(config: &mut AppConfig, id: EndpointId, pan: f32) -> f32 { let pan = pan.clamp(-1.0, 1.0); let key = id.to_string(); if pan.abs() <= 0.001 { config.peer_pan.remove(&key); } else { config.peer_pan.insert(key, pan); } pan } /// Store the per-peer listener volume, clamped to the slider range. Unity gain /// (`1.0`) is the implicit default, so an at-unity entry is removed rather than /// stored to keep the config tidy. Returns the clamped value. fn set_peer_volume_config(config: &mut AppConfig, id: EndpointId, volume: f32) -> f32 { let volume = volume.clamp(0.0, 2.0); let key = id.to_string(); if (volume - 1.0).abs() <= 0.001 { config.peer_volume.remove(&key); } else { config.peer_volume.insert(key, volume); } volume } /// Store the per-peer listener noise-gate threshold, clamped to the slider /// range. `0.0` means the gate is off, so an at-zero entry is removed rather /// than stored. Returns the clamped value. fn set_peer_gate_config(config: &mut AppConfig, id: EndpointId, threshold: f32) -> f32 { let threshold = threshold.clamp(0.0, METER_MAX); let key = id.to_string(); if threshold <= 0.0 { config.peer_gate.remove(&key); } else { config.peer_gate.insert(key, threshold); } threshold } fn peer_eq_settings(config: &AppConfig, id: &EndpointId) -> EqSettings { config .peer_eq .get(&id.to_string()) .copied() .unwrap_or_default() .clamped() } fn set_peer_eq_config( config: &mut AppConfig, id: EndpointId, band: EqBand, gain_db: f32, ) -> EqSettings { let key = id.to_string(); let mut settings = config.peer_eq.get(&key).copied().unwrap_or_default(); let gain_db = gain_db.clamp(EQ_GAIN_DB_MIN, EQ_GAIN_DB_MAX); match band { EqBand::Low => settings.low_gain_db = gain_db, EqBand::Mid => settings.mid_gain_db = gain_db, EqBand::High => settings.high_gain_db = gain_db, } settings = settings.clamped(); if settings.is_flat() { config.peer_eq.remove(&key); } else { config.peer_eq.insert(key, settings); } settings } fn pan_label(pan: f32) -> String { let pan = pan.clamp(-1.0, 1.0); if pan.abs() <= 0.01 { "Center".to_string() } else if pan < 0.0 { format!("L {:.0}%", pan.abs() * 100.0) } else { format!("R {:.0}%", pan * 100.0) } } fn update(state: &mut AppState, message: AppMessage) -> Task { match message { AppMessage::NicknameChanged(val) => { state.name = val; } AppMessage::TicketInputChanged(val) => { state.ticket_input = val; } AppMessage::RoomNameChanged(val) => { state.room_name_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(); // Clear any prior room's UI state now, at initiation, so an early // `PeerJoined` for the new room (which can beat `RoomJoined`) isn't // wiped. From Home this is a no-op (already cleared on leave). state.reset_room_state(); // 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(), room_name: String::new(), // joining: the label comes from the ticket input_device, output_device, echo_cancellation: state.config.echo_cancellation_enabled, avatar: state.config.avatar.clone(), }); } } 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(); // Clear any prior room's UI state at initiation (see JoinPressed). state.reset_room_state(); // 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(), room_name: state.room_name_input.clone(), input_device, output_device, echo_cancellation: state.config.echo_cancellation_enabled, avatar: state.config.avatar.clone(), }); } AppMessage::LeavePressed => { let _ = state.controller.send(CoreCommand::Leave); } AppMessage::ToggleScreenShare => { if state.self_sharing { let _ = state.controller.send(CoreCommand::StopScreenShare); } else if !state.share_starting { // Open the audio picker instead of sharing immediately, so the // user chooses which app's audio to capture rather than the whole // desktop (which echoes the call back to viewers, A23). Default // selection is "All system audio" (None). Kick off a fresh // enumeration so the list reflects what's playing right now. // Suppressed while a start is already in flight (`share_starting`) // so the picker can't be reopened during the startup window. state.share_picker_open = true; state.share_audio_selection = None; state.share_audio_apps.clear(); let _ = state.controller.send(CoreCommand::ListAudioApps); } } AppMessage::CloseSharePicker => { state.share_picker_open = false; } AppMessage::SelectShareAudioApp(app) => { state.share_audio_selection = app; } AppMessage::ConfirmShareScreen => { // Only a confirm from an open picker starts a share; a stray confirm // (or one arriving while a start is already in flight) is ignored, so // we can't double-send StartScreenShare. if state.share_picker_open && !state.share_starting { state.share_picker_open = false; state.share_starting = true; let audio_app = state.share_audio_selection.clone(); let _ = state .controller .send(CoreCommand::StartScreenShare { audio_app }); 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 => { toggle_mute(state); } AppMessage::ToggleDeafenPressed => { toggle_deafen(state); } AppMessage::UiEventReceived(event) => { match event { UiEvent::RoomJoined { ticket, self_id } => { // NB: do NOT clear peers here. `PeerJoined` rides a separate // channel sender (the gossip event task) and routinely arrives // BEFORE this `RoomJoined` (which the core emits only after audio // + echo-cancel setup), so clearing here would wipe a peer that // already announced → an empty roster. Room-scoped state is reset // at join *initiation* instead (see the Join* handlers). // Remember this gathering for one-click rejoin (W7 P5). The // emitted ticket is the canonical room door (topic + member // addr + label); push_recent de-dupes by topic and persists. let label = crate::network::PeerSpeakTicket::label_of(&ticket); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); crate::recents::push_recent(&mut state.config.recents, label, ticket.clone(), now); state.config.save(); 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.reset_room_state(); state.ticket = "".to_string(); state.status_message = "Ready to connect".to_string(); state.current_screen = Screen::Home; notify::play(Sound::SelfLeave, state.config.custom_sound_self_leave.as_deref()); } UiEvent::RoomReset => { state.reset_room_state(); state.ticket = "".to_string(); state.status_message = "Ready to connect".to_string(); state.current_screen = Screen::Home; } UiEvent::PeerJoined { id, state: peer_state } => { state.peers.insert(id, peer_state); 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()); } // Core-only recovery phase: presentation for this state lands in // the separate UI follow-up. In particular, do not play the // terminal ReconnectFailed chime here. UiEvent::PeerRecoveryStarted { .. } => {} 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 { from, name, text, attachment } => { // Incoming peer content is untrusted — sanitize name + text. // (The attachment filename was already sanitized in core.) let text = sanitize_chat(&text); // Keep the message if it has visible text OR an attachment (an // image with no caption is still a real message). if !text.is_empty() || attachment.is_some() { let name = sanitize_chat(&name); push_chat(&mut state.chat_messages, ChatEntry { name, text, mine: false, from: Some(from), attachment, }); } } UiEvent::AttachmentReady { from, id, data } => { // Bytes arrived for this specific (author, id). For images we // can cache the iced handle now (built once, not per redraw). // If the user was waiting to save this file, the save dialog // is opened from update() below by checking pending_saves — // done lazily so this arm stays simple. let key = (from, id); let handle = crate::files::validate_image_bytes(&data) .is_some() .then(|| iced::widget::image::Handle::from_bytes(data.clone())); let needs_save = state.pending_saves.remove(&key); let needs_play = state.pending_plays.remove(&id); state.attachments.insert(key, AttachmentState::Ready(data), handle); if needs_play { play_ready_audio(state, key); } if needs_save { return save_attachment_task(state, key); } } UiEvent::AttachmentFailed { from, id, error } => { let key = (from, id); state.pending_saves.remove(&key); state.pending_plays.remove(&id); state.attachments.insert(key, AttachmentState::Failed(error.clone()), None); state.status_message = format!("Attachment failed: {error}"); } UiEvent::AudioAppsListed { apps, app_audio_supported } => { // Only meaningful while the picker is open; if the user // already cancelled, drop it. if state.share_picker_open { state.share_app_audio_supported = app_audio_supported; if app_audio_supported { // Keep the current selection if it still exists in the // refreshed list, else fall back to "All system audio". if let Some(sel) = &state.share_audio_selection && !apps.iter().any(|a| a == sel) { state.share_audio_selection = None; } state.share_audio_apps = apps; } else { // Older pixelpass: per-app capture would hard-fail // (--strict-audio unknown). Force whole-desktop only. state.share_audio_apps.clear(); state.share_audio_selection = None; } } } UiEvent::ScreenShareStarted => { state.self_sharing = true; state.share_starting = false; state.share_audio_dropped = false; // Remember whether this share captures a specific app, so we // only apply `app_audio` warnings to app shares (P3). state.share_audio_app_active = state.share_audio_selection.is_some(); // Defensive: ensure no picker lingers across a successful start. state.share_picker_open = false; state.status_message = "Sharing your screen".to_string(); } UiEvent::ScreenShareStopped => { state.self_sharing = false; state.share_starting = false; state.share_audio_dropped = false; state.share_audio_app_active = false; state.status_message = "Screen share stopped".to_string(); } UiEvent::ShareAudioActive(active) => { // Per-app audio routed/lost. Apply only while we're actually // sharing a specific app: a late event from a just-killed host // must not flip the warning after stop or on a whole-desktop // share (audit P3, unscoped events). if state.self_sharing && state.share_audio_app_active { state.share_audio_dropped = !active; } } UiEvent::ClockSkewWarning { skew_secs, peer_ahead } => { show_clock_skew_warning( state, skew_secs, peer_ahead, std::time::Instant::now(), ); } UiEvent::IdentityStatus { node_id, persisted, error } => { state.self_node_id = Some(node_id); state.identity_persisted = persisted; state.identity_error = error; } UiEvent::FriendsUpdated { friends, read_only } => { // Core owns the store; mirror its snapshot. Drop presence for // anyone no longer a friend so the UI doesn't show a stale dot. let ids: HashSet = friends.iter().map(|f| f.id).collect(); state.friend_presence.retain(|id, _| ids.contains(id)); state.friends.friends = friends; state.friends_read_only = read_only; } UiEvent::FriendPresence { id, presence } => { state.friend_presence.insert(id, presence); } UiEvent::PresenceModeReverted { mode } => { // Core corrected the committed presence mode. Mirror + persist so // the picker reflects the discovery state the endpoint actually has. state.config.presence_mode = mode; state.config.save(); state.status_message = if mode == PresenceMode::Normal { "Discoverable timed out — back to Normal".to_string() } else { format!("Presence mode stayed {mode}") }; } UiEvent::GameChanged(detected) => { // The locally-detected game changed: switch the per-game // background (W18) if one is mapped, else fall back to the // default. Presence broadcasting is handled in core, gated by // the opt-in toggle; this is purely local presentation. state.current_game = detected; state.background_image = effective_background_bytes(&state.config, state.current_game.as_ref()); } UiEvent::ShutdownComplete => { if state.closing { return iced::exit(); } } UiEvent::Error(err) => { // A failed share start (spawn error) surfaces here, not via // ScreenShareStopped, so clear the in-flight flag to let the // user retry instead of being wedged. state.share_starting = false; state.status_message = format!("Error: {}", err); } } } AppMessage::CopyToClipboard => { if !state.ticket.is_empty() { return iced::clipboard::write(state.ticket.clone()); } } AppMessage::CopyText(s) => { return iced::clipboard::write(s); } AppMessage::Noop => {} AppMessage::TogglePtt(enabled) => { state.ptt_enabled = enabled; let _ = state.controller.send(CoreCommand::SetPttMode(enabled)); } AppMessage::StartHotkeyCapture(action) => { state.hotkey_capture = Some(action); state.hotkey_info_open = false; } AppMessage::ClearHotkey(action) => { state.config.hotkeys.set_binding(action, None); state.config.save(); } AppMessage::PeerVolumeChanged(id, vol) => { let vol = set_peer_volume_config(&mut state.config, id, vol); let _ = state.controller.send(CoreCommand::SetPeerVolume(id, vol)); } AppMessage::PeerPanChanged(id, pan) => { let pan = set_peer_pan_config(&mut state.config, id, pan); let _ = state.controller.send(CoreCommand::SetPeerPan(id, pan)); } AppMessage::PeerGateChanged(id, threshold) => { let threshold = set_peer_gate_config(&mut state.config, id, threshold); let _ = state.controller.send(CoreCommand::SetPeerGate(id, threshold)); } AppMessage::PeerEqChanged(id, band, gain_db) => { let settings = set_peer_eq_config(&mut state.config, id, band, gain_db); let _ = state.controller.send(CoreCommand::SetPeerEq(id, settings)); } 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::PresenceModeSelected(mode) => { state.config.presence_mode = mode; state.config.save(); // Push to core, which gates the live listener + ping scheduler (B2). let _ = state.controller.send(CoreCommand::SetPresenceMode(mode)); } AppMessage::FriendAddIdChanged(val) => { state.friend_add_id = val; state.friend_add_error = None; } AppMessage::FriendAddNameChanged(val) => { state.friend_add_name = val; state.friend_add_error = None; } AppMessage::AddFriend => { // Core owns the store; the GUI just validates the id locally, then sends // a command. The mirror (and the cleared form) update on FriendsUpdated. if state.friends_read_only { state.friend_add_error = Some("Friends list is read-only (couldn't load friends.json).".to_string()); } else { let id_str = state.friend_add_id.trim(); match id_str.parse::() { Ok(id) if state.friends.contains(&id) => { state.friend_add_error = Some("Already in your friends list.".to_string()); } Ok(id) => { let name = { let n = state.friend_add_name.trim(); if n.is_empty() { short_id(id_str) } else { n.to_string() } }; let _ = state.controller.send(CoreCommand::AddFriend { id, name, addr: None }); state.friend_add_id.clear(); state.friend_add_name.clear(); state.friend_add_error = None; } Err(_) => { state.friend_add_error = Some("That doesn't look like a valid node ID.".to_string()); } } } } AppMessage::AddFriendFromRoom(id) => { // Add a co-participant: pull their name + live address from the room // roster so the friend is reachable right away (no wait for a future // call to seed last_addr, unlike a bare add-by-id). No-op if read-only // or already a friend; idempotent in core regardless. if !state.friends_read_only && !state.friends.contains(&id) && let Some(peer) = state.peers.get(&id) { let name = { let n = crate::sanitize::sanitize_name(&peer.name); if n.is_empty() { short_id(&id.to_string()) } else { n } }; let _ = state.controller.send(CoreCommand::AddFriend { id, name, addr: Some(peer.addr.clone()), }); } } AppMessage::RemoveFriend(id) => { if !state.friends_read_only { let _ = state.controller.send(CoreCommand::RemoveFriend(id)); } } AppMessage::RenameFriend(id, new_name) => { if !state.friends_read_only { let _ = state.controller.send(CoreCommand::RenameFriend(id, new_name)); } } AppMessage::JoinFriendRoom(ticket) => { // Join via the friend's member ticket (same path as a manual join). 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 = "Joining your friend's room...".to_string(); // Clear any prior room's UI state at initiation (see JoinPressed). state.reset_room_state(); state.config.username = state.name.clone(); state.config.save(); state.mic_test_active = false; let _ = state.controller.send(CoreCommand::Join { name: state.name.clone(), ticket, room_name: String::new(), // joining: the label comes from the ticket input_device, output_device, echo_cancellation: state.config.echo_cancellation_enabled, avatar: state.config.avatar.clone(), }); } AppMessage::JoinRecent(ticket) => { // Rejoin a remembered room (same path as a manual ticket join). It's // best-effort: the door only admits us while the room is still live. 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 = "Rejoining a recent room...".to_string(); // Clear any prior room's UI state at initiation (see JoinPressed). state.reset_room_state(); state.config.username = state.name.clone(); state.config.save(); state.mic_test_active = false; let _ = state.controller.send(CoreCommand::Join { name: state.name.clone(), ticket, room_name: String::new(), // rejoining: the label comes from the ticket input_device, output_device, echo_cancellation: state.config.echo_cancellation_enabled, avatar: state.config.avatar.clone(), }); } AppMessage::RemoveRecent(ticket) => { crate::recents::remove_recent(&mut state.config.recents, &ticket); state.config.save(); } AppMessage::SelectSettingsCategory(category) => { state.settings_category = category; } 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::ToggleSoundEnabled(sound, enabled) => { state.config.set_sound_enabled(sound, enabled); state.config.save(); notify::set_sound_enabled(sound, enabled); } AppMessage::OpenRegenerateIdentityConfirm => { state.regenerate_identity_confirm_open = true; } AppMessage::CloseRegenerateIdentityConfirm => { state.regenerate_identity_confirm_open = false; } AppMessage::ConfirmRegenerateIdentity => { state.regenerate_identity_confirm_open = false; // The core mints + persists the new key and replies with a fresh // IdentityStatus (which updates self_node_id / persisted here). let _ = state.controller.send(CoreCommand::RegenerateIdentity); } 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; state.hotkey_info_open = false; } AppMessage::CloseLayoutPicker => { state.layout_picker_open = false; } AppMessage::OpenHotkeyInfo => { state.hotkey_info_open = true; state.layout_picker_open = false; } AppMessage::CloseHotkeyInfo => { state.hotkey_info_open = false; } AppMessage::OpenPixelpassHelp => { state.pixelpass_help_open = true; } AppMessage::ClosePixelpassHelp => { state.pixelpass_help_open = false; } AppMessage::DismissClockSkewWarning => { state.clock_skew_warning = None; } AppMessage::ClockSkewWarningTick => { clear_expired_clock_skew_warning(state, std::time::Instant::now()); } 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::SelectAvatar(avatar) => { state.config.avatar = avatar.clone(); state.config.save(); // Re-announce to the room if we're in a call (no-op otherwise). let _ = state.controller.send(CoreCommand::SetAvatar(avatar)); } AppMessage::PickAvatarFile => { // Open the native picker off the UI thread; the result comes back as // AvatarFilePicked. Filter to the formats we can actually decode. return Task::perform( async { let handle = rfd::AsyncFileDialog::new() .add_filter("Images", &["png", "jpg", "jpeg"]) .set_title("Choose an avatar image") .pick_file() .await; match handle { Some(h) => Some(h.read().await), None => None, } }, AppMessage::AvatarFilePicked, ); } AppMessage::AvatarFilePicked(picked) => { if let Some(bytes) = picked { match crate::avatar::process_upload(&bytes) { Ok(avatar) => { state.config.avatar = avatar.clone(); state.config.save(); state.status_message = "Avatar updated.".to_string(); let _ = state.controller.send(CoreCommand::SetAvatar(avatar)); } Err(e) => { state.status_message = e; } } } } AppMessage::PickBackgroundFile => { // Native picker off the UI thread; result returns as BackgroundFilePicked. return Task::perform( async { let handle = rfd::AsyncFileDialog::new() .add_filter("Images", &["png", "jpg", "jpeg", "webp", "bmp"]) .set_title("Choose a background image") .pick_file() .await; match handle { Some(h) => Some(h.read().await), None => None, } }, AppMessage::BackgroundFilePicked, ); } AppMessage::BackgroundFilePicked(picked) => { if let Some(bytes) = picked { match crate::background::process_background(&bytes) { Ok(png) => match AppConfig::background_path() { Some(path) => { if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); } match std::fs::write(&path, &png) { Ok(()) => { state.config.background = Some(path.to_string_lossy().into_owned()); state.config.save(); // Refresh the in-memory cache from the bytes we // just wrote (avoids re-reading from disk). state.background_image = Some(bytes::Bytes::from(png)); state.status_message = "Background updated.".to_string(); } Err(e) => { state.status_message = format!("Couldn't save background: {e}"); } } } None => { state.status_message = "Couldn't find a config directory to save the background." .to_string(); } }, Err(e) => { state.status_message = e; } } } } AppMessage::RemoveBackground => { // Best-effort delete of our stored copy; clear the config + cache. if let Some(path) = AppConfig::background_path() { let _ = std::fs::remove_file(path); } state.config.background = None; state.config.save(); state.background_image = None; state.status_message = "Background removed.".to_string(); } AppMessage::SetBackgroundDim(dim) => { state.config.background_dim = dim.clamp(0.0, 1.0); state.config.save(); } AppMessage::ToggleGamePresence(enabled) => { state.config.game_presence_enabled = enabled; state.config.save(); // Core publishes/clears our game immediately (D8). let _ = state.controller.send(CoreCommand::SetGamePresenceEnabled(enabled)); } AppMessage::GameOverrideSelected(choice) => { state.game_override = choice; let override_ = match choice { GameOverrideChoice::Auto => crate::game::ManualOverride::Auto, GameOverrideChoice::None => crate::game::ManualOverride::ForceNone, // "Pin current" carries the live detection; nothing detected = Auto. GameOverrideChoice::Current => match &state.current_game { Some(g) => crate::game::ManualOverride::Force(g.clone()), None => crate::game::ManualOverride::Auto, }, }; let _ = state.controller.send(CoreCommand::SetGameOverride(override_)); } AppMessage::GameMapExeChanged(val) => { state.game_map_exe_input = val; } AppMessage::GameMapNameChanged(val) => { state.game_map_name_input = val; } AppMessage::AddGameMapping => { let exe = crate::game::normalize_exe(&state.game_map_exe_input); let name = state.game_map_name_input.trim().to_string(); if !exe.is_empty() && !name.is_empty() { state.config.game_process_map.insert(exe, name); state.config.save(); state.game_map_exe_input.clear(); state.game_map_name_input.clear(); let _ = state .controller .send(CoreCommand::SetGameProcessMap(state.config.game_process_map.clone())); } } AppMessage::RemoveGameMapping(exe) => { if state.config.game_process_map.remove(&exe).is_some() { state.config.save(); let _ = state .controller .send(CoreCommand::SetGameProcessMap(state.config.game_process_map.clone())); } } AppMessage::PickGameBackground(game_id) => { // Native picker off the UI thread; result tagged with the game id. return Task::perform( async { let handle = rfd::AsyncFileDialog::new() .add_filter("Images", &["png", "jpg", "jpeg", "webp", "bmp"]) .set_title("Choose a per-game background") .pick_file() .await; match handle { Some(h) => Some(h.read().await), None => None, } }, move |bytes| AppMessage::GameBackgroundPicked(game_id, bytes), ); } AppMessage::GameBackgroundPicked(game_id, picked) => { if let Some(bytes) = picked { match crate::background::process_background(&bytes) { Ok(png) => match AppConfig::game_background_path(&game_id) { Some(path) => { if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); } match std::fs::write(&path, &png) { Ok(()) => { state .config .game_backgrounds .insert(game_id.clone(), path.to_string_lossy().into_owned()); state.config.save(); // Apply at once if it's the running game. if state.current_game.as_ref().map(|g| g.id.as_str()) == Some(game_id.as_str()) { state.background_image = effective_background_bytes( &state.config, state.current_game.as_ref(), ); } state.status_message = "Game background updated.".to_string(); } Err(e) => { state.status_message = format!("Couldn't save game background: {e}"); } } } None => { state.status_message = "Couldn't find a config directory to save the background." .to_string(); } }, Err(e) => { state.status_message = e; } } } } AppMessage::RemoveGameBackground(game_id) => { if let Some(path) = AppConfig::game_background_path(&game_id) { let _ = std::fs::remove_file(path); } if state.config.game_backgrounds.remove(&game_id).is_some() { state.config.save(); if state.current_game.as_ref().map(|g| g.id.as_str()) == Some(game_id.as_str()) { state.background_image = effective_background_bytes(&state.config, state.current_game.as_ref()); } state.status_message = "Game background removed.".to_string(); } } 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, from: Some(state.self_id.clone()), attachment: None, }); let _ = state.controller.send(CoreCommand::SendChat(text)); state.chat_input.clear(); } } AppMessage::PickAttachmentFile => { // Native picker off the UI thread; returns (filename, bytes). return Task::perform( async { let handle = rfd::AsyncFileDialog::new() .set_title("Attach a file to the chat") .pick_file() .await; match handle { Some(h) => Some((h.file_name(), h.read().await)), None => None, } }, AppMessage::AttachmentFilePicked, ); } AppMessage::AttachmentFilePicked(picked) => { if let Some((name, bytes)) = picked { let size = bytes.len() as u64; if !crate::files::size_within_cap(size) { state.status_message = format!( "File too large — max {}.", crate::files::human_size(crate::files::MAX_ATTACHMENT_BYTES) ); return Task::none(); } let kind = crate::files::classify(&bytes); // Random 32-byte handle for this attachment. let id: crate::files::AttachmentId = rand::random(); let att = crate::files::ChatAttachment { name: crate::files::sanitize_filename(&name), size, kind, id, }; // Keep our own bytes locally so we see our own attachment inline // immediately (others fetch it off the file plane). Cache under // our own (author, id) key so the render path — which keys the // line by `from` = self_id — finds them. if let Ok(self_eid) = state.self_id.parse::() { let key = (self_eid, id); let handle = (kind == crate::files::AttachmentKind::Image && crate::files::validate_image_bytes(&bytes).is_some()) .then(|| iced::widget::image::Handle::from_bytes(bytes.clone())); state .attachments .insert(key, AttachmentState::Ready(bytes.clone()), handle); } push_chat( &mut state.chat_messages, ChatEntry { name: format!("{} (You)", state.name), text: String::new(), mine: true, from: Some(state.self_id.clone()), attachment: Some(att.clone()), }, ); let _ = state.controller.send(CoreCommand::SendChatFile { text: String::new(), attachment: att, data: bytes, }); } } AppMessage::SaveAttachment(key) => { // If we already have the bytes, save now; otherwise fetch from the // sender and save when AttachmentReady arrives (pending_saves). The // key's author half is the exact sender of the clicked line. if state.attachments.is_ready(&key) { return save_attachment_task(state, key); } else if let Some(att) = find_attachment_source(state, key) { state.pending_saves.insert(key); state.status_message = format!("Downloading {}…", att.name); let _ = state .controller .send(CoreCommand::FetchAttachment { from: key.0, attachment: att }); } } AppMessage::AttachmentSaved(msg) => { if let Some(m) = msg { state.status_message = m; } } AppMessage::PlayAudio(key) => { if state.attachments.is_ready(&key) { play_ready_audio(state, key); } else if let Some(att) = find_attachment_source(state, key) { // Repeated clicks while the transfer is pending must not launch // duplicate fetches. (pending_plays is id-keyed — it's coupled to // the id-keyed clip player; same-id collisions are cosmetic.) if state.pending_plays.insert(key.1) { state.status_message = format!("Loading {}…", att.name); let _ = state .controller .send(CoreCommand::FetchAttachment { from: key.0, attachment: att }); } } } AppMessage::PauseAudio => state.clip_player.pause(), AppMessage::ResumeAudio => state.clip_player.resume(), AppMessage::SeekAudio(id, fraction) => { let clip = status_snapshot(&state.clip_status); if clip.playing_id == Some(id) && let Some(total) = clip.total { state.clip_player.seek(seek_target(fraction, total)); } } AppMessage::SetClipVolume(volume) => { // Master slider: always stores the universal level, but only the // active player is nudged when universal mode is actually on. let volume = volume.clamp(0.0, 2.0); state.config.clip_volume = volume; state.config.save(); if state.config.clip_volume_universal { state.clip_player.set_volume(volume); } } AppMessage::SetClipVolumeFor(id, volume) => { let volume = volume.clamp(0.0, 2.0); if state.config.clip_volume_universal { state.config.clip_volume = volume; state.config.save(); state.clip_player.set_volume(volume); } else { state.clip_volumes.insert(id, volume); // Only the clip the user is dragging should react immediately. if status_snapshot(&state.clip_status).playing_id == Some(id) { state.clip_player.set_volume(volume); } } } AppMessage::ToggleUniversalClipVolume(on) => { state.config.clip_volume_universal = on; state.config.save(); // Reapply the now-effective level to whatever is currently playing. if let Some(id) = status_snapshot(&state.clip_status).playing_id { state.clip_player.set_volume(effective_clip_volume(state, id)); } } AppMessage::AudioTick => { let clip = status_snapshot(&state.clip_status); if let Some(failure) = clip.failure { if failure.invalid_audio { state.invalid_audio.insert(failure.id); } state.pending_plays.remove(&failure.id); state.status_message = format!("Audio playback failed: {}", failure.error); state.clip_player.stop(); } } AppMessage::OpenUrl(url) => { // Defence in depth: only ever hand http(s) URLs to the opener. The // link span's href came from `linkify`, which only emits http/https, // but re-check here so this can't be widened into launching arbitrary // schemes/args. Each opener receives the URL as a single argv entry // (no shell), so there's no injection surface: // - Unix: `xdg-open `. // - Windows: `rundll32 url.dll,FileProtocolHandler ` — opens the // default browser without going through `cmd`/`start`, which would // otherwise re-parse `&` in query strings. if url.starts_with("http://") || url.starts_with("https://") { let spawned = { #[cfg(unix)] { std::process::Command::new("xdg-open").arg(&url).spawn() } #[cfg(windows)] { std::process::Command::new("rundll32") .args(["url.dll,FileProtocolHandler", &url]) .spawn() } }; if let Err(e) = spawned { crate::log_msg(&format!("Failed to open URL {url:?}: {e}")); } } } 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 let Some(action) = state.hotkey_capture.take() { if let Some(binding) = KeyBinding::from_key(&key) { state.config.hotkeys.set_binding(action, Some(binding)); state.config.save(); } else { state.hotkey_capture = Some(action); } } else if let Some(action) = state .config .hotkeys .lookup_key(&key, HotkeyContext { in_call: in_call(state) }) { handle_hotkey_pressed(state, action); } } AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyReleased { key, .. })) => { if state .config .hotkeys .lookup_key(&key, HotkeyContext { in_call: in_call(state) }) == Some(HotkeyAction::PushToTalk) && state.ptt_enabled && 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)) => { if state.closing { return Task::none(); } // We took over the close path (exit_on_close_request:false) so we can // persist the final window size + position and give core a chance to // leave the room/finalize recordings before quitting. state.config.save(); state.closing = true; state.status_message = "Shutting down...".to_string(); let tx = state.controller.command_sender(); return Task::batch(vec![ Task::perform( async move { tx.send(CoreCommand::Shutdown).await.is_ok() }, AppMessage::ShutdownCommandSent, ), shutdown_timeout_task(), ]); } AppMessage::EventOccurred(_) => {} AppMessage::ShutdownCommandSent(sent) => { if !sent { return iced::exit(); } } AppMessage::ShutdownTimeout => { if state.closing { return iced::exit(); } } AppMessage::NavigateToSettings => { state.current_screen = Screen::Settings; state.layout_picker_open = false; state.hotkey_info_open = false; } AppMessage::NavigateBack => { state.config.save(); state.hotkey_capture = None; // 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.", } } /// One-line explanation of a presence posture for the settings picker (W7). fn presence_mode_hint(mode: PresenceMode) -> &'static str { match mode { PresenceMode::Invisible => { "Answer no one — appear offline to everyone, even friends." } PresenceMode::Normal => { "Answer friends only, from your saved address. No presence beacon." } PresenceMode::Discoverable => { "Also publish so friends can still find you after you change networks (opt-in)." } } } /// 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}") } } fn format_clock_skew_duration(skew_secs: u64) -> String { let minutes = skew_secs.max(1).saturating_add(59) / 60; if minutes == 1 { "1 minute".to_string() } else { format!("{minutes} minutes") } } fn show_clock_skew_warning( state: &mut AppState, skew_secs: u64, peer_ahead: bool, now: std::time::Instant, ) { state.clock_skew_warning = Some(ClockSkewBanner { skew_secs, peer_ahead, expires_at: now + std::time::Duration::from_secs(CLOCK_SKEW_WARNING_VISIBLE_SECS), }); } fn clear_expired_clock_skew_warning(state: &mut AppState, now: std::time::Instant) { if state .clock_skew_warning .is_some_and(|warning| now >= warning.expires_at) { state.clock_skew_warning = None; } } /// 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::>().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, entry: ChatEntry) { messages.push(entry); if messages.len() > CHAT_HISTORY_MAX { let overflow = messages.len() - CHAT_HISTORY_MAX; messages.drain(..overflow); } } /// Pick the default save-dialog filename for an attachment, matched by the FULL /// `(author, id)` key — not the bare id — so a peer reusing another sender's id /// can't supply the filename (and extension) for a different line (Tier C F-12 /// metadata residual). Falls back to "download" if the line is gone. Matches own /// and received lines alike (our own `from` = `self_id` parses to the key author). fn attachment_default_name(messages: &[ChatEntry], key: AttachmentKey) -> String { messages .iter() .find_map(|m| { let att = m.attachment.as_ref()?; let from = m.from.as_ref()?.parse::().ok()?; (att.id == key.1 && from == key.0).then(|| att.name.clone()) }) .unwrap_or_else(|| "download".to_string()) } /// Find the chat-attachment descriptor for an exact `(author, id)` key among /// received (non-own) messages. Matching on the author too — not just the id — /// means a peer reusing another sender's id can't redirect the fetch to the /// wrong line (Tier C F-12). fn find_attachment_source( state: &AppState, key: AttachmentKey, ) -> Option { state.chat_messages.iter().find_map(|m| { let att = m.attachment.as_ref()?; let from = m.from.as_ref()?; if att.id == key.1 && !m.mine && from.parse::().ok() == Some(key.0) { Some(att.clone()) } else { None } }) } /// Validate cached bytes and hand them to the independent clip player. A false /// filename hint falls back to the generic file chip without reaching rodio. /// Resolve the gain to use for clip `id`: the shared universal level, or the /// clip's own stored level (defaulting to unity) when universal mode is off. fn effective_clip_volume(state: &AppState, id: crate::files::AttachmentId) -> f32 { if state.config.clip_volume_universal { state.config.clip_volume } else { state.clip_volumes.get(&id).copied().unwrap_or(1.0) } } fn play_ready_audio(state: &mut AppState, key: AttachmentKey) { let Some(AttachmentState::Ready(data)) = state.attachments.get(&key) else { return; }; let id = key.1; if crate::files::is_probably_audio(data) { let bytes = data.clone(); state.invalid_audio.remove(&id); state.clip_player.play(id, bytes); // Apply this clip's effective gain; the command lands after Play so it // takes effect on the freshly connected player. state.clip_player.set_volume(effective_clip_volume(state, id)); } else { state.invalid_audio.insert(id); state.status_message = "This attachment is not valid supported audio.".to_string(); } } /// Write a ready attachment's bytes to a user-chosen location via a native save /// dialog. The default filename comes from the (already-sanitized) descriptor. /// Build a Task that opens the native save dialog off the UI thread and writes /// the (already-fetched) attachment bytes to the chosen path. No-op task if the /// bytes aren't ready. /// /// MUST be async (`rfd::AsyncFileDialog`): the blocking `rfd::FileDialog` spins /// its own GTK loop, and invoking it from inside iced's running event loop with /// the Linux `xdg-desktop-portal`/GTK backend wedges the dialog — Save/Cancel /// stop responding. The file *picker* paths already use the async variant; this /// is the one save path that must match. fn save_attachment_task(state: &AppState, key: AttachmentKey) -> Task { let Some(AttachmentState::Ready(data)) = state.attachments.get(&key) else { return Task::none(); }; let data = data.clone(); let default_name = attachment_default_name(&state.chat_messages, key); Task::perform( async move { let handle = rfd::AsyncFileDialog::new() .set_file_name(default_name) .set_title("Save attachment") .save_file() .await; match handle { Some(h) => match std::fs::write(h.path(), &data) { Ok(()) => Some(format!("Saved {}", h.path().display())), Err(e) => Some(format!("Save failed: {e}")), }, None => None, } }, AppMessage::AttachmentSaved, ) } 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) } /// The Friends panel (W7) — moved from Settings to the home screen. One card with /// the live friends list (presence status + one-click Join + inline rename/remove), /// the add-by-node-ID form, and the presence posture selector. Self-contained: /// recomputes the palette + the few style helpers it needs so it doesn't depend on /// `view`'s locals. // The home-screen "Connect" card (left panel): create / join a room. Extracted // into its own free fn (mirroring `friends_panel`) so the responsive home layout // can rebuild it per layout pass and place it in either a row or a column. fn connect_card(state: &AppState) -> Element<'_, AppMessage> { let pal = state.config.theme.palette(); let color_crust = pal.crust; let color_mantle = pal.mantle; let color_surface = pal.surface; let color_text = pal.text; let color_subtext = pal.subtext; let color_blue = pal.blue; let color_lavender = pal.lavender; 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: pal.overlay, value: color_text, selection: color_blue, }; let logo = text("PEERSPEAK").size(38).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), context_input("Enter nickname...", &state.name) .on_input(AppMessage::NicknameChanged) .style(t_style) .padding(10) ]; // Optional cosmetic room label (W7) above the Create button: it rides in the // minted ticket so everyone who joins inherits "in ". Enter also creates. let create_group = column![ context_input("Room name (optional)", &state.room_name_input) .on_input(AppMessage::RoomNameChanged) .on_submit(AppMessage::CreatePressed) .style(t_style) .padding(10), vertical_space(8.0), 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), context_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); container( column![ logo, subtitle, vertical_space(20.0), nickname_input, vertical_space(16.0), create_group, 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(32) .width(420) .into() } /// The "Recent rooms" card (W7 P5): a one-click rejoin list, in its own card so a /// growing history never reflows the Connect card's Create/Join controls. Always /// shown (with an empty-state hint when there's no history yet), mirroring the /// Friends card so the feature is discoverable on a fresh install. fn recents_card(state: &AppState) -> Element<'_, AppMessage> { let pal = state.config.theme.palette(); let color_crust = pal.crust; let color_mantle = pal.mantle; let color_surface = pal.surface; let color_text = pal.text; let color_subtext = pal.subtext; let color_maroon = pal.maroon; 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 empty = state.config.recents.is_empty(); let content: Element<'_, AppMessage> = if empty { column![ text("RECENT ROOMS").size(14).color(color_subtext), text("No recent rooms yet.").size(12).color(color_subtext), ] .spacing(4) .into() } else { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); let mut rows = column![].spacing(6).width(iced::Length::Fill); for r in &state.config.recents { let label = { let n = crate::sanitize::sanitize_name(&r.name); if n.is_empty() { "Untitled room".to_string() } else { n } }; let when = crate::recents::relative_time(now, r.joined_at); let entry = button( row![ text(label).size(14).color(color_text), horizontal_space(), text(when).size(11).color(color_subtext), ] .align_y(iced::alignment::Vertical::Center), ) .on_press(AppMessage::JoinRecent(r.ticket.clone())) .style(b_style(color_crust, color_surface, color_text, 6.0)) .padding(8) .width(iced::Length::Fill); rows = rows.push( row![ entry, button(text("✕").size(12)) .on_press(AppMessage::RemoveRecent(r.ticket.clone())) .style(b_style(color_surface, color_maroon, color_text, 6.0)) .padding(8), ] .spacing(6) .align_y(iced::alignment::Vertical::Center), ); } column![ text("RECENT ROOMS").size(18).color(color_text), text("Rooms you've been in — click to hop back. Best-effort: only works while someone's still there.") .size(11) .color(color_subtext), vertical_space(10.0), rows, ] .spacing(6) .into() }; container(content) .style(c_style(if empty { color_crust } else { color_mantle }, color_surface, 8.0)) .padding(if empty { 16 } else { 24 }) .width(if empty { 340 } else { 380 }) .into() } fn friends_panel(state: &AppState) -> Element<'_, AppMessage> { let pal = state.config.theme.palette(); let color_crust = pal.crust; let color_mantle = pal.mantle; 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_red = pal.red; let color_maroon = pal.maroon; let color_green = pal.green; let has_friends = !state.friends.list().is_empty(); 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, }; // The live friends list: status dot, inline rename, short id, remove. let mut friend_rows = column![].spacing(6).width(iced::Length::Fill); if !has_friends { friend_rows = friend_rows.push( text("No friends yet.") .size(12) .color(color_subtext), ); } else { for f in state.friends.list() { let fid = f.id; let id_short = format!("{}…", short_id(&f.id.to_string())); // Live presence (W7 B2); a missing entry = offline. In-room → Join. let status: Element = match state.friend_presence.get(&fid) { Some(crate::presence::FriendPresence::InRoom { name, ticket }) => { let label = if name.is_empty() { "in a room".to_string() } else { format!("in {name}") }; row![ text(label).size(11).color(color_green), button(text("Join").size(12)) .on_press(AppMessage::JoinFriendRoom(ticket.clone())) .style(b_style(color_surface, color_blue, color_text, 6.0)) .padding(6), ] .spacing(6) .align_y(iced::alignment::Vertical::Center) .into() } Some(crate::presence::FriendPresence::Online) => { text("● online").size(11).color(color_green).into() } None => text("○ offline").size(11).color(color_subtext).into(), }; friend_rows = friend_rows.push( row![ context_input("name", &f.name) .on_input(move |v| AppMessage::RenameFriend(fid, v)) .style(t_style) .padding(6) .width(iced::Length::Fixed(140.0)), status, horizontal_space(), text(id_short).size(11).color(color_subtext), button(text("✕").size(12)) .on_press(AppMessage::RemoveFriend(fid)) .style(b_style(color_surface, color_maroon, color_text, 6.0)) .padding(6), ] .spacing(8) .align_y(iced::alignment::Vertical::Center), ); } } let friend_add_error: Element = match &state.friend_add_error { Some(e) => text(e).size(11).color(color_red).into(), None => column![].into(), }; // A16: read-only when core couldn't load friends.json (never overwrite it). let readonly_warning: Element = if state.friends_read_only { text("⚠ friends.json couldn't load — read-only so it isn't overwritten. Fix or remove it, then restart.") .size(11) .color(color_red) .into() } else { column![].into() }; let add_form = column![ context_input("Friend's node ID", &state.friend_add_id) .on_input(AppMessage::FriendAddIdChanged) .style(t_style) .padding(6), vertical_space(6.0), row![ context_input("Name (optional)", &state.friend_add_name) .on_input(AppMessage::FriendAddNameChanged) .style(t_style) .padding(6) .width(iced::Length::Fill), button(text("Add").size(13)) .on_press(AppMessage::AddFriend) .style(b_style(color_surface, color_blue, color_text, 6.0)) .padding(8), ] .spacing(8) .align_y(iced::alignment::Vertical::Center), friend_add_error, ] .spacing(0) .width(iced::Length::Fill); // Presence posture: a compact dropdown (mirrors the Settings NetworkMode // picker) with a one-line explainer for the current choice below it. let presence_picker = column![ pick_list( &PresenceMode::ALL[..], Some(state.config.presence_mode), AppMessage::PresenceModeSelected, ) .width(iced::Length::Fill), text(presence_mode_hint(state.config.presence_mode)) .size(11) .color(color_subtext), ] .spacing(4) .width(iced::Length::Fill); let intro: Element<'_, AppMessage> = if has_friends { text("Who's online — click Join to hop into a friend's room.") .size(11) .color(color_subtext) .into() } else { column![].into() }; container( column![ text("FRIENDS").size(if has_friends { 18 } else { 14 }).color(color_text), intro, vertical_space(if has_friends { 10.0 } else { 4.0 }), readonly_warning, friend_rows, vertical_space(if has_friends { 12.0 } else { 8.0 }), text("Add a friend").size(13).color(color_subtext), add_form, vertical_space(if has_friends { 14.0 } else { 10.0 }), text("Your presence").size(13).color(color_subtext), presence_picker, ] .spacing(6), ) .style(c_style(color_mantle, color_surface, 12.0)) .padding(if has_friends { 24 } else { 18 }) .width(if has_friends { 460 } else { 360 }) .into() } /// Wrap the main [`view`] with the custom background layer (W16). When a /// background image is set, render `stack![ image(Cover), scrim, ui ]` so the /// photo sits behind the whole UI with a legibility scrim (the theme base colour /// at `background_dim` alpha) between them; otherwise return the UI untouched. The /// three screen roots go transparent (`root_bg` in `view`) so the image shows /// through the gaps between panels. This is the registered top-level view. fn view_with_background(state: &AppState) -> Element<'_, AppMessage> { let content = view(state); let Some(bytes) = state.background_image.clone() else { return content; }; let pal = state.config.theme.palette(); let dim = state.config.background_dim; let image_layer = iced::widget::image(cached_image_handle(bytes)) .content_fit(iced::ContentFit::Cover) .width(iced::Length::Fill) .height(iced::Length::Fill); let scrim = container( iced::widget::Space::new() .width(iced::Length::Fill) .height(iced::Length::Fill), ) .width(iced::Length::Fill) .height(iced::Length::Fill) .style(move |_: &Theme| container::Style { background: Some(Background::Color(crate::background::scrim_color(pal.base, dim))), ..Default::default() }); iced::widget::stack![image_layer, scrim, content] .width(iced::Length::Fill) .height(iced::Length::Fill) .into() } 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; // The window backdrop fill for the three screen roots. When a custom // background image is set (W16), the root goes transparent so the image + // scrim layered behind by `view_with_background` shows through the gaps // between panels; otherwise it's the usual opaque `crust`. let root_bg = if state.background_image.is_some() { Color::TRANSPARENT } else { color_crust }; // 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, } }; // The Hotkeys info button is always available (hotkeys are app-wide). The // room-layout button is hidden on the Home screen, leaving only it + Settings. let info_button = tooltip( button(icon(IconKind::Info, 18.0, color_text)) .on_press(AppMessage::OpenHotkeyInfo) .style(b_style(color_surface, color_blue, color_text, 6.0)) .padding(8), container(text("Hotkeys").size(11).color(color_text)) .padding(8) .style(c_style(color_crust, color_surface, 6.0)), iced::widget::tooltip::Position::Bottom, ) .gap(8); let layout_button: Element<'_, AppMessage> = if state.current_screen == Screen::Home { iced::widget::Space::new().width(0.0).height(0.0).into() } else { 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) .into() }; let top_bar = row![ horizontal_space(), info_button, layout_button, 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), }; // Per-sound enable checkbox (W6). Interactive only while the master // toggle is on — without it every chime is muted anyway, so we drop // `on_toggle` to render it greyed out and inert. let mut enable = checkbox(state.config.sound_enabled(sound)).size(16); if state.config.notifications_enabled { enable = enable.on_toggle(move |on| AppMessage::ToggleSoundEnabled(sound, on)); } column![ row![ enable, text(label).size(12).color(color_subtext), horizontal_space(), validation_widget, ].spacing(6).align_y(iced::alignment::Vertical::Center), context_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> = AppTheme::ALL[0..5].iter().map(|&t| theme_choice(t)).collect(); let theme_row2: Vec> = 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); let remove_background: Element<'_, AppMessage> = if state.config.background.is_some() { button(text("Remove background").size(13)) .on_press(AppMessage::RemoveBackground) .style(b_style(color_surface, color_blue, color_text, 6.0)) .padding(8) .into() } else { iced::widget::Space::new().width(0.0).height(0.0).into() }; let background_section = column![ row![ button(text("Choose image…").size(13)) .on_press(AppMessage::PickBackgroundFile) .style(b_style(color_surface, color_blue, color_text, 6.0)) .padding(8), remove_background, ].spacing(8), text(format!("Background dimming: {:.0}%", state.config.background_dim * 100.0)) .size(11) .color(color_subtext), slider(0.0..=1.0, state.config.background_dim, AppMessage::SetBackgroundDim) .step(0.05), text("Set a picture from your computer as the app background. Auto-resized; a dimming overlay keeps text readable. Applies live.") .size(11) .color(color_subtext), ] .spacing(10) .width(iced::Length::Fill); // Inline avatar chooser (W4): the monogram fallback plus the bundled // presets, each a clickable tile. Same SelectAvatar message, applied live // + persisted (and re-announced to the room). let avatar_choice = |a: crate::avatar::Avatar, label: String| -> Element<'_, AppMessage> { let selected = state.config.avatar == a; let preview = avatar_view(&a, &state.name, &state.self_id, 52.0); column![ button( container(preview) .center_x(iced::Length::Fixed(60.0)) .center_y(iced::Length::Fixed(60.0)) ) .on_press(AppMessage::SelectAvatar(a)) .padding(2) .style(b_style( if selected { color_surface } else { 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() }; let mut avatar_tiles: Vec> = Vec::new(); // Show the current custom avatar (if any) as the first, selected tile. if matches!(state.config.avatar, crate::avatar::Avatar::Custom(_)) { avatar_tiles.push(avatar_choice(state.config.avatar.clone(), "Custom".to_string())); } avatar_tiles.push(avatar_choice(crate::avatar::Avatar::Monogram, "Monogram".to_string())); for i in 0..crate::avatar::PRESET_COUNT { avatar_tiles.push(avatar_choice( crate::avatar::Avatar::Preset(i), format!("Preset {}", i + 1), )); } // Wrap into rows of four (no flex-wrap in iced 0.14) so the tiles don't // run off a narrow Settings panel. let mut tile_rows: Vec> = Vec::new(); let mut tiles_iter = avatar_tiles.into_iter(); loop { let chunk: Vec> = tiles_iter.by_ref().take(4).collect(); if chunk.is_empty() { break; } tile_rows.push(iced::widget::Row::with_children(chunk).spacing(12).into()); } let upload_btn = button(text("Upload image…").size(13)) .on_press(AppMessage::PickAvatarFile) .style(b_style(color_surface, color_blue, color_text, 6.0)) .padding(8); let avatar_section = column![ iced::widget::Column::with_children(tile_rows).spacing(12), upload_btn, text("Shown next to your name in the room and chat. Custom images are PNG/JPEG, auto-resized. 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 mut hotkey_rows = Column::new().spacing(8).width(iced::Length::Fill); for action in HotkeyAction::ALL { let capturing = state.hotkey_capture == Some(action); let binding = if capturing { "Press a key...".to_string() } else { format_binding(state.config.hotkeys.binding(action)) }; hotkey_rows = hotkey_rows.push( row![ column![ text(action.label()).size(13).color(color_text), text(match action.tier() { crate::hotkeys::HotkeyTier::AppWide => "App-wide", crate::hotkeys::HotkeyTier::RoomOnly => "Room-only", }) .size(10) .color(color_subtext), ] .spacing(2) .width(iced::Length::Fill), container(text(binding).size(12).color(if capturing { color_yellow } else { color_subtext })) .width(iced::Length::Fixed(110.0)) .align_x(iced::alignment::Horizontal::Right), button(text("Set").size(12)) .on_press(AppMessage::StartHotkeyCapture(action)) .style(b_style(color_surface, color_blue, color_text, 6.0)) .padding(6), button(text("Clear").size(12)) .on_press(AppMessage::ClearHotkey(action)) .style(b_style(color_surface, color_red, color_text, 6.0)) .padding(6), ] .spacing(10) .align_y(iced::alignment::Vertical::Center), ); } let hotkey_conflicts = state.config.hotkeys.conflicts(); let conflict_block: Element<'_, AppMessage> = if hotkey_conflicts.is_empty() { vertical_space(0.0).into() } else { let mut lines = Column::new().spacing(4); for conflict in hotkey_conflicts { lines = lines.push( text(format!( "Conflict: {} is assigned to {} and {}.", conflict.binding.label(), conflict.first.label(), conflict.second.label() )) .size(11) .color(color_red), ); } lines.into() }; let hotkey_section = column![ hotkey_rows, conflict_block, text("Shortcuts work only while the PeerSpeak window has focus. Unset actions are ignored.") .size(11) .color(color_subtext), ] .spacing(8) .width(iced::Length::Fill); // --- Identity (W7) --- // Your persistent node id + a Regenerate control. When the key isn't // persisted (disk/permission failure → ephemeral fallback) we show a // standing red warning, because the id won't survive the next launch and // friends will stop recognising you. let id_display = state .self_node_id .as_deref() .map(|id| format!("{}…", short_id(id))) .unwrap_or_else(|| "(starting…)".to_string()); let identity_warning: Element = if state.identity_persisted { column![].into() } else { let reason = state .identity_error .as_deref() .unwrap_or("the key file could not be read or written"); container( column![ text("⚠ Identity not saved") .size(13) .color(color_red), text(format!( "Your identity couldn't be saved to disk ({reason}). It won't \ survive the next launch, so your friends will stop recognising \ you. Check free space and permissions on ~/.config/peerspeak/." )) .size(12) .color(color_subtext), ] .spacing(4), ) .padding(10) .width(iced::Length::Fill) .style(move |_t: &Theme| container::Style { background: Some(Background::Color(Color { a: 0.12, ..color_red })), border: Border { color: color_red, width: 1.0, radius: 8.0.into() }, ..Default::default() }) .into() }; // The ID line exposes the full value in a locked selectable field while // keeping the one-click Copy button for fast whole-ID copy. let id_row: Element = match state.self_node_id.clone() { Some(full) => row![ text("ID:").size(13).color(color_text), locked_value(&full, AppMessage::Noop) .width(iced::Length::Fixed(260.0)) .size(13) .padding(4), button( row![ icon(IconKind::Copy, 13.0, color_text), text("Copy").size(12), ] .spacing(5) .align_y(iced::alignment::Vertical::Center) ) .on_press(AppMessage::CopyText(full.clone())) .style(b_style(color_surface, color_blue, color_text, 6.0)) .padding(6), ] .spacing(10) .align_y(iced::alignment::Vertical::Center) .into(), None => text(format!("ID: {id_display}")).size(13).color(color_text).into(), }; let identity_section = column![ text("Your permanent ID — friends recognise you by this. It stays the \ same across launches; regenerate only to start fresh as a new \ identity (friends who saved the old one will no longer reach you).") .size(12) .color(color_subtext), id_row, button(text("Regenerate identity").size(13)) .on_press(AppMessage::OpenRegenerateIdentityConfirm) .style(b_style(color_surface, color_maroon, color_text, 6.0)) .padding(8), identity_warning, ].spacing(8).width(iced::Length::Fill); // Presence + Friends moved to the home screen (see `friends_panel`). let settings_body: Element<'_, AppMessage> = match state.settings_category { SettingsCategory::Audio => column![ 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), 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), { let control: Element<'_, AppMessage> = { #[cfg(target_os = "linux")] { column![ 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).into() } #[cfg(not(target_os = "linux"))] { column![ checkbox(false) .label("Echo cancellation"), text("Echo cancellation is not available on Windows yet.").size(11).color(color_subtext), ].spacing(8).into() } }; control }, ].spacing(8).width(iced::Length::Fill), ] .spacing(10) .width(iced::Length::Fill) .into(), SettingsCategory::Hotkeys => column![ section_header("Hotkeys"), hotkey_section, ] .spacing(10) .width(iced::Length::Fill) .into(), SettingsCategory::Recording => column![ 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), ] .spacing(10) .width(iced::Length::Fill) .into(), SettingsCategory::Profile => column![ section_header("Avatar"), avatar_section, vertical_space(section_gap), section_header("Identity"), identity_section, ] .spacing(10) .width(iced::Length::Fill) .into(), SettingsCategory::Appearance => column![ 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), section_header("Theme"), theme_section, vertical_space(section_gap), section_header("Background"), background_section, ] .spacing(10) .width(iced::Length::Fill) .into(), SettingsCategory::Network => column![ 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), ] .spacing(10) .width(iced::Length::Fill) .into(), SettingsCategory::Notifications => column![ section_header("Notifications & Sounds"), column![ checkbox(state.config.notifications_enabled) .label("Enable sound notifications") .on_toggle(AppMessage::ToggleNotifications), vertical_space(6.0), text("Tick a sound to enable its chime; untick to silence just that one. Optional WAV path overrides the built-in sound (blank = built-in).").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) .into(), SettingsCategory::Games => { // What's running right now (local detection), for context. let detected_label = match &state.current_game { Some(g) => match &g.name { Some(name) => format!("Currently detected: {name}"), None => "Currently detected: (a game, no name available)".to_string(), }, None => "Currently detected: nothing".to_string(), }; // Manual override picker: Auto / None, plus "Pin current" when a // game is detected. let mut override_opts = vec![GameOverrideChoice::Auto, GameOverrideChoice::None]; if state.current_game.is_some() { override_opts.push(GameOverrideChoice::Current); } // Per-game background row for the running game (if any). let game_bg_section: Element<'_, AppMessage> = match &state.current_game { Some(g) => { let has_bg = state.config.game_backgrounds.contains_key(&g.id); let id_for_pick = g.id.clone(); let mut controls = row![ button(text("Set background for this game").size(13)) .on_press(AppMessage::PickGameBackground(id_for_pick)), ] .spacing(8); if has_bg { let id_for_remove = g.id.clone(); controls = controls.push( button(text("Remove").size(13)) .on_press(AppMessage::RemoveGameBackground(id_for_remove)), ); } controls.into() } None => text("Start a game to set its background.") .size(12) .color(color_subtext) .into(), }; // The list of configured per-game backgrounds (by stable id). let mut bg_list = column![].spacing(4).width(iced::Length::Fill); for id in state.config.game_backgrounds.keys() { let id_owned = id.clone(); bg_list = bg_list.push( row![ text(id.clone()).size(12).width(iced::Length::Fill), button(text("Remove").size(12)) .on_press(AppMessage::RemoveGameBackground(id_owned)), ] .spacing(8) .width(iced::Length::Fill), ); } // The process→name mappings (non-Steam games), with an add form. let mut map_list = column![].spacing(4).width(iced::Length::Fill); for (exe, name) in &state.config.game_process_map { let exe_owned = exe.clone(); map_list = map_list.push( row![ text(format!("{exe} → {name}")).size(12).width(iced::Length::Fill), button(text("Remove").size(12)) .on_press(AppMessage::RemoveGameMapping(exe_owned)), ] .spacing(8) .width(iced::Length::Fill), ); } column![ section_header("Game presence"), column![ checkbox(state.config.game_presence_enabled) .label("Show the game I'm playing to people in the call") .on_toggle(AppMessage::ToggleGamePresence), text("Off by default. When on, your detected game appears next to your avatar for everyone in the room.") .size(11).color(color_subtext), vertical_space(6.0), text(detected_label).size(12).color(color_subtext), row![ text("Override:").size(13), pick_list( override_opts, Some(state.game_override), AppMessage::GameOverrideSelected, ), ].spacing(8), ].spacing(6).width(iced::Length::Fill), vertical_space(section_gap), section_header("Per-game background"), column![ text("Give a game its own UI background; it switches automatically while you play. Falls back to your custom background (Appearance) otherwise.") .size(11).color(color_subtext), game_bg_section, bg_list, ].spacing(8).width(iced::Length::Fill), vertical_space(section_gap), section_header("Non-Steam games"), column![ text("Steam games are detected automatically. For other launchers, map an executable name to a display name.") .size(11).color(color_subtext), row![ context_input("executable (e.g. hl2_linux)", &state.game_map_exe_input) .on_input(AppMessage::GameMapExeChanged) .width(iced::Length::Fill), context_input("shown name (e.g. Half-Life 2)", &state.game_map_name_input) .on_input(AppMessage::GameMapNameChanged) .width(iced::Length::Fill), button(text("Add").size(13)).on_press(AppMessage::AddGameMapping), ].spacing(8).width(iced::Length::Fill), map_list, ].spacing(8).width(iced::Length::Fill), ] .spacing(10) .width(iced::Length::Fill) .into() } }; let category_button = |category: SettingsCategory| -> Element<'_, AppMessage> { let selected = state.settings_category == category; let label_color = if selected { color_blue } else { color_text }; let border_color = if selected { color_blue } else { Color::TRANSPARENT }; let bg = if selected { color_surface } else { Color::TRANSPARENT }; button( container( column![ text(category.label()).size(14).color(label_color), text(category.hint()).size(11).color(color_subtext), ] .spacing(2) .width(iced::Length::Fill), ) .width(iced::Length::Fill), ) .on_press(AppMessage::SelectSettingsCategory(category)) .style(move |_theme: &Theme, status: button::Status| { let active_bg = match status { button::Status::Hovered if selected => color_surface, button::Status::Hovered => color_crust, _ => bg, }; button::Style { background: Some(Background::Color(active_bg)), text_color: label_color, border: Border { color: border_color, width: if selected { 1.0 } else { 0.0 }, radius: 8.0.into(), }, ..Default::default() } }) .padding(10) .width(iced::Length::Fill) .into() }; let mut settings_nav = column![ text("SETTINGS").size(11).color(color_subtext), ] .spacing(8) .width(iced::Length::Fill); for category in SettingsCategory::ALL { settings_nav = settings_nav.push(category_button(category)); } let settings_nav = container(settings_nav) .padding(12) .width(iced::Length::Fixed(220.0)) .height(iced::Length::Fill) .style(c_style(color_crust, color_surface, 8.0)); let settings_content: Element<'_, AppMessage> = if state.window_size.width < 820.0 { scrollable( column![ text("Category").size(12).color(color_subtext), pick_list( &SettingsCategory::ALL[..], Some(state.settings_category), AppMessage::SelectSettingsCategory, ).width(iced::Length::Fill), vertical_space(10.0), settings_body, ] .spacing(8) .width(iced::Length::Fill), ) .width(iced::Length::Fill) .height(iced::Length::Fill) .into() } else { row![ settings_nav, scrollable(settings_body) .width(iced::Length::Fill) .height(iced::Length::Fill), ] .spacing(16) .width(iced::Length::Fill) .height(iced::Length::Fill) .into() }; // 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); let settings_screen = container(settings_box) .width(iced::Length::Fill) .height(iced::Length::Fill) .padding(24) .center_x(iced::Length::Fill) .style(c_style(root_bg, Color::TRANSPARENT, 0.0)); return with_regenerate_confirm(settings_screen.into(), state); } if state.current_screen == Screen::Home { // --- HOME SCREEN --- // Keep Create/Join dominant on a fresh install. Once Recents or Friends // has real content, the wider three-card layout returns. let has_recents = !state.config.recents.is_empty(); let has_friends = !state.friends.list().is_empty(); let body = responsive(move |size| { let cards: Element = match home_layout_mode(size.width, has_recents, has_friends) { HomeLayoutMode::FocusedEmpty => row![ connect_card(state), column![friends_panel(state), recents_card(state)] .spacing(16) .width(iced::Length::Fixed(360.0)), ] .spacing(22) .align_y(iced::alignment::Vertical::Top) .into(), HomeLayoutMode::ThreeColumn => row![ recents_card(state), connect_card(state), friends_panel(state), ] .spacing(20) .align_y(iced::alignment::Vertical::Top) .into(), HomeLayoutMode::Stacked => { let mut stack = column![connect_card(state)] .spacing(20) .align_x(iced::alignment::Horizontal::Center); if has_recents { stack = stack.push(recents_card(state)); } stack = stack.push(friends_panel(state)); if !has_recents { stack = stack.push(recents_card(state)); } stack.into() } }; scrollable(container(cards).center_x(iced::Length::Fill)) .width(iced::Length::Fill) .into() }); let home = container( column![ top_bar, vertical_space(20.0), body ].align_x(iced::alignment::Horizontal::Center) ) .width(iced::Length::Fill) .height(iced::Length::Fill) .style(c_style(root_bg, Color::TRANSPARENT, 0.0)); with_hotkey_info(with_layout_picker(home.into(), state), 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); // The room's cosmetic label (W7) rides in our share ticket; show it under // the wordmark when the room was named. Sanitized since a joined ticket is // peer-supplied. let room_label = crate::sanitize::sanitize_name( &crate::network::PeerSpeakTicket::label_of(&state.ticket), ); let title: Element<'_, AppMessage> = if room_label.is_empty() { text("PEERSPEAK").size(20).color(color_blue).into() } else { column![ text("PEERSPEAK").size(20).color(color_blue), text(room_label).size(13).color(color_subtext), ] .into() }; let header = row![ title, 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), row![ text("Ticket:").size(12).color(color_subtext), locked_value(&state.ticket, AppMessage::Noop) .width(iced::Length::Fixed(260.0)) .size(12) .padding(4), ] .spacing(6) .align_y(iced::alignment::Vertical::Center), 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![ avatar_view(&state.config.avatar, &state.name, &state.self_id, 34.0), 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) } ] .spacing(10) .align_y(iced::alignment::Vertical::Center), // Our own detected game (W17/W18). When we're broadcasting it, it // matches what peers see; otherwise it's marked "not shared". { let el: Element<'_, AppMessage> = match state.current_game.as_ref().and_then(|g| g.name.as_deref()) { Some(name) if state.config.game_presence_enabled => { text(format!("Playing {name}")).size(11).color(color_blue).into() } Some(name) => text(format!("Playing {name} (not shared)")) .size(11) .color(color_subtext) .into(), None => text("").into(), }; el }, // Live "you're sharing" badge — only present while sharing. When // sharing a specific app whose audio has dropped (A23 strict // mode), a warning line is added: viewers hear silence, not the // call, until that app plays again. { let el: Element<'_, AppMessage> = if state.self_sharing { let badge = 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); if state.share_audio_dropped { column![ badge, text( "⚠ Shared app isn't sending audio — viewers hear silence until it plays" ) .size(11) .color(color_yellow), ] .spacing(3) .into() } else { badge.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); // Always actionable: opens the pixelpass explainer if it's missing, // otherwise launches the viewer (A11 — consistent with Share Screen). watch_btn = watch_btn.on_press(if state.pixelpass_available { AppMessage::WatchShare(ticket) } else { AppMessage::OpenPixelpassHelp }); 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 }; // Add-friend affordance (W7): a star you can click to friend this // co-participant; a filled gold star (non-interactive) once they're a // friend. Hidden while their friend state is read-only-degraded. let add_friend_el: Element<'_, AppMessage> = if state.friends_read_only { iced::widget::Space::new().width(0.0).height(0.0).into() } else if state.friends.contains(peer_id) { tooltip( text("★").size(16).color(color_yellow), container(text("In your friends list").size(11).color(color_text)) .padding(8) .style(c_style(color_crust, color_surface, 6.0)), iced::widget::tooltip::Position::Bottom, ) .gap(6) .into() } else { tooltip( button(text("☆").size(16).color(color_subtext)) .on_press(AppMessage::AddFriendFromRoom(peer_id_clone)) .style(b_style(color_surface, color_blue, color_text, 6.0)) .padding([2, 6]), container(text("Add to your friends list").size(11).color(color_text)) .padding(8) .style(c_style(color_crust, color_surface, 6.0)), iced::widget::tooltip::Position::Bottom, ) .gap(6) .into() }; let mut card_content = column![ row![ avatar_view(&peer.avatar, &peer.name, &peer_id.to_string(), 38.0), { // Name + id, plus a "Playing " line when the peer is // broadcasting a game (game presence, W17). let mut name_col = column![ text(&peer.name).size(16).color(color_text), text(format!("ID: {}", short_id(&peer_id.to_string()))).size(11).color(color_subtext), ]; if let Some(game) = &peer.game { name_col = name_col.push( text(format!("Playing {game}")).size(11).color(color_blue), ); } name_col }, add_friend_el, 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 .config .peer_volume .get(&peer_id.to_string()) .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)) .step(0.01) .on_release(AppMessage::PersistConfig) ].spacing(8).align_y(iced::alignment::Vertical::Center) ); let peer_key = peer_id.to_string(); let current_pan = state.config.peer_pan.get(&peer_key).copied().unwrap_or(0.0); card_content = card_content.push( row![ text("Pan:").size(12).color(color_subtext), container(text(pan_label(current_pan)).size(11).color(color_subtext)) .width(iced::Length::Fixed(58.0)), slider(-1.0..=1.0, current_pan, move |v| AppMessage::PeerPanChanged(peer_id_clone, v)) .step(0.05) .on_release(AppMessage::PersistConfig), ] .spacing(8) .align_y(iced::alignment::Vertical::Center), ); // Peer noise gate: suppress this peer's background noise on our end. // Threshold is normalized RMS on the same 0..METER_MAX scale as the // mic gate; 0 = off. let current_gate = state.config.peer_gate.get(&peer_key).copied().unwrap_or(0.0); let gate_label = if current_gate <= 0.0 { "Off".to_string() } else { format!("{:.0}%", (current_gate / METER_MAX * 100.0).clamp(0.0, 100.0)) }; card_content = card_content.push( row![ text("Gate:").size(12).color(color_subtext), container(text(gate_label).size(11).color(color_subtext)) .width(iced::Length::Fixed(58.0)), slider(0.0..=METER_MAX, current_gate, move |v| AppMessage::PeerGateChanged(peer_id_clone, v)) .step(0.001) .on_release(AppMessage::PersistConfig), ] .spacing(8) .align_y(iced::alignment::Vertical::Center), ); let eq = peer_eq_settings(&state.config, peer_id); let eq_row = |label: &'static str, band: EqBand, value: f32| -> Element<'_, AppMessage> { row![ container(text(format!("{label} {value:+.1} dB")).size(11).color(color_subtext)) .width(iced::Length::Fixed(86.0)), slider(EQ_GAIN_DB_MIN..=EQ_GAIN_DB_MAX, value, move |v| { AppMessage::PeerEqChanged(peer_id_clone, band, v) }) .step(0.5) .on_release(AppMessage::PersistConfig), ] .spacing(8) .align_y(iced::alignment::Vertical::Center) .into() }; card_content = card_content.push( column![ text("EQ").size(11).color(color_subtext), eq_row("Low", EqBand::Low, eq.low_gain_db), eq_row("Mid", EqBand::Mid, eq.mid_gain_db), eq_row("High", EqBand::High, eq.high_gain_db), ] .spacing(4), ); 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 { let ptt_binding = if state.hotkey_capture == Some(HotkeyAction::PushToTalk) { "Press a key...".to_string() } else { format_binding(state.config.hotkeys.binding(HotkeyAction::PushToTalk)) }; column![ text(format!("PTT key: {ptt_binding}")).size(14).color(color_subtext), button(text("Set PTT Key").size(12).align_x(iced::alignment::Horizontal::Center)) .on_press(AppMessage::StartHotkeyCapture(HotkeyAction::PushToTalk)) .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 is wired at join time on Linux; other // targets show an inert status row instead of a dead toggle. let control: Element<'_, AppMessage> = { #[cfg(target_os = "linux")] { 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) .into() } #[cfg(not(target_os = "linux"))] { column![ checkbox(false) .label("Echo cancellation"), text("Not available on Windows yet.").size(11).color(color_subtext), ].spacing(4).into() } }; control }, 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. Always enabled and labelled "Share Screen": if the // optional pixelpass companion isn't installed, clicking opens a // short how-to-install explainer instead of being a dead disabled // button (A11) — so it never reads as a broken in-app feature. let (share_kind, share_label, share_bg, share_hover, share_fg) = 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 share_press = if state.pixelpass_available { AppMessage::ToggleScreenShare } else { AppMessage::OpenPixelpassHelp }; button(btn_content(share_kind, share_label, share_fg)) .on_press(share_press) .style(b_style(share_bg, share_hover, share_fg, 8.0)) .padding(14) .width(iced::Length::Fill) } ]; // 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 { let clip_status = status_snapshot(&state.clip_status); for m in &state.chat_messages { let name_color = if m.mine { color_green } else { color_lavender }; // Split the (already-sanitized) message into text + URL spans so // links render clickable and open in the system browser (A13). let spans: Vec<_> = crate::sanitize::linkify(&m.text) .into_iter() .map(|seg| match seg { crate::sanitize::Segment::Text(t) => { span(t).size(13).color(color_text) } crate::sanitize::Segment::Link(u) => { span(u.clone()).size(13).color(color_blue).link(u) } }) .collect(); let body = selectable_rich_text(spans) .on_link_click(AppMessage::OpenUrl) .selection_color(color_blue) .width(iced::Length::Fill); // Small avatar keyed on the sender's id (falls back to name); the // " (You)" suffix on our own echoes is stripped for clean initials. // Resolve the sender's chosen avatar: our own from config, a peer's // from their current presence (looked up by id), else monogram. let av_key = m.from.as_deref().unwrap_or(m.name.as_str()); let av_name = m.name.split(" (").next().unwrap_or(m.name.as_str()); let av = if m.mine { state.config.avatar.clone() } else { m.from .as_deref() .and_then(|f| { state .peers .iter() .find(|(k, _)| k.to_string() == f) .map(|(_, v)| v.avatar.clone()) }) .unwrap_or_default() }; chat_col = chat_col.push( row![ avatar_view(&av, av_name, av_key, 22.0), text(format!("{}:", m.name)).size(12).color(name_color), body, ] .spacing(8) .align_y(iced::alignment::Vertical::Top), ); // Attachment row (indented under the message), if any. if let Some(att) = &m.attachment { // This line's cache key is (its author, the attachment id). // `None` only for a system line or an unparseable author. let key: Option = m .from .as_ref() .and_then(|f| f.parse::().ok()) .map(|eid| (eid, att.id)); let data = key.as_ref().and_then(|k| state.attachments.get(k)); let elem: Element<'_, AppMessage> = if let Some(AttachmentState::Failed(e)) = data { text(format!("⚠ {} — {e}", att.name)) .size(12) .color(color_red) .into() } else if att.kind == crate::files::AttachmentKind::Image { match key.as_ref().and_then(|k| state.attachments.handle(k)) { Some(handle) => iced::widget::image(handle.clone()) .width(iced::Length::Fixed(260.0)) .into(), None => text(format!("🖼 {} — loading…", att.name)) .size(12) .color(color_subtext) .into(), } } else if crate::files::looks_like_audio_name(&att.name) && !state.invalid_audio.contains(&att.id) { let active = clip_status.playing_id == Some(att.id); let loading = state.pending_plays.contains(&att.id) && !matches!(data, Some(AttachmentState::Ready(_))); let position = if active { clip_status.position } else { std::time::Duration::ZERO }; let total = active.then_some(clip_status.total).flatten(); let play_button = if loading { button(text("Loading…").size(12)) } else if active && clip_status.paused { button(text("Play").size(12)).on_press(AppMessage::ResumeAudio) } else if active { button(text("Pause").size(12)).on_press(AppMessage::PauseAudio) } else { button(text("Play").size(12)) .on_press_maybe(key.map(AppMessage::PlayAudio)) } .style(b_style( color_blue, color_lavender, color_crust, 6.0, )) .padding(6); let elapsed = format_clip_time(position); let duration = total .map(format_clip_time) .unwrap_or_else(|| "--:--".to_string()); column![ row![ text(format!( "{} ({})", att.name, crate::files::human_size(att.size) )) .size(12) .color(color_text), button(text(if matches!(data, Some(AttachmentState::Ready(_))) { "Save" } else { "Download" }) .size(12)) .on_press_maybe(key.map(AppMessage::SaveAttachment)) .style(b_style( color_surface, color_overlay, color_text, 6.0, )) .padding(6), ] .spacing(8) .align_y(iced::alignment::Vertical::Center), row![ play_button, slider( 0.0..=1.0, if active { clip_progress(position, total) } else { 0.0 }, move |fraction| AppMessage::SeekAudio(att.id, fraction), ) .step(0.001) .width(iced::Length::Fixed(180.0)), text(format!("{elapsed} / {duration}")) .size(11) .color(color_subtext), // Per-clip volume. In universal mode this shows // and drives the shared level; otherwise it is // this clip's own remembered level. text("🔊").size(12).color(color_subtext), slider( 0.0..=2.0, effective_clip_volume(state, att.id), move |v| AppMessage::SetClipVolumeFor(att.id, v), ) .step(0.01) .width(iced::Length::Fixed(80.0)), ] .spacing(8) .align_y(iced::alignment::Vertical::Center), ] .spacing(4) .into() } else { let ready = matches!(data, Some(AttachmentState::Ready(_))); let btn_label = if ready { "Save" } else { "Download" }; row![ text(format!( "📎 {} ({})", att.name, crate::files::human_size(att.size) )) .size(12) .color(color_text), button(text(btn_label).size(12)) .on_press_maybe(key.map(AppMessage::SaveAttachment)) .style(b_style( color_blue, color_lavender, color_crust, 6.0, )) .padding(6), ] .spacing(8) .align_y(iced::alignment::Vertical::Center) .into() }; chat_col = chat_col.push( row![ iced::widget::Space::new().width(iced::Length::Fixed(30.0)), elem ] .spacing(0), ); } } } let chat_scroll = scrollable(chat_col) .width(iced::Length::Fill) .height(iced::Length::Fill) .anchor_bottom(); let chat_input_row = row![ button(text("📎").size(15)) .on_press(AppMessage::PickAttachmentFile) .style(b_style(color_surface, color_overlay, color_text, 6.0)) .padding(8), context_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); // Chat header: title on the left, the universal-volume control on the // right. The master slider drives every clip when "Universal" is checked; // when unchecked each clip keeps its own level and this slider is inert. let universal = state.config.clip_volume_universal; let chat_header = row![ text("Chat").size(16).color(color_blue), horizontal_space(), checkbox(universal) .label("Universal volume") .text_size(12) .on_toggle(AppMessage::ToggleUniversalClipVolume), text("🔊").size(13).color(color_subtext), slider(0.0..=2.0, state.config.clip_volume, AppMessage::SetClipVolume) .step(0.01) .width(iced::Length::Fixed(110.0)), ] .spacing(10) .align_y(iced::alignment::Vertical::Center); let chat_inner = column![ chat_header, 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 clock_skew_banner: Element<'_, AppMessage> = if let Some(warning) = state.clock_skew_warning { let direction = if warning.peer_ahead { "ahead" } else { "behind" }; let skew = format_clock_skew_duration(warning.skew_secs); let copy = format!( "A peer couldn't be seen - clocks are out of sync by ~{skew} (peer clock looks {direction}). Check your system clock (turn on automatic time sync)." ); column![ vertical_space(10.0), container( row![ icon(IconKind::Clock, 16.0, color_yellow), text(copy).size(12).color(color_text).width(iced::Length::Fill), button(text("Dismiss").size(12)) .on_press(AppMessage::DismissClockSkewWarning) .style(b_style(color_surface, color_blue, color_text, 6.0)) .padding(6), ] .spacing(10) .align_y(iced::alignment::Vertical::Center) ) .padding(10) .width(iced::Length::Fill) .style(move |_theme: &Theme| container::Style { text_color: Some(color_text), background: Some(Background::Color(Color { a: 0.14, ..color_yellow })), border: Border { color: color_yellow, width: 1.0, radius: 8.0.into() }, ..Default::default() }) ] .into() } else { iced::widget::Space::new().width(0.0).height(0.0).into() }; let room = container( column![top_bar, header_container, clock_skew_banner, vertical_space(12.0), body] ) .padding(15) .width(iced::Length::Fill) .height(iced::Length::Fill) .style(c_style(root_bg, Color::TRANSPARENT, 0.0)); with_hotkey_info( with_share_picker( with_pixelpass_help(with_layout_picker(room.into(), state), state), state, ), 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 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() } } } /// 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, } impl Program for Divider { type State = DividerState; fn update( &self, state: &mut Self::State, event: &Event, bounds: Rectangle, cursor: mouse::Cursor, ) -> Option> { // 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 { 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() } /// Overlay the live hotkey reference from the top-right info button. It reads /// directly from config, so Settings edits are reflected immediately. fn with_hotkey_info<'a>( base: Element<'a, AppMessage>, state: &'a AppState, ) -> Element<'a, AppMessage> { if !state.hotkey_info_open { return base; } let pal = state.config.theme.palette(); let crust = pal.crust; let mantle = pal.mantle; let surface = pal.surface; let text_c = pal.text; let subtext = pal.subtext; let blue = pal.blue; 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.25, ..crust })), ..Default::default() }), ) .on_press(AppMessage::CloseHotkeyInfo); let mut rows = Column::new().spacing(8).width(iced::Length::Fill); for action in HotkeyAction::ALL { rows = rows.push( row![ text(action.label()).size(12).color(text_c), horizontal_space(), text(format_binding(state.config.hotkeys.binding(action))) .size(12) .color(subtext), ] .spacing(12) .align_y(iced::alignment::Vertical::Center), ); } let dialog = container( column![ row![ text("Hotkeys").size(16).color(blue), horizontal_space(), button(text("✕").size(16).color(subtext)) .on_press(AppMessage::CloseHotkeyInfo) .style(|_t: &Theme, _s: button::Status| button::Style { background: None, ..Default::default() }) .padding(2), ] .align_y(iced::alignment::Vertical::Center), rows, ] .spacing(14), ) .style(move |_t: &Theme| container::Style { text_color: Some(text_c), background: Some(Background::Color(mantle)), border: Border { color: surface, width: 1.0, radius: 8.0.into() }, ..Default::default() }) .padding(16) .width(iced::Length::Fixed(320.0)); stack![ base, backdrop, container(column![ vertical_space(48.0), row![horizontal_space(), dialog].width(iced::Length::Fill), ]) .width(iced::Length::Fill) .height(iced::Length::Fill) .padding(12), ] .into() } /// Overlays the "screen sharing needs pixelpass" explainer popup over `base` /// when open (A11). Triggered by the Share Screen / Watch controls when the /// optional `pixelpass` companion isn't installed, so those controls open a /// short how-to instead of being dead/disabled. Returns the base unchanged when /// the popup is closed. fn with_pixelpass_help<'a>( base: Element<'a, AppMessage>, state: &'a AppState, ) -> Element<'a, AppMessage> { if !state.pixelpass_help_open { return base; } let pal = state.config.theme.palette(); let crust = pal.crust; let mantle = pal.mantle; let surface = pal.surface; let text_c = pal.text; let subtext = pal.subtext; let blue = pal.blue; 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::ClosePixelpassHelp); let dialog = container( column![ row![ text("Screen sharing").size(16).color(blue), horizontal_space(), button(text("✕").size(16).color(subtext)) .on_press(AppMessage::ClosePixelpassHelp) .style(|_t: &Theme, _s: button::Status| button::Style { background: None, ..Default::default() }) .padding(2), ] .align_y(iced::alignment::Vertical::Center), text( "Screen sharing uses pixelpass, a small companion tool that \ streams video peer-to-peer alongside your call. It's optional \ and ships separately, so peerspeak works fully without it." ) .size(13) .color(text_c), text("To enable sharing and watching:").size(13).color(text_c), text("• Install pixelpass and make sure the `pixelpass` command is on your PATH.").size(12).color(subtext), text("• Install `mpv` too — it's what opens a peer's shared screen.").size(12).color(subtext), text("Once both are present, restart peerspeak and the Share Screen button will start a share.").size(12).color(subtext), row![ horizontal_space(), button(text("Got it").size(13)) .on_press(AppMessage::ClosePixelpassHelp) .style(move |_t: &Theme, status: button::Status| button::Style { background: Some(Background::Color(match status { button::Status::Hovered => blue, _ => surface, })), text_color: text_c, border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 6.0.into() }, ..Default::default() }) .padding(8), ], ] .spacing(14), ) .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(460.0)); stack![ base, backdrop, container(dialog) .center_x(iced::Length::Fill) .center_y(iced::Length::Fill), ] .into() } /// Overlay the screen-share **audio picker** when open (A23). Lets the user /// capture a single app's audio instead of the whole desktop sink — the default /// whole-desktop capture contains our own call playout, so a viewer would /// otherwise hear themselves echoed back. "All system audio" keeps the legacy /// behavior (with a warning); picking an app passes `--app=` to pixelpass. fn with_share_picker<'a>( base: Element<'a, AppMessage>, state: &'a AppState, ) -> Element<'a, AppMessage> { if !state.share_picker_open { return base; } let pal = state.config.theme.palette(); let crust = pal.crust; let mantle = pal.mantle; let surface = pal.surface; let text_c = pal.text; let subtext = pal.subtext; let blue = pal.blue; let yellow = pal.yellow; 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::CloseSharePicker); // One selectable row: a radio-style dot + label. Highlighted when selected. let opt_row = |selected: bool, label: String, sublabel: Option<&'static str>, msg: AppMessage| { let dot = text(if selected { "●" } else { "○" }) .size(14) .color(if selected { blue } else { subtext }); let mut labels = column![text(label).size(13).color(text_c)].spacing(2); if let Some(s) = sublabel { labels = labels.push(text(s).size(11).color(yellow)); } button( row![dot, labels] .spacing(10) .align_y(iced::alignment::Vertical::Center), ) .on_press(msg) .width(iced::Length::Fill) .padding(8) .style(move |_t: &Theme, status: button::Status| { let bg = if selected { Some(Background::Color(surface)) } else if matches!(status, button::Status::Hovered) { Some(Background::Color(Color { a: 0.5, ..surface })) } else { None }; button::Style { background: bg, text_color: text_c, border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 6.0.into() }, ..Default::default() } }) }; // "All system audio" first (the whole-desktop default — carries the echo // warning), then each currently-playing app. let mut options = column![opt_row( state.share_audio_selection.is_none(), "All system audio".to_string(), Some("⚠ may echo the call back to viewers"), AppMessage::SelectShareAudioApp(None), )] .spacing(4); for app in &state.share_audio_apps { let selected = state.share_audio_selection.as_deref() == Some(app.as_str()); options = options.push(opt_row( selected, app.clone(), None, AppMessage::SelectShareAudioApp(Some(app.clone())), )); } let list: Element<'_, AppMessage> = if !state.share_app_audio_supported { // Older pixelpass without --strict-audio: per-app capture is unavailable // (it would hard-fail), so only whole-desktop is offered. Nudge to upgrade. column![ options, text("Update pixelpass to capture a single app's audio (avoids echoing the call to viewers).") .size(11) .color(yellow), ] .spacing(8) .into() } else if state.share_audio_apps.is_empty() { column![ options, text("No other apps are playing audio right now.") .size(11) .color(subtext), ] .spacing(8) .into() } else { scrollable(options).height(iced::Length::Shrink).into() }; let cancel_btn = button(text("Cancel").size(13).color(text_c)) .on_press(AppMessage::CloseSharePicker) .style(move |_t: &Theme, status: button::Status| button::Style { background: Some(Background::Color(match status { button::Status::Hovered => surface, _ => mantle, })), text_color: text_c, border: Border { color: surface, width: 1.0, radius: 6.0.into() }, ..Default::default() }) .padding(8); let share_btn = button(text("Share").size(13).color(crust)) .on_press(AppMessage::ConfirmShareScreen) .style(move |_t: &Theme, status: button::Status| button::Style { background: Some(Background::Color(match status { button::Status::Hovered => pal.lavender, _ => blue, })), text_color: crust, border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 6.0.into() }, ..Default::default() }) .padding(8); let dialog = container( column![ row![ text("Share screen audio").size(16).color(blue), horizontal_space(), button(text("✕").size(16).color(subtext)) .on_press(AppMessage::CloseSharePicker) .style(|_t: &Theme, _s: button::Status| button::Style { background: None, ..Default::default() }) .padding(2), ] .align_y(iced::alignment::Vertical::Center), text("Capture audio from:").size(13).color(text_c), list, row![ horizontal_space(), cancel_btn, share_btn, ] .spacing(8), ] .spacing(14), ) .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(420.0)) .max_height(460.0); stack![ base, backdrop, container(dialog) .center_x(iced::Length::Fill) .center_y(iced::Length::Fill), ] .into() } /// Overlay the "Regenerate identity?" confirm dialog when open (W7). A /// destructive action — minting a new id discards the old one — so it's gated /// behind an explicit confirm with a clear warning. fn with_regenerate_confirm<'a>( base: Element<'a, AppMessage>, state: &'a AppState, ) -> Element<'a, AppMessage> { if !state.regenerate_identity_confirm_open { return base; } let pal = state.config.theme.palette(); let crust = pal.crust; let mantle = pal.mantle; let surface = pal.surface; let text_c = pal.text; let subtext = pal.subtext; let maroon = pal.maroon; 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::CloseRegenerateIdentityConfirm); let dialog = container( column![ text("Regenerate identity?").size(16).color(maroon), text( "This mints a brand-new identity and permanently discards your \ current one. Friends who saved your old ID will no longer \ recognise or reach you until you reconnect and they re-add you. \ This can't be undone." ) .size(13) .color(text_c), text("Takes effect on your next room join.").size(12).color(subtext), row![ horizontal_space(), button(text("Cancel").size(13)) .on_press(AppMessage::CloseRegenerateIdentityConfirm) .style(move |_t: &Theme, status: button::Status| button::Style { background: Some(Background::Color(match status { button::Status::Hovered => surface, _ => mantle, })), text_color: text_c, border: Border { color: surface, width: 1.0, radius: 6.0.into() }, ..Default::default() }) .padding(8), button(text("Regenerate").size(13)) .on_press(AppMessage::ConfirmRegenerateIdentity) .style(move |_t: &Theme, status: button::Status| button::Style { background: Some(Background::Color(match status { button::Status::Hovered => Color { a: 0.85, ..maroon }, _ => maroon, })), text_color: text_c, border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 6.0.into() }, ..Default::default() }) .padding(8), ].spacing(10), ] .spacing(14), ) .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(440.0)); stack![ base, backdrop, container(dialog) .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 for LayoutIcon { type State = (); fn draw( &self, _state: &(), renderer: &Renderer, _theme: &Theme, bounds: Rectangle, _cursor: mouse::Cursor, ) -> Vec { 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 for LayoutThumb { type State = (); fn draw( &self, _state: &(), renderer: &Renderer, _theme: &Theme, bounds: Rectangle, _cursor: mouse::Cursor, ) -> Vec { 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 for ThemeSwatch { type State = (); fn draw( &self, _state: &(), renderer: &Renderer, _theme: &Theme, bounds: Rectangle, _cursor: mouse::Cursor, ) -> Vec { 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, Info, 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() } /// A circular monogram avatar (W4): the participant's initial(s) on a colour /// deterministically derived from `key` (their node id, or display name where no /// id is available). This is the fallback shown until presets / custom uploads /// (W4 Phases 2–3) override it. `size` is the diameter in px. fn avatar_badge<'a>(name: &str, key: &str, size: f32) -> Element<'a, AppMessage> { let (r, g, b) = crate::avatar::color_for_key(key); let bg = Color::from_rgb8(r, g, b); let fg = if crate::avatar::use_dark_text_on((r, g, b)) { Color::from_rgb8(0x1e, 0x1e, 0x2e) } else { Color::WHITE }; container(text(crate::avatar::initials(name)).size(size * 0.42).color(fg)) .center_x(iced::Length::Fixed(size)) .center_y(iced::Length::Fixed(size)) .style(move |_t: &Theme| container::Style { background: Some(Background::Color(bg)), border: Border { color: Color::TRANSPARENT, width: 0.0, radius: (size / 2.0).into() }, ..Default::default() }) .into() } /// Maximum distinct avatar images we keep handles for. Each avatar is bounded to /// 48 KiB / 256×256 at ingest, so a 64-entry LRU caps this cache at a few MB /// regardless of how many distinct avatars peers publish over time (Tier C F-03). const AVATAR_CACHE_CAP: usize = 64; thread_local! { /// Bounded cache of avatar image handles, keyed by PNG content, so the SAME /// `image::Handle` (and thus the same GPU texture id) is reused across /// redraws. `image::Handle::from_bytes` mints a fresh *unique* id on every /// call, so building handles inline in `view()` made iced re-upload the /// texture on every repaint — including the redraws fired on each mouse move — /// which showed up as constant flicker. A peer can publish an unbounded stream /// of distinct valid avatars over a session, so the cache is an LRU (bounded + /// byte-equality keyed) rather than a plain map (Tier C F-03). Lives on the /// (single) UI thread. static AVATAR_HANDLE_CACHE: std::cell::RefCell> = std::cell::RefCell::new(crate::avatar::ByteLru::new(AVATAR_CACHE_CAP)); } /// A stable image handle for these exact PNG bytes (cached by content), so it /// keeps the same id across redraws — see [`AVATAR_HANDLE_CACHE`]. fn cached_image_handle(bytes: bytes::Bytes) -> iced::widget::image::Handle { AVATAR_HANDLE_CACHE.with(|cache| { cache .borrow_mut() .get_or_insert(bytes.as_ref(), || { iced::widget::image::Handle::from_bytes(bytes.clone()) }) }) } /// Render a participant's avatar (W4): the chosen preset image if any, else the /// monogram fallback. `name`/`key` feed the monogram; `size` is the diameter. fn avatar_view<'a>( avatar: &crate::avatar::Avatar, name: &str, key: &str, size: f32, ) -> Element<'a, AppMessage> { let png_bytes: Option = avatar .preset_png() .map(bytes::Bytes::from_static) .or_else(|| avatar.custom_png().map(bytes::Bytes::from)); match png_bytes { Some(b) => iced::widget::image(cached_image_handle(b)) .width(iced::Length::Fixed(size)) .height(iced::Length::Fixed(size)) .into(), None => avatar_badge(name, key, size), } } /// 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 for Icon { type State = (); fn draw( &self, _state: &(), renderer: &Renderer, _theme: &Theme, bounds: Rectangle, _cursor: mouse::Cursor, ) -> Vec { 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::Info => { f.stroke(&Path::circle(p(12.0, 12.0), 8.5 * s), stk()); f.stroke(&poly(&[(12.0, 10.5), (12.0, 17.0)], false), stk()); f.fill(&Path::circle(p(12.0, 7.0), 1.1 * s), col); } 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::{ attachment_default_name, clear_expired_clock_skew_warning, format_clock_skew_duration, format_duration, initial_window_position, reconnect_attempt_chime, reconnected_chime, set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning, update, AppConfig, AppMessage, AppState, AttachmentCache, AttachmentState, ChatEntry, ClockSkewBanner, GateMeter, METER_MAX, UiEvent, CLOCK_SKEW_WARNING_VISIBLE_SECS, }; use iroh::SecretKey; /// Two distinct (peer, id) keys for cache tests; `id` may be shared to model /// a malicious peer reusing a victim's attachment id (Tier C F-12). fn peer_key(id: [u8; 32]) -> super::AttachmentKey { (SecretKey::generate().public(), id) } #[test] fn attachment_cache_evicts_oldest_when_full() { let mut cache = AttachmentCache::new(2); let k1 = peer_key([1u8; 32]); let k2 = peer_key([2u8; 32]); let k3 = peer_key([3u8; 32]); cache.insert(k1, AttachmentState::Ready(vec![1]), None); cache.insert(k2, AttachmentState::Ready(vec![2]), None); assert_eq!(cache.len(), 2); // Inserting a third NEW key evicts the oldest (k1), not the newest. cache.insert(k3, AttachmentState::Ready(vec![3]), None); assert_eq!(cache.len(), 2); assert!(cache.get(&k1).is_none(), "oldest should be evicted"); assert!(cache.get(&k2).is_some()); assert!(cache.get(&k3).is_some()); } #[test] fn attachment_cache_replace_keeps_position_and_count() { let mut cache = AttachmentCache::new(2); let k1 = peer_key([1u8; 32]); let k2 = peer_key([2u8; 32]); cache.insert(k1, AttachmentState::Failed("pending".into()), None); cache.insert(k2, AttachmentState::Ready(vec![2]), None); // Replacing k1 (Failed -> Ready) must NOT bump it to newest; it stays the // eviction candidate, and the count is unchanged (no order leak). cache.insert(k1, AttachmentState::Ready(vec![1]), None); assert_eq!(cache.len(), 2); let k3 = peer_key([3u8; 32]); cache.insert(k3, AttachmentState::Ready(vec![3]), None); assert!(cache.get(&k1).is_none(), "replaced entry kept its old age"); assert!(cache.get(&k2).is_some()); assert!(cache.get(&k3).is_some()); } #[test] fn attachment_cache_same_id_distinct_authors_do_not_alias() { // The F-12 core property: two peers sending the SAME attachment id keep // separate bytes — one never overwrites or aliases the other. let mut cache = AttachmentCache::new(8); let shared_id = [7u8; 32]; let victim = peer_key(shared_id); let attacker = peer_key(shared_id); cache.insert(victim, AttachmentState::Ready(vec![1, 1, 1]), None); cache.insert(attacker, AttachmentState::Ready(vec![9, 9, 9]), None); assert_eq!(cache.len(), 2); assert!(matches!(cache.get(&victim), Some(AttachmentState::Ready(b)) if b == &[1, 1, 1])); assert!(matches!(cache.get(&attacker), Some(AttachmentState::Ready(b)) if b == &[9, 9, 9])); } #[test] fn attachment_cache_is_ready_and_handle_and_clear() { let mut cache = AttachmentCache::new(4); let k = peer_key([5u8; 32]); cache.insert( k, AttachmentState::Ready(vec![1]), Some(iced::widget::image::Handle::from_bytes(vec![1])), ); assert!(cache.is_ready(&k)); assert!(cache.handle(&k).is_some()); let failed = peer_key([6u8; 32]); cache.insert(failed, AttachmentState::Failed("nope".into()), None); assert!(!cache.is_ready(&failed)); assert!(cache.handle(&failed).is_none()); cache.clear(); assert_eq!(cache.len(), 0); assert!(cache.get(&k).is_none()); } #[test] fn attachment_cache_cap_zero_is_clamped_to_one() { let mut cache = AttachmentCache::new(0); let k = peer_key([1u8; 32]); cache.insert(k, AttachmentState::Ready(vec![1]), None); assert_eq!(cache.len(), 1); assert!(cache.get(&k).is_some()); } #[test] fn attachment_default_name_matches_full_key_not_bare_id() { // Two chat lines carry the SAME attachment id but come from different // senders. The save-dialog default filename must be the one belonging to // the clicked (author, id) — not whichever line happens to match the bare // id first (Tier C F-12 metadata residual). let victim = SecretKey::generate().public(); let attacker = SecretKey::generate().public(); let shared_id = [7u8; 32]; let mk = |from: iroh::EndpointId, fname: &str| ChatEntry { name: "Peer".to_string(), text: String::new(), mine: false, from: Some(from.to_string()), attachment: Some(crate::files::ChatAttachment { name: fname.to_string(), size: 3, kind: crate::files::AttachmentKind::File, id: shared_id, }), }; // Attacker's line is FIRST in history, so a bare-id scan would pick it. let messages = vec![mk(attacker, "evil.sh"), mk(victim, "report.pdf")]; assert_eq!(attachment_default_name(&messages, (victim, shared_id)), "report.pdf"); assert_eq!(attachment_default_name(&messages, (attacker, shared_id)), "evil.sh"); // Unknown line → safe fallback. let unknown = SecretKey::generate().public(); assert_eq!(attachment_default_name(&messages, (unknown, shared_id)), "download"); } #[test] fn reset_room_state_clears_all_room_scoped_state() { let mut state = AppState::default(); let peer = SecretKey::generate().public(); let attachment_id = [9u8; 32]; let now = std::time::Instant::now(); state.peers.insert(peer, crate::network::PeerState { name: "Peer".to_string(), is_muted: false, addr: iroh::EndpointAddr::from(peer), sharing: None, avatar: crate::avatar::Avatar::default(), game: None, }); state.audio_levels.insert(peer, 0.5); state.locally_muted.insert(peer); state.chat_messages.push(ChatEntry { name: "Peer".to_string(), text: "old room".to_string(), mine: false, from: Some(peer.to_string()), attachment: None, }); state.chat_input = "draft".to_string(); let att_key = (peer, attachment_id); state.attachments.insert( att_key, AttachmentState::Ready(vec![1]), Some(iced::widget::image::Handle::from_bytes(vec![1])), ); state.pending_saves.insert(att_key); state.pending_plays.insert(attachment_id); state.invalid_audio.insert(attachment_id); state.connecting.insert(peer); state.ever_connected.insert(peer); state.recording = true; state.recording_started = Some(now); state.call_started = Some(now); state.mic_level = 0.75; state.self_sharing = true; state.share_picker_open = true; state.share_audio_apps = vec!["Firefox".to_string()]; state.share_audio_selection = Some("Firefox".to_string()); state.share_starting = true; state.share_audio_dropped = true; state.share_audio_app_active = true; state.share_app_audio_supported = false; state.clock_skew_warning = Some(ClockSkewBanner { skew_secs: 180, peer_ahead: true, expires_at: now, }); state.clip_status.lock().unwrap().playing_id = Some(attachment_id); state.reset_room_state(); assert!(state.peers.is_empty()); assert!(state.audio_levels.is_empty()); assert!(state.locally_muted.is_empty()); assert!(state.chat_messages.is_empty()); assert!(state.chat_input.is_empty()); assert!(state.attachments.len() == 0); assert!(state.pending_saves.is_empty()); assert!(state.pending_plays.is_empty()); assert!(state.invalid_audio.is_empty()); assert!(state.connecting.is_empty()); assert!(state.ever_connected.is_empty()); assert!(!state.recording); assert!(state.recording_started.is_none()); assert!(state.call_started.is_none()); assert_eq!(state.mic_level, 0.0); assert!(!state.self_sharing); assert!(!state.share_picker_open); assert!(state.share_audio_apps.is_empty()); assert!(state.share_audio_selection.is_none()); assert!(!state.share_starting); assert!(!state.share_audio_dropped); assert!(!state.share_audio_app_active); assert!(state.share_app_audio_supported, "reset is optimistic by default"); assert!(state.clock_skew_warning.is_none()); for _ in 0..50 { if crate::audio::clip_player::status_snapshot(&state.clip_status).playing_id.is_none() { return; } std::thread::sleep(std::time::Duration::from_millis(2)); } panic!("clip player did not stop during room reset"); } #[test] fn clock_skew_warning_shows_dismisses_and_expires() { let mut state = AppState::default(); let now = std::time::Instant::now(); show_clock_skew_warning(&mut state, 181, true, now); let warning = state.clock_skew_warning.expect("warning should be visible"); assert_eq!(warning.skew_secs, 181); assert!(warning.peer_ahead); assert_eq!( warning.expires_at, now + std::time::Duration::from_secs(CLOCK_SKEW_WARNING_VISIBLE_SECS) ); let _ = update(&mut state, AppMessage::DismissClockSkewWarning); assert!(state.clock_skew_warning.is_none()); show_clock_skew_warning(&mut state, 240, false, now); clear_expired_clock_skew_warning( &mut state, now + std::time::Duration::from_secs(CLOCK_SKEW_WARNING_VISIBLE_SECS - 1), ); assert!(state.clock_skew_warning.is_some()); clear_expired_clock_skew_warning( &mut state, now + std::time::Duration::from_secs(CLOCK_SKEW_WARNING_VISIBLE_SECS), ); assert!(state.clock_skew_warning.is_none()); } #[test] fn clock_skew_ui_event_populates_banner() { let mut state = AppState::default(); let _ = update( &mut state, AppMessage::UiEventReceived(UiEvent::ClockSkewWarning { skew_secs: 121, peer_ahead: false, }), ); let warning = state.clock_skew_warning.expect("event should show banner"); assert_eq!(warning.skew_secs, 121); assert!(!warning.peer_ahead); } #[test] fn clock_skew_duration_rounds_up_to_minutes() { assert_eq!(format_clock_skew_duration(0), "1 minute"); assert_eq!(format_clock_skew_duration(1), "1 minute"); assert_eq!(format_clock_skew_duration(60), "1 minute"); assert_eq!(format_clock_skew_duration(61), "2 minutes"); assert_eq!(format_clock_skew_duration(181), "4 minutes"); } #[test] fn share_picker_startup_window_is_guarded() { // P3-1: between confirming the picker and the core's ScreenShareStarted, // self_sharing is still false. The picker must not be reopenable in that // window, and a stray confirm must not re-fire StartScreenShare. // Picker open, user confirms a selection. let mut state = AppState { share_picker_open: true, share_audio_selection: Some("mpv".to_string()), ..Default::default() }; let _ = update(&mut state, AppMessage::ConfirmShareScreen); assert!(state.share_starting, "confirm should mark a start in flight"); assert!(!state.share_picker_open, "confirm should close the picker"); assert!(!state.self_sharing, "core hasn't acked the start yet"); // Clicking Share again during startup must NOT reopen the picker. let _ = update(&mut state, AppMessage::ToggleScreenShare); assert!(!state.share_picker_open, "picker must stay closed while starting"); assert!(state.share_starting); // A stray confirm during startup is ignored (no double-start). let _ = update(&mut state, AppMessage::ConfirmShareScreen); assert!(state.share_starting); // Core acks: flag clears, sharing begins, no picker lingers. let _ = update( &mut state, AppMessage::UiEventReceived(UiEvent::ScreenShareStarted), ); assert!(!state.share_starting); assert!(state.self_sharing); assert!(!state.share_picker_open); } #[test] fn share_start_failure_clears_in_flight_flag() { // A failed spawn surfaces as UiEvent::Error (not ScreenShareStopped); the // in-flight flag must still clear so the user can retry. let mut state = AppState { share_picker_open: true, ..Default::default() }; let _ = update(&mut state, AppMessage::ConfirmShareScreen); assert!(state.share_starting); let _ = update( &mut state, AppMessage::UiEventReceived(UiEvent::Error("boom".into())), ); assert!(!state.share_starting, "error must un-wedge the start flag"); assert!(!state.self_sharing); // And now the picker can be opened again. let _ = update(&mut state, AppMessage::ToggleScreenShare); assert!(state.share_picker_open); } #[test] fn share_audio_dropped_tracks_app_audio_events() { // While sharing a specific app, app_audio lost/routed toggles the warning // flag; start and stop both reset it so it can't linger across sessions. // A specific app was chosen in the picker, so the share is app-specific. let mut state = AppState { share_audio_selection: Some("mpv".to_string()), ..Default::default() }; // Start sharing — flag is clear, and the share is marked app-specific. let _ = update( &mut state, AppMessage::UiEventReceived(UiEvent::ScreenShareStarted), ); assert!(!state.share_audio_dropped); assert!(state.share_audio_app_active); // The chosen app's audio stops → warning on. let _ = update( &mut state, AppMessage::UiEventReceived(UiEvent::ShareAudioActive(false)), ); assert!(state.share_audio_dropped); // It plays again → warning off. let _ = update( &mut state, AppMessage::UiEventReceived(UiEvent::ShareAudioActive(true)), ); assert!(!state.share_audio_dropped); // Drop again, then stop sharing → flag reset regardless. let _ = update( &mut state, AppMessage::UiEventReceived(UiEvent::ShareAudioActive(false)), ); assert!(state.share_audio_dropped); let _ = update( &mut state, AppMessage::UiEventReceived(UiEvent::ScreenShareStopped), ); assert!(!state.share_audio_dropped); assert!(!state.share_audio_app_active); } #[test] fn share_audio_active_ignored_unless_app_sharing() { // P3 (unscoped events): a late app_audio event from a just-killed host // must not flip the warning when we're not sharing a specific app — // neither after stop nor on a whole-desktop share. // (a) Whole-desktop share (no app selected): events are ignored. let mut state = AppState::default(); let _ = update( &mut state, AppMessage::UiEventReceived(UiEvent::ScreenShareStarted), ); assert!(!state.share_audio_app_active, "no app selected ⇒ not app-specific"); let _ = update( &mut state, AppMessage::UiEventReceived(UiEvent::ShareAudioActive(false)), ); assert!(!state.share_audio_dropped, "whole-desktop share ignores app_audio"); // (b) After stop: a straggling event can't resurrect the warning. let mut state = AppState { share_audio_selection: Some("mpv".to_string()), ..Default::default() }; let _ = update( &mut state, AppMessage::UiEventReceived(UiEvent::ScreenShareStarted), ); let _ = update( &mut state, AppMessage::UiEventReceived(UiEvent::ScreenShareStopped), ); let _ = update( &mut state, AppMessage::UiEventReceived(UiEvent::ShareAudioActive(false)), ); assert!(!state.share_audio_dropped, "post-stop event is ignored"); } #[test] fn old_pixelpass_picker_offers_whole_desktop_only() { // P2 (version skew): when the resolved pixelpass lacks --strict-audio, the // picker must drop all per-app options and force the whole-desktop choice, // so a per-app share (which would pass the unknown flag) can't be started. let mut state = AppState { share_picker_open: true, share_audio_selection: Some("Firefox".to_string()), share_audio_apps: vec!["Firefox".to_string(), "mpv".to_string()], ..Default::default() }; let _ = update( &mut state, AppMessage::UiEventReceived(UiEvent::AudioAppsListed { apps: vec!["Firefox".to_string(), "mpv".to_string()], app_audio_supported: false, }), ); assert!(!state.share_app_audio_supported); assert!(state.share_audio_apps.is_empty(), "no per-app rows offered"); assert!(state.share_audio_selection.is_none(), "forced to whole-desktop"); // A supported pixelpass keeps the app list and a valid selection. let _ = update( &mut state, AppMessage::UiEventReceived(UiEvent::AudioAppsListed { apps: vec!["Firefox".to_string(), "mpv".to_string()], app_audio_supported: true, }), ); assert!(state.share_app_audio_supported); assert_eq!(state.share_audio_apps.len(), 2); } #[test] fn peer_gate_persists_when_on_and_clears_when_off() { let mut config = AppConfig::default(); let id = SecretKey::generate().public(); // A positive threshold is stored, clamped to the slider's METER_MAX ceiling. let stored = set_peer_gate_config(&mut config, id, 0.05); assert_eq!(stored, 0.05); assert_eq!(config.peer_gate.get(&id.to_string()).copied(), Some(0.05)); assert_eq!(set_peer_gate_config(&mut config, id, 99.0), METER_MAX); // Zero (or negative) means "gate off" — the entry is removed so the // config doesn't carry a disabled gate. let stored = set_peer_gate_config(&mut config, id, 0.0); assert_eq!(stored, 0.0); assert!(!config.peer_gate.contains_key(&id.to_string())); } #[test] fn peer_volume_persists_non_unity_and_clears_at_unity() { let mut config = AppConfig::default(); let id = SecretKey::generate().public(); // A non-unity value is clamped into range and stored. let stored = set_peer_volume_config(&mut config, id, 1.5); assert_eq!(stored, 1.5); assert_eq!(config.peer_volume.get(&id.to_string()).copied(), Some(1.5)); // Out-of-range values clamp to the slider bounds. assert_eq!(set_peer_volume_config(&mut config, id, 5.0), 2.0); assert_eq!(set_peer_volume_config(&mut config, id, -1.0), 0.0); // Returning to unity removes the entry (unity is the implicit default), // so the config doesn't accumulate no-op entries. let stored = set_peer_volume_config(&mut config, id, 1.0); assert_eq!(stored, 1.0); assert!(!config.peer_volume.contains_key(&id.to_string())); } #[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"); } #[test] fn settings_categories_are_stable_and_grouped_for_navigation() { use super::SettingsCategory; let labels: Vec<_> = SettingsCategory::ALL.iter().map(|c| c.label()).collect(); assert_eq!( labels, vec![ "Audio", "Hotkeys", "Recording", "Profile", "Appearance", "Network", "Notifications", "Games" ] ); assert_eq!(SettingsCategory::Audio.hint(), "Devices, mic gate, echo"); assert_eq!(SettingsCategory::Profile.hint(), "Avatar and identity"); assert_eq!(SettingsCategory::Games.hint(), "Detection, presence, backgrounds"); } #[test] fn home_layout_prioritizes_connect_on_empty_home() { use super::{home_layout_mode, HomeLayoutMode}; assert_eq!(home_layout_mode(1280.0, false, false), HomeLayoutMode::FocusedEmpty); assert_eq!(home_layout_mode(760.0, false, false), HomeLayoutMode::Stacked); assert_eq!(home_layout_mode(1280.0, true, false), HomeLayoutMode::ThreeColumn); assert_eq!(home_layout_mode(1100.0, true, true), HomeLayoutMode::Stacked); } 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, from: None, attachment: None, }; 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, from: None, attachment: None, }, ); } 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, from: None, attachment: None, }, ); } 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)); } }