Files
peerspeak/src/app/mod.rs
T

5234 lines
216 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::core::{CoreController, messages::{CoreCommand, UiEvent}};
use crate::network::PeerState;
use crate::notify::{self, Sound};
use crate::audio::eq::{EqSettings, EQ_GAIN_DB_MAX, EQ_GAIN_DB_MIN};
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 iced::widget::{
container, column, row, text, button, text_input, scrollable, slider, checkbox, pick_list,
radio, tooltip, progress_bar, canvas, Canvas, Column, stack, mouse_area,
rich_text, span, responsive,
};
use iced::widget::canvas::{Action, Frame, Geometry, Path, Program};
use iced::{
Color, Background, Border, Element, Subscription, Task, Theme, Event, keyboard, mouse,
Point, Rectangle, Renderer, Size,
};
use iroh::EndpointId;
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, OnceLock};
use tokio::sync::Mutex;
static UI_RX: OnceLock<Mutex<Option<tokio::sync::mpsc::Receiver<UiEvent>>>> = OnceLock::new();
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Screen {
Home,
Room,
Settings,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsCategory {
Audio,
Hotkeys,
Recording,
Profile,
Appearance,
Network,
Notifications,
}
impl SettingsCategory {
const ALL: [SettingsCategory; 7] = [
SettingsCategory::Audio,
SettingsCategory::Hotkeys,
SettingsCategory::Recording,
SettingsCategory::Profile,
SettingsCategory::Appearance,
SettingsCategory::Network,
SettingsCategory::Notifications,
];
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",
}
}
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",
}
}
}
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<String>,
}
/// 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;
/// 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),
TogglePtt(bool),
StartHotkeyCapture(HotkeyAction),
ClearHotkey(HotkeyAction),
PeerVolumeChanged(EndpointId, f32),
PeerPanChanged(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),
/// 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,
/// 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<Vec<u8>>),
/// 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<Vec<u8>>),
/// 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 (spawns/kills a pixelpass host).
ToggleScreenShare,
/// 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,
}
fn core_subscription() -> impl iced::futures::Stream<Item = UiEvent> {
iced::stream::channel(100, |mut output: iced::futures::channel::mpsc::Sender<UiEvent>| async move {
if let Some(rx_lock) = UI_RX.get() {
let mut guard = rx_lock.lock().await;
if let Some(mut rx) = guard.take() {
use iced::futures::sink::SinkExt;
while let Some(event) = rx.recv().await {
let _ = output.send(event).await;
}
}
}
})
}
pub struct AppState {
name: String,
ticket_input: String,
/// 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<HotkeyAction>,
input_devices: Vec<AudioDevice>,
output_devices: Vec<AudioDevice>,
selected_input: Option<AudioDevice>,
selected_output: Option<AudioDevice>,
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<bytes::Bytes>,
peers: HashMap<EndpointId, PeerState>,
peer_volumes: HashMap<EndpointId, f32>,
audio_levels: HashMap<EndpointId, f32>,
/// Peers we've locally muted (their audio isn't mixed into our output).
locally_muted: HashSet<EndpointId>,
/// When we joined the current room, for the in-room call-duration timer.
call_started: Option<std::time::Instant>,
/// Whether a local call recording is in progress (confirmed by the core).
recording: bool,
/// When the current recording started, for the header REC timer.
recording_started: Option<std::time::Instant>,
/// Room text-chat history (newest last) and the pending input line.
chat_messages: Vec<ChatEntry>,
chat_input: String,
/// Last known window size, tracked so divider clamps stay valid on resize.
/// (The divider positions themselves are persisted in `config`.)
window_size: Size,
/// Whether the room-layout picker popup is open (launch + in-call screens).
layout_picker_open: bool,
/// Whether the 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 Chat drawer is open (drawer layout only).
drawer_chat_open: bool,
/// Raw mic level (normalized RMS, `0.0..=1.0`) for the settings meter.
mic_level: f32,
/// Whether the standalone (off-call) mic test stream is running.
mic_test_active: bool,
/// Peers whose audio link is currently down (initial connect or reconnect).
connecting: HashSet<EndpointId>,
/// Peers we've had a live link to at least once — used to say "Reconnecting"
/// rather than "Connecting" the second time around.
ever_connected: HashSet<EndpointId>,
controller: Arc<CoreController>,
current_screen: Screen,
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<String>,
/// 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<String>,
/// 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<EndpointId, crate::presence::FriendPresence>,
/// "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<String>,
/// Window close has been requested and the GUI is waiting for core teardown.
closing: bool,
}
impl AppState {
fn custom_sound_path(&self, sound: Sound) -> &str {
let opt = match sound {
Sound::SelfJoin => &self.config.custom_sound_self_join,
Sound::PeerJoin => &self.config.custom_sound_peer_join,
Sound::PeerLeave => &self.config.custom_sound_peer_leave,
Sound::ReconnectAttempt => &self.config.custom_sound_reconnect_attempt,
Sound::Reconnected => &self.config.custom_sound_reconnected,
Sound::SelfLeave => &self.config.custom_sound_self_leave,
Sound::MicToggle => &self.config.custom_sound_mic_toggle,
Sound::ReconnectFailed => &self.config.custom_sound_reconnect_failed,
};
opt.as_deref().unwrap_or("")
}
}
impl Default for AppState {
fn default() -> Self {
let (ui_tx, ui_rx) = tokio::sync::mpsc::channel(100);
let controller = Arc::new(CoreController::new(ui_tx));
let _ = UI_RX.set(Mutex::new(Some(ui_rx)));
let mut config = AppConfig::load();
// The window opens at the restored size (see `run_gui`); clamp the
// persisted divider positions against THAT size, not a hardcoded default,
// so they stay valid for the window we're actually about to show.
let (ww, wh) = (config.window_width, config.window_height);
config.participants_width =
clamp_participants_width(config.participants_width, ww);
config.chat_height = clamp_chat_height(config.chat_height, wh);
config.controls_width = clamp_controls_width(config.controls_width, ww);
config.chat_drawer_width = clamp_chat_drawer_width(config.chat_drawer_width, ww);
notify::set_enabled(config.notifications_enabled);
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));
for (peer, settings) in &config.peer_eq {
if let Ok(id) = peer.parse::<EndpointId>() {
let _ = controller.send(CoreCommand::SetPeerEq(id, *settings));
}
}
for (peer, pan) in &config.peer_pan {
if let Ok(id) = peer.parse::<EndpointId>() {
let _ = controller.send(CoreCommand::SetPeerPan(id, *pan));
}
}
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);
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,
peers: HashMap::new(),
peer_volumes: HashMap::new(),
audio_levels: HashMap::new(),
locally_muted: HashSet::new(),
call_started: None,
recording: false,
recording_started: None,
chat_messages: Vec::new(),
chat_input: String::new(),
window_size: Size::new(ww, wh),
layout_picker_open: false,
hotkey_info_open: false,
pixelpass_help_open: false,
drawer_chat_open: false,
mic_level: 0.0,
mic_test_active: false,
connecting: HashSet::new(),
ever_connected: HashSet::new(),
controller,
current_screen: Screen::Home,
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<bytes::Bytes> {
let path = config.background.as_deref()?;
std::fs::read(path).ok().map(bytes::Bytes::from)
}
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<iced::window::Icon> {
const RGBA: &[u8] = include_bytes!("../../assets/icons/peerspeak-128.rgba");
iced::window::icon::from_rgba(RGBA.to_vec(), 128, 128).ok()
}
/// True when running under a Wayland compositor (winit will use its Wayland
/// backend). Mirrors winit's own selection: it prefers Wayland when
/// `WAYLAND_DISPLAY` is set, otherwise falls back to X11 via `DISPLAY`.
fn is_wayland() -> bool {
std::env::var_os("WAYLAND_DISPLAY").is_some_and(|v| !v.is_empty())
}
/// Decide the initial window position from the saved coordinates.
///
/// Position restore only works on **X11** — Wayland's xdg-shell gives clients no
/// way to place their own window, so we center there and let the compositor
/// decide. Returns `Centered` when we're on Wayland or have no saved position.
fn initial_window_position(
saved_x: Option<i32>,
saved_y: Option<i32>,
is_wayland: bool,
) -> iced::window::Position {
match (saved_x, saved_y) {
(Some(x), Some(y)) if !is_wayland => {
iced::window::Position::Specific(iced::Point::new(x as f32, y as f32))
}
_ => iced::window::Position::Centered,
}
}
fn subscription(_state: &AppState) -> Subscription<AppMessage> {
let core_sub = Subscription::run(core_subscription).map(AppMessage::UiEventReceived);
let event_sub = iced::event::listen().map(AppMessage::EventOccurred);
Subscription::batch(vec![core_sub, event_sub])
}
fn shutdown_timeout_task() -> Task<AppMessage> {
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<EndpointId>,
ever_connected: &HashSet<EndpointId>,
id: EndpointId,
) -> Option<Sound> {
let is_reconnect_attempt = ever_connected.contains(&id);
let was_already_connecting = connecting.contains(&id);
connecting.insert(id);
(is_reconnect_attempt && !was_already_connecting).then_some(Sound::ReconnectAttempt)
}
/// Reconnect-chime edge trigger for `UiEvent::PeerConnected`. Clears the connecting
/// state, records that we've linked with this peer at least once, and returns
/// `Some(Sound::Reconnected)` only if it had connected before (a true reconnect, not
/// the first link). Pure so the logic is unit-testable.
fn reconnected_chime(
connecting: &mut HashSet<EndpointId>,
ever_connected: &mut HashSet<EndpointId>,
id: EndpointId,
) -> Option<Sound> {
let was_reconnect = ever_connected.contains(&id);
connecting.remove(&id);
ever_connected.insert(id);
was_reconnect.then_some(Sound::Reconnected)
}
fn 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
}
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<AppMessage> {
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();
// 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();
// 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 {
let _ = state.controller.send(CoreCommand::StartScreenShare);
state.status_message = "Starting screen share…".to_string();
}
}
AppMessage::WatchShare(ticket) => {
let _ = state.controller.send(CoreCommand::ViewShare(ticket));
state.status_message = "Opening screen share…".to_string();
}
AppMessage::ToggleMutePressed => {
toggle_mute(state);
}
AppMessage::ToggleDeafenPressed => {
toggle_deafen(state);
}
AppMessage::UiEventReceived(event) => {
match event {
UiEvent::RoomJoined { ticket, self_id } => {
// 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.ticket = "".to_string();
state.peers.clear();
state.audio_levels.clear();
state.locally_muted.clear();
state.call_started = None;
state.recording = false;
state.recording_started = None;
state.chat_messages.clear();
state.chat_input.clear();
state.connecting.clear();
state.ever_connected.clear();
state.status_message = "Ready to connect".to_string();
state.current_screen = Screen::Home;
state.mic_level = 0.0;
state.self_sharing = false;
notify::play(Sound::SelfLeave, state.config.custom_sound_self_leave.as_deref());
}
UiEvent::PeerJoined { id, state: peer_state } => {
state.peers.insert(id, peer_state);
notify::play(Sound::PeerJoin, state.config.custom_sound_peer_join.as_deref());
}
UiEvent::PeerLeft { id } => {
state.peers.remove(&id);
state.audio_levels.remove(&id);
state.locally_muted.remove(&id);
state.connecting.remove(&id);
state.ever_connected.remove(&id);
notify::play(Sound::PeerLeave, state.config.custom_sound_peer_leave.as_deref());
}
// 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 } => {
// Incoming peer content is untrusted — sanitize name + text.
let text = sanitize_chat(&text);
if !text.is_empty() {
let name = sanitize_chat(&name);
push_chat(&mut state.chat_messages, ChatEntry {
name,
text,
mine: false,
from: Some(from),
});
}
}
UiEvent::ScreenShareStarted => {
state.self_sharing = true;
state.status_message = "Sharing your screen".to_string();
}
UiEvent::ScreenShareStopped => {
state.self_sharing = false;
state.status_message = "Screen share stopped".to_string();
}
UiEvent::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<EndpointId> = 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::ShutdownComplete => {
if state.closing {
return iced::exit();
}
}
UiEvent::Error(err) => {
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::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) => {
state.peer_volumes.insert(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::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::<EndpointId>() {
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();
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();
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::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::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()),
});
let _ = state.controller.send(CoreCommand::SendChat(text));
state.chat_input.clear();
}
}
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 <url>`.
// - Windows: `rundll32 url.dll,FileProtocolHandler <url>` — 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}")
}
}
/// First 8 characters of an id string for compact display. Panic-free: takes
/// chars (not a byte slice), so a short or non-ASCII id can never panic the
/// render (security finding S1) — ids are long ASCII hex today, but this guards
/// the slice regardless.
fn short_id(id: &str) -> String {
id.chars().take(8).collect()
}
/// Max characters kept for a single chat message after sanitizing.
const CHAT_MSG_MAX_CHARS: usize = 2000;
/// Sanitize a chat string for display, applied to BOTH our outgoing text and
/// incoming text from peers (peer input is untrusted — a buggy/malicious sender
/// could include control characters or an enormous payload). Drops control
/// characters (ANSI escapes, NUL, stray CR/LF/TAB), collapses any whitespace run
/// to a single space, trims the ends, and caps the length. Returns "" for input
/// that is empty after cleaning (the caller skips empty messages).
fn sanitize_chat(input: &str) -> String {
let no_control: String = input
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
let collapsed = no_control.split_whitespace().collect::<Vec<_>>().join(" ");
collapsed.chars().take(CHAT_MSG_MAX_CHARS).collect()
}
/// Append a chat line, trimming the oldest once history exceeds the cap so a long
/// call can't grow the buffer without bound.
fn push_chat(messages: &mut Vec<ChatEntry>, entry: ChatEntry) {
messages.push(entry);
if messages.len() > CHAT_HISTORY_MAX {
let overflow = messages.len() - CHAT_HISTORY_MAX;
messages.drain(..overflow);
}
}
fn horizontal_space() -> iced::widget::Space {
iced::widget::Space::new().width(iced::Length::Fill)
}
fn vertical_space(height: f32) -> iced::widget::Space {
iced::widget::Space::new().height(height)
}
/// 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),
text_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 <name>". Enter also creates.
let create_group = column![
text_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),
text_input("Paste room ticket here...", &state.ticket_input)
.on_input(AppMessage::TicketInputChanged)
.style(t_style)
.padding(10),
vertical_space(8.0),
button(text("Join Room").size(16).align_x(iced::alignment::Horizontal::Center))
.on_press(AppMessage::JoinPressed)
.style(b_style(color_surface, color_blue, color_text, 8.0))
.padding(12)
.width(iced::Length::Fill)
];
let status = text(&state.status_message).size(14).color(color_subtext);
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<AppMessage> = 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![
text_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<AppMessage> = 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<AppMessage> = 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![
text_input("Friend's node ID", &state.friend_add_id)
.on_input(AppMessage::FriendAddIdChanged)
.style(t_style)
.padding(6),
vertical_space(6.0),
row![
text_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),
text_input("Default (embedded)...", path)
.on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val))
.style(t_style)
.padding(8)
].spacing(4).width(iced::Length::Fill)
};
// Live mic level meter for gate calibration. Shares the gate slider's
// 0..0.1 scale so you can read your voice against the threshold directly.
// During a call the in-call meter feeds it; otherwise a "Test mic" toggle
// spins up a standalone capture stream.
let in_call = !state.ticket.is_empty();
let mic_test_control: Element<'_, AppMessage> = if in_call {
text("Live (in call)").size(11).color(color_green).into()
} else {
let (mic_kind, label, bg) = if state.mic_test_active {
(IconKind::Stop, "Stop mic test", color_red)
} else {
(IconKind::Mic, "Test mic", color_surface)
};
button(
row![
icon(mic_kind, 13.0, color_text),
text(label).size(12),
]
.spacing(5)
.align_y(iced::alignment::Vertical::Center)
)
.on_press(AppMessage::ToggleMicTest(!state.mic_test_active))
.style(b_style(bg, color_blue, color_text, 6.0))
.padding(6)
.into()
};
// Unified meter + draggable gate (Discord/OBS-style): the live mic level
// fills the bar and the yellow handle is the gate threshold, dragged
// directly on the same axis. Green fill = above the gate (transmitting),
// dim = below it (muted). Live status word reinforces the colour.
let gate_thresh = state.config.noise_gate_threshold;
let speaking = state.mic_level >= 0.001;
let passing = speaking && state.mic_level >= gate_thresh;
let (status_label, status_color) = if !speaking {
("○ Idle", color_subtext)
} else if passing {
("● Transmitting", color_green)
} else {
("● Muted by gate", color_red)
};
let gate_meter = Canvas::new(GateMeter {
level: state.mic_level,
threshold: gate_thresh,
track: color_crust,
border: color_surface,
fill_on: color_green,
fill_off: color_surface,
handle: Color::from_rgb8(255, 40, 40),
handle_edge: color_crust,
})
.width(iced::Length::Fill)
.height(iced::Length::Fixed(20.0));
let mic_meter = column![
gate_meter,
row![
text(status_label).size(12).color(status_color),
horizontal_space(),
text(format!("gate {:.1}%", gate_thresh * 100.0)).size(11).color(color_subtext),
horizontal_space(),
mic_test_control,
].align_y(iced::alignment::Vertical::Center).spacing(8),
].spacing(6).width(iced::Length::Fill);
// Inline room-layout chooser (Settings shows the thumbnails outright, no
// popup button). Same SelectRoomLayout message, applied live + persisted.
let layout_choice = |layout: RoomLayout, label: &'static str| -> Element<'_, AppMessage> {
let selected = state.config.room_layout == layout;
let tile = Canvas::new(LayoutThumb {
layout,
selected,
base: color_base,
surface: color_surface,
overlay: color_overlay,
border: if selected { color_blue } else { color_surface },
})
.width(iced::Length::Fixed(132.0))
.height(iced::Length::Fixed(86.0));
column![
button(tile)
.on_press(AppMessage::SelectRoomLayout(layout))
.padding(2)
.style(b_style(Color::TRANSPARENT, color_surface, color_text, 8.0)),
text(label).size(11).color(if selected { color_blue } else { color_subtext }),
]
.spacing(4)
.align_x(iced::alignment::Horizontal::Center)
.into()
};
// Inline theme chooser — a clickable palette-preview swatch per theme.
// Same SelectTheme message, applied live + persisted.
let theme_choice = |t: AppTheme| -> Element<'_, AppMessage> {
let selected = state.config.theme == t;
let tile = Canvas::new(ThemeSwatch {
palette: t.palette(),
selected,
border: if selected { color_blue } else { color_surface },
})
.width(iced::Length::Fixed(120.0))
.height(iced::Length::Fixed(64.0));
column![
button(tile)
.on_press(AppMessage::SelectTheme(t))
.padding(2)
.style(b_style(Color::TRANSPARENT, color_surface, color_text, 8.0)),
text(t.label())
.size(11)
.color(if selected { color_blue } else { color_subtext }),
]
.spacing(4)
.align_x(iced::alignment::Horizontal::Center)
.into()
};
// 10 themes laid out as two rows of five (no flex-wrap in iced 0.14).
let theme_row1: Vec<Element<'_, AppMessage>> =
AppTheme::ALL[0..5].iter().map(|&t| theme_choice(t)).collect();
let theme_row2: Vec<Element<'_, AppMessage>> =
AppTheme::ALL[5..].iter().map(|&t| theme_choice(t)).collect();
// Title comes from the "Theme" section header (added below), so this body
// is just the swatch rows + hint.
let theme_section = column![
iced::widget::Row::with_children(theme_row1).spacing(12),
iced::widget::Row::with_children(theme_row2).spacing(12),
text("Colour theme for the whole UI. Applies live.")
.size(11)
.color(color_subtext),
]
.spacing(10)
.width(iced::Length::Fill);
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<Element<'_, AppMessage>> = 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<Element<'_, AppMessage>> = Vec::new();
let mut tiles_iter = avatar_tiles.into_iter();
loop {
let chunk: Vec<Element<'_, AppMessage>> = 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<AppMessage> = 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 shows a short form (iced text isn't selectable) plus a Copy
// button that puts the FULL node id on the clipboard, so it's shareable.
let id_row: Element<AppMessage> = match state.self_node_id.clone() {
Some(full) => row![
text(format!("ID: {id_display}")).size(13).color(color_text),
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))
.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(),
};
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<AppMessage> =
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),
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),
// Live "you're sharing" badge — only present while sharing.
{
let el: Element<'_, AppMessage> = if state.self_sharing {
row![
icon(IconKind::Live, 14.0, color_red),
text("Sharing your screen").size(13).color(color_red),
]
.spacing(6)
.align_y(iced::alignment::Vertical::Center)
.into()
} else {
iced::widget::Space::new().width(0.0).height(0.0).into()
};
el
},
progress_bar(0.0..=0.3, state.mic_level)
.girth(8.0)
.style(move |_t: &Theme| iced::widget::progress_bar::Style {
background: Background::Color(color_crust),
bar: Background::Color(self_mic_color),
border: Border { color: color_surface, width: 1.0, radius: 4.0.into() },
}),
].spacing(8)
)
.style(c_style(color_base, color_surface, 6.0))
.padding(12);
peers_list = peers_list.push(self_card);
for (peer_id, peer) in &state.peers {
let level = state.audio_levels.get(peer_id).copied().unwrap_or(0.0);
let is_connecting = state.connecting.contains(peer_id);
let is_speaking = !is_connecting && level > 0.01;
let (ind_label, ind_color): (&str, Color) = if is_connecting {
let label = if state.ever_connected.contains(peer_id) {
"[Reconnecting…]"
} else {
"[Connecting…]"
};
(label, color_yellow)
} else if peer.is_muted {
("[Muted]", color_red)
} else if is_speaking {
("[Speaking]", color_green)
} else {
("[Idle]", color_subtext)
};
// Fixed-width, right-aligned slot so the label changing (e.g. Idle→
// Speaking) doesn't reflow the row and shift the mute button (A10).
// Width covers the longest label, "[Reconnecting…]".
let indicator = container(text(ind_label).size(14).color(ind_color))
.width(iced::Length::Fixed(124.0))
.align_x(iced::alignment::Horizontal::Right);
let peer_id_clone = *peer_id;
let is_locally_muted = state.locally_muted.contains(peer_id);
// Local-mute toggle (silences this peer for us only).
let (mute_kind, mute_bg, mute_fg) = if is_locally_muted {
(IconKind::SpeakerOff, color_red, color_crust)
} else {
(IconKind::Speaker, color_surface, color_text)
};
let mute_btn = button(icon(mute_kind, 16.0, mute_fg))
.on_press(AppMessage::TogglePeerMute(peer_id_clone))
.style(b_style(mute_bg, color_blue, mute_fg, 6.0))
.padding(6);
// Screen-share "Live" badge + Watch button when this peer is sharing.
// Watch is enabled only if pixelpass is installed locally.
let share_el: Element<'_, AppMessage> = if let Some(ticket) = peer.sharing.clone() {
let mut watch_btn = button(
row![
icon(IconKind::Eye, 14.0, color_crust),
text("Watch").size(13),
]
.spacing(5)
.align_y(iced::alignment::Vertical::Center),
)
.style(b_style(color_blue, color_lavender, color_crust, 6.0))
.padding(6);
// 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),
column![
text(&peer.name).size(16).color(color_text),
text(format!("ID: {}", short_id(&peer_id.to_string()))).size(11).color(color_subtext)
],
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.peer_volumes.get(peer_id).copied().unwrap_or(1.0);
card_content = card_content.push(
row![
text("Vol:").size(12).color(color_subtext),
slider(0.0..=2.0, current_vol, move |v| AppMessage::PeerVolumeChanged(peer_id_clone, v))
].spacing(8).align_y(iced::alignment::Vertical::Center)
);
let 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),
);
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 {
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 = rich_text(spans)
.on_link_click(AppMessage::OpenUrl)
.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),
);
}
}
let chat_scroll = scrollable(chat_col)
.width(iced::Length::Fill)
.height(iced::Length::Fill)
.anchor_bottom();
let chat_input_row = row![
text_input("Message the room…", &state.chat_input)
.on_input(AppMessage::ChatInputChanged)
.on_submit(AppMessage::ChatSubmit)
.style(t_style)
.padding(8),
button(text("Send").size(13))
.on_press(AppMessage::ChatSubmit)
.style(b_style(color_blue, color_lavender, color_crust, 6.0))
.padding(8),
]
.spacing(8)
.align_y(iced::alignment::Vertical::Center);
let chat_inner = column![
text("Chat").size(16).color(color_blue),
chat_scroll,
chat_input_row,
]
.spacing(8);
// Divider constructors (fresh widget per call).
let vdiv = |kind| {
Canvas::new(Divider { kind, vertical: true, line: color_surface, grip: color_lavender })
.width(iced::Length::Fixed(DIVIDER_THICKNESS))
.height(iced::Length::Fill)
};
let hdiv = || {
Canvas::new(Divider {
kind: DividerKind::Chat,
vertical: false,
line: color_surface,
grip: color_lavender,
})
.width(iced::Length::Fill)
.height(iced::Length::Fixed(DIVIDER_THICKNESS))
};
// Assemble the body per the chosen room layout. `chat_inner` is moved into
// exactly one arm (allowed across mutually-exclusive match arms).
let pw = state.config.participants_width;
let body: Element<'_, AppMessage> = match state.config.room_layout {
RoomLayout::BottomDock => {
// Cap Participants so the Fill Controls panel keeps its minimum.
let avail = state.window_size.width - 30.0;
let pwb = pw.min((avail - CONTROLS_MIN_W - DIVIDER_THICKNESS).max(PARTICIPANTS_MIN_W));
let main = row![
peers_panel.width(iced::Length::Fixed(pwb)),
vdiv(DividerKind::Panels),
control_panel.width(iced::Length::Fill),
]
.spacing(0)
.height(iced::Length::Fill);
let chat = container(chat_inner)
.style(c_style(color_mantle, color_surface, 8.0))
.padding(12)
.width(iced::Length::Fill)
.height(iced::Length::Fixed(state.config.chat_height));
column![main, hdiv(), chat].into()
}
RoomLayout::ThreeColumn => {
// Participants is fixed and Controls is fixed, so cap Participants
// (shared with the 2-panel layouts, where it's much wider) to leave
// the centre Chat column at least a minimum width.
let avail = state.window_size.width - 30.0; // outer padding
let pw3 = pw.min(
(avail - state.config.controls_width - CHAT_MIN_W - 2.0 * DIVIDER_THICKNESS)
.max(PARTICIPANTS_MIN_W),
);
let chat = container(chat_inner)
.style(c_style(color_mantle, color_surface, 8.0))
.padding(12)
.width(iced::Length::Fill)
.height(iced::Length::Fill);
row![
peers_panel.width(iced::Length::Fixed(pw3)),
vdiv(DividerKind::Panels),
chat,
vdiv(DividerKind::Controls),
control_panel.width(iced::Length::Fixed(state.config.controls_width)),
]
.spacing(0)
.height(iced::Length::Fill)
.into()
}
RoomLayout::Drawer => {
let avail = state.window_size.width - 30.0;
if state.drawer_chat_open {
// Participants + Chat drawer are both fixed; cap Participants so
// the Fill Controls panel between them keeps its minimum.
let pwd = pw.min(
(avail - state.config.chat_drawer_width - CONTROLS_MIN_W - 2.0 * DIVIDER_THICKNESS)
.max(PARTICIPANTS_MIN_W),
);
let chat = container(chat_inner)
.style(c_style(color_mantle, color_surface, 8.0))
.padding(12)
.width(iced::Length::Fixed(state.config.chat_drawer_width))
.height(iced::Length::Fill);
row![
peers_panel.width(iced::Length::Fixed(pwd)),
vdiv(DividerKind::Panels),
control_panel.width(iced::Length::Fill),
vdiv(DividerKind::ChatDrawer),
chat,
]
.spacing(0)
.height(iced::Length::Fill)
.into()
} else {
let pwd = pw.min((avail - CONTROLS_MIN_W - DIVIDER_THICKNESS).max(PARTICIPANTS_MIN_W));
row![
peers_panel.width(iced::Length::Fixed(pwd)),
vdiv(DividerKind::Panels),
control_panel.width(iced::Length::Fill),
]
.spacing(0)
.height(iced::Length::Fill)
.into()
}
}
};
let room = container(
column![top_bar, header_container, vertical_space(12.0), body]
)
.padding(15)
.width(iced::Length::Fill)
.height(iced::Length::Fill)
.style(c_style(root_bg, Color::TRANSPARENT, 0.0));
with_hotkey_info(
with_pixelpass_help(with_layout_picker(room.into(), 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<AppMessage> for GateMeter {
type State = GateMeterState;
fn update(
&self,
state: &mut Self::State,
event: &Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<AppMessage>> {
match event {
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
if let Some(p) = cursor.position_in(bounds) {
state.dragging = true;
let t = Self::x_to_threshold(p.x, bounds.width);
return Some(Action::publish(AppMessage::NoiseGateDragging(t)).and_capture());
}
}
// Track moves anywhere on screen so the drag survives leaving the bar.
Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => {
if let Some(p) = cursor.position() {
let t = Self::x_to_threshold(p.x - bounds.x, bounds.width);
return Some(Action::publish(AppMessage::NoiseGateDragging(t)).and_capture());
}
}
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) if state.dragging => {
state.dragging = false;
let x = cursor.position().map(|p| p.x - bounds.x).unwrap_or(0.0);
let t = Self::x_to_threshold(x, bounds.width);
// Persist the final value on release.
return Some(Action::publish(AppMessage::NoiseGateChanged(t)).and_capture());
}
_ => {}
}
None
}
fn draw(
&self,
_state: &Self::State,
renderer: &Renderer,
_theme: &Theme,
bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Vec<Geometry> {
let mut frame = Frame::new(renderer, bounds.size());
let w = bounds.width;
let h = bounds.height;
// Track.
frame.fill_rectangle(Point::ORIGIN, Size::new(w, h), self.track);
// Level fill, coloured by whether we're above the gate.
let level_frac = (self.level / METER_MAX).clamp(0.0, 1.0);
let fill = if self.level >= self.threshold { self.fill_on } else { self.fill_off };
if level_frac > 0.0 {
frame.fill_rectangle(Point::ORIGIN, Size::new(w * level_frac, h), fill);
}
// Gate handle: a bright vertical line + grip caps, each backed by a dark
// edge so the handle stays legible even when the green level sweeps past it.
let thr_frac = (self.threshold / METER_MAX).clamp(0.0, 1.0);
let x = (w * thr_frac).clamp(3.0, (w - 3.0).max(3.0));
// Dark edge (slightly larger), then bright core.
frame.fill(&Path::rectangle(Point::new(x - 3.0, 0.0), Size::new(6.0, h)), self.handle_edge);
frame.fill(&Path::rectangle(Point::new(x - 1.0, 0.0), Size::new(2.0, h)), self.handle);
// Grip caps top and bottom.
frame.fill(&Path::rectangle(Point::new(x - 5.5, 0.0), Size::new(11.0, 6.0)), self.handle_edge);
frame.fill(&Path::rectangle(Point::new(x - 4.0, 1.0), Size::new(8.0, 4.0)), self.handle);
frame.fill(&Path::rectangle(Point::new(x - 5.5, h - 6.0), Size::new(11.0, 6.0)), self.handle_edge);
frame.fill(&Path::rectangle(Point::new(x - 4.0, h - 5.0), Size::new(8.0, 4.0)), self.handle);
// Border.
frame.stroke(
&Path::rectangle(Point::ORIGIN, Size::new(w, h)),
canvas::Stroke::default().with_color(self.border).with_width(1.0),
);
vec![frame.into_geometry()]
}
fn mouse_interaction(
&self,
state: &Self::State,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> mouse::Interaction {
if state.dragging || cursor.is_over(bounds) {
mouse::Interaction::ResizingHorizontally
} else {
mouse::Interaction::default()
}
}
}
/// A draggable splitter between two panels. Reports drag motion along its axis as
/// `AppMessage::DividerDragged(kind, delta_px)`; the parent applies + clamps it.
/// `vertical` = a vertical bar dragged horizontally (resizes width); otherwise a
/// horizontal bar dragged vertically (resizes height). Modeled on [`GateMeter`]'s
/// drag handling: the drag is tracked off the global cursor so it survives the
/// pointer leaving the thin divider strip.
struct Divider {
kind: DividerKind,
/// True = vertical bar (horizontal drag); false = horizontal bar (vertical drag).
vertical: bool,
/// Centre line colour.
line: Color,
/// Grip-dot colour.
grip: Color,
}
/// Drag state: the last cursor coordinate along the drag axis while dragging.
#[derive(Default)]
struct DividerState {
last: Option<f32>,
}
impl Program<AppMessage> for Divider {
type State = DividerState;
fn update(
&self,
state: &mut Self::State,
event: &Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<AppMessage>> {
// Cursor coordinate along the drag axis (x for a vertical bar, else y).
let axis = |p: Point| if self.vertical { p.x } else { p.y };
match event {
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
if cursor.is_over(bounds)
&& let Some(p) = cursor.position()
{
// Record the absolute start coordinate; subsequent moves yield
// deltas. Capture with a zero-delta no-op.
state.last = Some(axis(p));
return Some(
Action::publish(AppMessage::DividerDragged(self.kind, 0.0)).and_capture(),
);
}
}
// Track globally so the drag continues past the thin strip's bounds.
Event::Mouse(mouse::Event::CursorMoved { .. }) if state.last.is_some() => {
if let Some(p) = cursor.position() {
let cur = axis(p);
let last = state.last.unwrap();
state.last = Some(cur);
return Some(
Action::publish(AppMessage::DividerDragged(self.kind, cur - last))
.and_capture(),
);
}
}
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
if state.last.is_some() =>
{
state.last = None;
// Persist the final position once, on release (not per pixel).
return Some(Action::publish(AppMessage::PersistConfig).and_capture());
}
_ => {}
}
None
}
fn draw(
&self,
_state: &Self::State,
renderer: &Renderer,
_theme: &Theme,
bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Vec<Geometry> {
let mut frame = Frame::new(renderer, bounds.size());
let w = bounds.width;
let h = bounds.height;
if self.vertical {
let x = w / 2.0;
frame.fill(&Path::rectangle(Point::new(x - 1.0, 0.0), Size::new(2.0, h)), self.line);
let cy = h / 2.0;
for i in -1..=1 {
let dy = cy + i as f32 * 6.0;
frame.fill(
&Path::rectangle(Point::new(x - 1.5, dy - 1.5), Size::new(3.0, 3.0)),
self.grip,
);
}
} else {
let y = h / 2.0;
frame.fill(&Path::rectangle(Point::new(0.0, y - 1.0), Size::new(w, 2.0)), self.line);
let cx = w / 2.0;
for i in -1..=1 {
let dx = cx + i as f32 * 6.0;
frame.fill(
&Path::rectangle(Point::new(dx - 1.5, y - 1.5), Size::new(3.0, 3.0)),
self.grip,
);
}
}
vec![frame.into_geometry()]
}
fn mouse_interaction(
&self,
state: &Self::State,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> mouse::Interaction {
if state.last.is_some() || cursor.is_over(bounds) {
if self.vertical {
mouse::Interaction::ResizingHorizontally
} else {
mouse::Interaction::ResizingVertically
}
} else {
mouse::Interaction::default()
}
}
}
/// Wrap a base screen with the room-layout picker popup when it's open: a dimmed,
/// click-to-dismiss backdrop plus a centered gallery of clickable layout
/// thumbnails. Returns the base unchanged when the picker is closed. Used by both
/// the launch and in-call screens (the Settings screen shows thumbnails inline).
fn with_layout_picker<'a>(
base: Element<'a, AppMessage>,
state: &'a AppState,
) -> Element<'a, AppMessage> {
if !state.layout_picker_open {
return base;
}
let pal = state.config.theme.palette();
let crust = pal.crust;
let mantle = pal.mantle;
let base_c = pal.base;
let surface = pal.surface;
let overlay = pal.overlay;
let text_c = pal.text;
let subtext = pal.subtext;
let blue = pal.blue;
let green = pal.green;
let backdrop = mouse_area(
container(horizontal_space())
.width(iced::Length::Fill)
.height(iced::Length::Fill)
.style(move |_t: &Theme| container::Style {
background: Some(Background::Color(Color { a: 0.55, ..crust })),
..Default::default()
}),
)
.on_press(AppMessage::CloseLayoutPicker);
let thumb = |layout: RoomLayout, label: &'static str| -> Element<'a, AppMessage> {
let selected = state.config.room_layout == layout;
let tile = Canvas::new(LayoutThumb {
layout,
selected,
base: base_c,
surface,
overlay,
border: if selected { blue } else { surface },
})
.width(iced::Length::Fixed(168.0))
.height(iced::Length::Fixed(112.0));
let btn = button(tile)
.on_press(AppMessage::SelectRoomLayout(layout))
.padding(2)
.style(move |_t: &Theme, status: button::Status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered => surface,
_ => Color::TRANSPARENT,
})),
text_color: text_c,
border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 8.0.into() },
..Default::default()
});
let (lbl_color, marker): (Color, Element<'a, AppMessage>) = if selected {
(blue, text("● current").size(10).color(green).into())
} else {
(text_c, vertical_space(0.0).into())
};
column![btn, text(label).size(13).color(lbl_color), marker]
.spacing(4)
.align_x(iced::alignment::Horizontal::Center)
.into()
};
let gallery = container(
column![
row![
text("Choose room layout").size(16).color(blue),
horizontal_space(),
button(text("✕").size(16).color(subtext))
.on_press(AppMessage::CloseLayoutPicker)
.style(|_t: &Theme, _s: button::Status| button::Style {
background: None,
..Default::default()
})
.padding(2),
]
.align_y(iced::alignment::Vertical::Center),
row![
thumb(RoomLayout::ThreeColumn, "3-Column"),
thumb(RoomLayout::BottomDock, "Bottom Dock"),
thumb(RoomLayout::Drawer, "Drawer"),
]
.spacing(20),
text("Click a layout to apply it instantly.").size(11).color(subtext),
]
.spacing(16),
)
.style(move |_t: &Theme| container::Style {
text_color: Some(text_c),
background: Some(Background::Color(mantle)),
border: Border { color: surface, width: 1.0, radius: 12.0.into() },
..Default::default()
})
.padding(20)
.width(iced::Length::Fixed(600.0));
stack![
base,
backdrop,
container(gallery)
.center_x(iced::Length::Fill)
.center_y(iced::Length::Fill),
]
.into()
}
/// 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 "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<AppMessage> for LayoutIcon {
type State = ();
fn draw(
&self,
_state: &(),
renderer: &Renderer,
_theme: &Theme,
bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Vec<Geometry> {
let mut f = Frame::new(renderer, bounds.size());
let (w, h) = (bounds.width, bounds.height);
f.stroke(
&Path::rectangle(Point::new(1.0, 1.5), Size::new(w - 2.0, h - 3.0)),
canvas::Stroke::default().with_color(self.fg).with_width(1.5),
);
// Vertical split into two panes.
f.fill(
&Path::rectangle(Point::new(w * 0.5 - 0.75, 1.5), Size::new(1.5, h - 3.0)),
self.fg,
);
vec![f.into_geometry()]
}
}
/// A schematic thumbnail of a [`RoomLayout`] (colored boxes for each panel),
/// drawn so the picker stays in sync with the theme and needs no image assets.
struct LayoutThumb {
layout: RoomLayout,
selected: bool,
base: Color,
surface: Color,
/// Accent shade for the chat panel, to distinguish it from the others.
overlay: Color,
/// Border colour (blue when selected, surface otherwise).
border: Color,
}
impl LayoutThumb {
fn pane(f: &mut Frame, x: f32, y: f32, w: f32, h: f32, color: Color) {
f.fill(&Path::rectangle(Point::new(x, y), Size::new(w, h)), color);
}
}
impl Program<AppMessage> for LayoutThumb {
type State = ();
fn draw(
&self,
_state: &(),
renderer: &Renderer,
_theme: &Theme,
bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Vec<Geometry> {
let mut f = Frame::new(renderer, bounds.size());
let (w, h) = (bounds.width, bounds.height);
f.fill(&Path::rectangle(Point::ORIGIN, Size::new(w, h)), self.base);
let pad = 9.0;
let (ix, iy) = (pad, pad);
let (iw, ih) = (w - 2.0 * pad, h - 2.0 * pad);
let g = 4.0;
match self.layout {
RoomLayout::ThreeColumn => {
let c1 = iw * 0.34;
let c3 = iw * 0.24;
let c2 = iw - c1 - c3 - 2.0 * g;
Self::pane(&mut f, ix, iy, c1, ih, self.surface);
Self::pane(&mut f, ix + c1 + g, iy, c2, ih, self.overlay);
Self::pane(&mut f, ix + c1 + g + c2 + g, iy, c3, ih, self.surface);
}
RoomLayout::BottomDock => {
let toph = ih * 0.6;
let both = ih - toph - g;
let lw = iw * 0.66;
Self::pane(&mut f, ix, iy, lw, toph, self.surface);
Self::pane(&mut f, ix + lw + g, iy, iw - lw - g, toph, self.surface);
Self::pane(&mut f, ix, iy + toph + g, iw, both, self.overlay);
}
RoomLayout::Drawer => {
let dw = iw * 0.18;
let main = iw - dw - g;
let lw = main * 0.62;
Self::pane(&mut f, ix, iy, lw, ih, self.surface);
Self::pane(&mut f, ix + lw + g, iy, main - lw - g, ih, self.surface);
Self::pane(&mut f, ix + main + g, iy, dw, ih, self.overlay);
}
}
// Border on top (thicker + blue when selected).
let bw: f32 = if self.selected { 2.0 } else { 1.0 };
f.stroke(
&Path::rectangle(Point::new(bw / 2.0, bw / 2.0), Size::new(w - bw, h - bw)),
canvas::Stroke::default().with_color(self.border).with_width(bw),
);
vec![f.into_geometry()]
}
}
/// A small palette-preview tile for the theme picker (modeled on `LayoutThumb`):
/// the theme's background, a surface panel with two "text" lines to preview
/// legibility, and a stack of accent colours. Border highlights the selection.
struct ThemeSwatch {
palette: Palette,
selected: bool,
border: Color,
}
impl Program<AppMessage> for ThemeSwatch {
type State = ();
fn draw(
&self,
_state: &(),
renderer: &Renderer,
_theme: &Theme,
bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Vec<Geometry> {
let mut f = Frame::new(renderer, bounds.size());
let (w, h) = (bounds.width, bounds.height);
let p = &self.palette;
let pad = 7.0;
// Background fill.
f.fill(&Path::rectangle(Point::ORIGIN, Size::new(w, h)), p.base);
// Surface panel on the left half, with two text-colour lines on it.
let panel_w = (w - 2.0 * pad) * 0.52;
f.fill(
&Path::rectangle(Point::new(pad, pad), Size::new(panel_w, h - 2.0 * pad)),
p.surface,
);
let mut line = |y: f32, ww: f32, col: Color| {
f.fill(
&Path::rectangle(Point::new(pad + 6.0, y), Size::new(ww, 3.0)),
col,
);
};
line(pad + 9.0, panel_w * 0.66, p.text);
line(pad + 17.0, panel_w * 0.48, p.subtext);
// Accent swatches stacked on the right.
let ax = pad + panel_w + 6.0;
let aw = (w - pad - ax).max(2.0);
let accents = [p.blue, p.green, p.yellow, p.red, p.lavender];
let gap = 3.0;
let ah = ((h - 2.0 * pad) - gap * (accents.len() as f32 - 1.0)) / accents.len() as f32;
for (i, c) in accents.iter().enumerate() {
let ay = pad + i as f32 * (ah + gap);
f.fill(&Path::rectangle(Point::new(ax, ay), Size::new(aw, ah)), *c);
}
// Border (thicker + accent when selected).
let bw: f32 = if self.selected { 2.0 } else { 1.0 };
f.stroke(
&Path::rectangle(Point::new(bw / 2.0, bw / 2.0), Size::new(w - bw, h - bw)),
canvas::Stroke::default().with_color(self.border).with_width(bw),
);
vec![f.into_geometry()]
}
}
/// The PeerSpeak icon set, drawn on a `canvas` so it needs no image/font asset
/// and recolors with the theme (consistent with `LayoutThumb`/`GateMeter`).
/// Each icon is authored in a 24×24 space and scaled to the widget size.
#[derive(Clone, Copy, PartialEq)]
enum IconKind {
Mic,
MicOff,
Headphones,
Deafen,
Speaker,
SpeakerOff,
Monitor,
Eye,
Record,
Stop,
Chat,
People,
Clock,
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 23) 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()
}
thread_local! {
/// Cache of avatar image handles, keyed by a hash of the PNG bytes, 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. Lives on the (single) UI thread.
static AVATAR_HANDLE_CACHE: std::cell::RefCell<HashMap<u64, iced::widget::image::Handle>> =
std::cell::RefCell::new(HashMap::new());
}
/// A stable image handle for these exact PNG bytes (cached by content hash), so
/// it keeps the same id across redraws — see [`AVATAR_HANDLE_CACHE`].
fn cached_image_handle(bytes: bytes::Bytes) -> iced::widget::image::Handle {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
bytes.as_ref().hash(&mut hasher);
let key = hasher.finish();
AVATAR_HANDLE_CACHE.with(|cache| {
cache
.borrow_mut()
.entry(key)
.or_insert_with(|| 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<bytes::Bytes> = 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<AppMessage> for Icon {
type State = ();
fn draw(
&self,
_state: &(),
renderer: &Renderer,
_theme: &Theme,
bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Vec<Geometry> {
use std::f32::consts::PI;
let mut f = Frame::new(renderer, bounds.size());
let s = bounds.width.min(bounds.height) / 24.0;
let col = self.color;
let sw = (self.weight * s).max(1.0);
let p = |x: f32, y: f32| Point::new(x * s, y * s);
let stk = || {
canvas::Stroke::default()
.with_color(col)
.with_width(sw)
.with_line_cap(canvas::LineCap::Round)
.with_line_join(canvas::LineJoin::Round)
};
// A stroked polyline (or polygon when `closed`).
let poly = |pts: &[(f32, f32)], closed: bool| {
Path::new(|b| {
for (i, q) in pts.iter().enumerate() {
let pt = p(q.0, q.1);
if i == 0 {
b.move_to(pt);
} else {
b.line_to(pt);
}
}
if closed {
b.close();
}
})
};
// A stroked circular arc from a0..a1 radians (0 = +x, sweeps toward +y).
let arcp = |cx: f32, cy: f32, r: f32, a0: f32, a1: f32| {
Path::new(|b| {
b.arc(iced::widget::canvas::path::Arc {
center: p(cx, cy),
radius: r * s,
start_angle: iced::Radians(a0),
end_angle: iced::Radians(a1),
});
})
};
let rrect = |x: f32, y: f32, w: f32, h: f32, r: f32| {
Path::rounded_rectangle(p(x, y), Size::new(w * s, h * s), (r * s).into())
};
match self.kind {
IconKind::Mic => {
f.stroke(&rrect(9.0, 2.5, 6.0, 11.0, 3.0), stk());
f.stroke(&arcp(12.0, 11.0, 6.5, 0.0, PI), stk());
f.stroke(&poly(&[(12.0, 17.5), (12.0, 21.0)], false), stk());
f.stroke(&poly(&[(8.5, 21.0), (15.5, 21.0)], false), stk());
}
IconKind::MicOff => {
f.stroke(&rrect(9.0, 2.5, 6.0, 11.0, 3.0), stk());
f.stroke(&arcp(12.0, 11.0, 6.5, 0.0, PI), stk());
f.stroke(&poly(&[(12.0, 17.5), (12.0, 21.0)], false), stk());
f.stroke(&poly(&[(8.5, 21.0), (15.5, 21.0)], false), stk());
f.stroke(&poly(&[(3.5, 3.5), (20.5, 20.5)], false), stk());
}
IconKind::Headphones => {
f.stroke(&arcp(12.0, 12.5, 8.0, PI, 2.0 * PI), stk());
f.stroke(&rrect(3.0, 13.0, 4.5, 7.0, 2.25), stk());
f.stroke(&rrect(16.5, 13.0, 4.5, 7.0, 2.25), stk());
}
IconKind::Deafen => {
f.stroke(&arcp(12.0, 12.5, 8.0, PI, 2.0 * PI), stk());
f.stroke(&rrect(3.0, 13.0, 4.5, 7.0, 2.25), stk());
f.stroke(&rrect(16.5, 13.0, 4.5, 7.0, 2.25), stk());
f.stroke(&poly(&[(3.0, 3.0), (21.0, 21.0)], false), stk());
}
IconKind::Speaker => {
f.stroke(
&poly(
&[
(4.0, 9.5),
(7.5, 9.5),
(13.0, 5.0),
(13.0, 19.0),
(7.5, 14.5),
(4.0, 14.5),
],
true,
),
stk(),
);
f.stroke(&arcp(14.0, 12.0, 4.0, -0.5, 0.5), stk());
f.stroke(&arcp(14.0, 12.0, 7.5, -0.65, 0.65), stk());
}
IconKind::SpeakerOff => {
f.stroke(
&poly(
&[
(4.0, 9.5),
(7.5, 9.5),
(13.0, 5.0),
(13.0, 19.0),
(7.5, 14.5),
(4.0, 14.5),
],
true,
),
stk(),
);
f.stroke(&poly(&[(16.5, 9.5), (21.5, 14.5)], false), stk());
f.stroke(&poly(&[(21.5, 9.5), (16.5, 14.5)], false), stk());
}
IconKind::Monitor => {
f.stroke(&rrect(3.0, 4.0, 18.0, 12.0, 2.0), stk());
f.stroke(&poly(&[(9.0, 20.0), (15.0, 20.0)], false), stk());
f.stroke(&poly(&[(12.0, 16.0), (12.0, 20.0)], false), stk());
// up-arrow inside (the "share" cue)
f.stroke(&poly(&[(12.0, 13.0), (12.0, 7.5)], false), stk());
f.stroke(&poly(&[(9.5, 10.0), (12.0, 7.5), (14.5, 10.0)], false), stk());
}
IconKind::Eye => {
f.stroke(
&poly(
&[
(2.5, 12.0),
(6.0, 8.5),
(12.0, 7.0),
(18.0, 8.5),
(21.5, 12.0),
(18.0, 15.5),
(12.0, 17.0),
(6.0, 15.5),
],
true,
),
stk(),
);
f.stroke(&Path::circle(p(12.0, 12.0), 3.0 * s), stk());
}
IconKind::Record => {
f.fill(&Path::circle(p(12.0, 12.0), 6.0 * s), col);
}
IconKind::Stop => {
f.fill(&rrect(6.0, 6.0, 12.0, 12.0, 2.5), col);
}
IconKind::Chat => {
f.stroke(&rrect(3.0, 4.0, 18.0, 12.0, 3.0), stk());
f.stroke(&poly(&[(8.0, 16.0), (8.0, 20.5), (12.5, 16.0)], false), stk());
}
IconKind::People => {
f.stroke(&Path::circle(p(9.0, 9.0), 3.2 * s), stk());
f.stroke(&arcp(9.0, 20.0, 5.3, PI, 2.0 * PI), stk());
f.stroke(&Path::circle(p(16.5, 8.0), 2.7 * s), stk());
f.stroke(&arcp(16.5, 20.0, 4.6, PI, 2.0 * PI), stk());
}
IconKind::Clock => {
f.stroke(&Path::circle(p(12.0, 12.0), 8.5 * s), stk());
f.stroke(&poly(&[(12.0, 7.0), (12.0, 12.0)], false), stk());
f.stroke(&poly(&[(12.0, 12.0), (15.5, 14.0)], false), stk());
}
IconKind::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::{
format_duration, initial_window_position, reconnect_attempt_chime, reconnected_chime,
GateMeter, METER_MAX,
};
#[test]
fn x11_restores_saved_window_position() {
// On X11 (is_wayland = false) a saved position becomes Specific(x, y).
match initial_window_position(Some(120), Some(-40), false) {
iced::window::Position::Specific(p) => {
assert_eq!(p.x, 120.0);
assert_eq!(p.y, -40.0); // negative coords (left/above primary) preserved
}
other => panic!("expected Specific, got {other:?}"),
}
}
#[test]
fn wayland_always_centers_even_with_saved_position() {
assert!(matches!(
initial_window_position(Some(120), Some(40), true),
iced::window::Position::Centered
));
}
#[test]
fn missing_or_partial_saved_position_centers() {
assert!(matches!(
initial_window_position(None, None, false),
iced::window::Position::Centered
));
// A half-saved position (one axis missing) is not enough to restore.
assert!(matches!(
initial_window_position(Some(10), None, false),
iced::window::Position::Centered
));
assert!(matches!(
initial_window_position(None, Some(10), false),
iced::window::Position::Centered
));
}
#[test]
fn format_duration_renders_mss_and_hmmss() {
assert_eq!(format_duration(0), "0:00");
assert_eq!(format_duration(5), "0:05");
assert_eq!(format_duration(59), "0:59");
assert_eq!(format_duration(65), "1:05");
assert_eq!(format_duration(600), "10:00");
assert_eq!(format_duration(3599), "59:59");
// Past an hour switches to h:mm:ss with zero-padded minutes/seconds.
assert_eq!(format_duration(3600), "1:00:00");
assert_eq!(format_duration(3661), "1:01:01");
assert_eq!(format_duration(3725), "1:02:05");
}
#[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"]
);
assert_eq!(SettingsCategory::Audio.hint(), "Devices, mic gate, echo");
assert_eq!(SettingsCategory::Profile.hint(), "Avatar and identity");
}
#[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,
};
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,
},
);
}
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,
},
);
}
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));
}
}