Files
peerspeak/src/app/mod.rs
T

4499 lines
188 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::core::{CoreController, messages::{CoreCommand, UiEvent}};
use crate::network::PeerState;
use crate::notify::{self, Sound};
use crate::audio::pw_cli::{AudioDevice, enumerate_audio_devices};
use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout};
use crate::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,
Recording,
Profile,
Appearance,
Network,
Notifications,
}
impl SettingsCategory {
const ALL: [SettingsCategory; 6] = [
SettingsCategory::Audio,
SettingsCategory::Recording,
SettingsCategory::Profile,
SettingsCategory::Appearance,
SettingsCategory::Network,
SettingsCategory::Notifications,
];
fn label(self) -> &'static str {
match self {
SettingsCategory::Audio => "Audio",
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::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())
}
}
/// 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,
}
/// Minimum width of the Participants panel (px).
const PARTICIPANTS_MIN_W: f32 = 200.0;
/// Minimum width reserved for the Controls panel when resizing Participants (px).
const CONTROLS_MIN_W: f32 = 220.0;
/// Minimum height of the Chat dock (px).
const CHAT_MIN_H: f32 = 110.0;
/// Minimum height reserved above the Chat dock (header + main row) when resizing
/// the dock (px).
const ABOVE_CHAT_MIN_H: f32 = 300.0;
/// Thickness of a draggable divider (px).
const DIVIDER_THICKNESS: f32 = 8.0;
/// Clamp the Participants panel width so neither it nor the Controls panel drops
/// below its minimum, given the current window width.
fn clamp_participants_width(width: f32, window_w: f32) -> f32 {
let max = (window_w - CONTROLS_MIN_W).max(PARTICIPANTS_MIN_W);
width.clamp(PARTICIPANTS_MIN_W, max)
}
/// Clamp the Chat dock height so neither it nor the area above it drops below its
/// minimum, given the current window height.
fn clamp_chat_height(height: f32, window_h: f32) -> f32 {
let max = (window_h - ABOVE_CHAT_MIN_H).max(CHAT_MIN_H);
height.clamp(CHAT_MIN_H, max)
}
/// Minimum width of the Chat column / drawer (px).
const CHAT_MIN_W: f32 = 200.0;
/// Clamp the Controls panel width (3-column layout) so neither it nor the rest of
/// the row drops below its minimum, given the current window width.
fn clamp_controls_width(width: f32, window_w: f32) -> f32 {
// Leave room for the Participants panel + a minimum Chat column.
let max = (window_w - PARTICIPANTS_MIN_W - CHAT_MIN_W).max(CONTROLS_MIN_W);
width.clamp(CONTROLS_MIN_W, max)
}
/// Clamp the Chat drawer width (drawer layout) so neither it nor the rest of the
/// row drops below its minimum, given the current window width.
fn clamp_chat_drawer_width(width: f32, window_w: f32) -> f32 {
let max = (window_w - PARTICIPANTS_MIN_W - CONTROLS_MIN_W).max(CHAT_MIN_W);
width.clamp(CHAT_MIN_W, max)
}
#[derive(Debug, Clone)]
pub enum AppMessage {
NicknameChanged(String),
TicketInputChanged(String),
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),
StartSettingHotkey,
PeerVolumeChanged(EndpointId, f32),
/// Toggle local mute of a peer (silence them just for us).
TogglePeerMute(EndpointId),
InputDeviceSelected(AudioDevice),
OutputDeviceSelected(AudioDevice),
/// Live input-gain drag (applies immediately, persisted on release).
InputVolumeChanged(f32),
/// Live output-gain drag (applies immediately, persisted on release).
OutputVolumeChanged(f32),
/// Persist the current config to disk (slider release).
PersistConfig,
NoiseGateChanged(f32),
/// Live value while dragging the gate handle on the meter — updates the gate
/// immediately but does not persist (saved once on release via NoiseGateChanged).
NoiseGateDragging(f32),
NetworkModeSelected(NetworkMode),
RecordingModeSelected(RecordingMode),
/// 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 "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>>),
/// Toggle the Chat drawer open/closed (drawer layout).
ToggleDrawerChat,
/// Start/stop sharing our own screen (spawns/kills a pixelpass host).
ToggleScreenShare,
/// Watch a peer's screen share, identified by their pixelpass ticket.
WatchShare(String),
}
fn core_subscription() -> impl iced::futures::Stream<Item = UiEvent> {
iced::stream::channel(100, |mut output: iced::futures::channel::mpsc::Sender<UiEvent>| async move {
if let Some(rx_lock) = UI_RX.get() {
let mut guard = rx_lock.lock().await;
if let Some(mut rx) = guard.take() {
use iced::futures::sink::SinkExt;
while let Some(event) = rx.recv().await {
let _ = output.send(event).await;
}
}
}
})
}
pub struct AppState {
name: String,
ticket_input: String,
/// 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,
ptt_hotkey: keyboard::Key,
is_setting_hotkey: bool,
input_devices: Vec<AudioDevice>,
output_devices: Vec<AudioDevice>,
selected_input: Option<AudioDevice>,
selected_output: Option<AudioDevice>,
config: AppConfig,
peers: HashMap<EndpointId, PeerState>,
peer_volumes: HashMap<EndpointId, f32>,
audio_levels: HashMap<EndpointId, f32>,
/// Peers we've locally muted (their audio isn't mixed into our output).
locally_muted: HashSet<EndpointId>,
/// When we joined the current room, for the in-room call-duration timer.
call_started: Option<std::time::Instant>,
/// Whether a local call recording is in progress (confirmed by the core).
recording: bool,
/// When the current recording started, for the header REC timer.
recording_started: Option<std::time::Instant>,
/// Room text-chat history (newest last) and the pending input line.
chat_messages: Vec<ChatEntry>,
chat_input: String,
/// Last known window size, tracked so divider clamps stay valid on resize.
/// (The divider positions themselves are persisted in `config`.)
window_size: Size,
/// Whether the room-layout picker popup is open (launch + in-call screens).
layout_picker_open: bool,
/// Whether the 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>,
}
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));
let pixelpass_available =
crate::screenshare::is_available(config.pixelpass_path.as_deref());
let all_devices = enumerate_audio_devices();
let input_devices: Vec<_> = all_devices.iter().filter(|d| d.is_input).cloned().collect();
let output_devices: Vec<_> = all_devices.iter().filter(|d| !d.is_input).cloned().collect();
let selected_input = input_devices.iter().find(|d| d.name == config.input_device).cloned();
let selected_output = output_devices.iter().find(|d| d.name == config.output_device).cloned();
Self {
// Pre-fill the nickname with the last one used (or "Peer" by default).
name: config.username.clone(),
ticket_input: "".to_string(),
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,
ptt_hotkey: keyboard::Key::Named(keyboard::key::Named::Space),
is_setting_hotkey: false,
input_devices,
output_devices,
selected_input,
selected_output,
config,
peers: HashMap::new(),
peer_volumes: HashMap::new(),
audio_levels: HashMap::new(),
locally_muted: HashSet::new(),
call_started: None,
recording: false,
recording_started: None,
chat_messages: Vec::new(),
chat_input: String::new(),
window_size: Size::new(ww, wh),
layout_picker_open: false,
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,
}
}
}
fn theme(state: &AppState) -> Theme {
state.config.theme.base_theme()
}
pub fn run_gui() -> iced::Result {
// Restore the last window size (saved on close). Position is restored too,
// but only on X11 — Wayland's xdg-shell gives clients no way to set their own
// position, so we center there and leave placement to the compositor.
let saved = AppConfig::load();
let init_size = iced::Size::new(saved.window_width, saved.window_height);
let init_position = initial_window_position(saved.window_x, saved.window_y, is_wayland());
iced::application(AppState::default, update, view)
.title("PeerSpeak P2P Voice Chat")
.theme(theme)
.subscription(subscription)
.window(iced::window::Settings {
// Restored from config (defaults 900×760: taller so the bottom chat
// dock doesn't squeeze the controls column). Layout is responsive.
size: init_size,
position: init_position,
// App/taskbar icon (mainly used on X11/XWayland; native Wayland takes
// the icon from the .desktop file matched by app_id instead).
icon: window_icon(),
// app_id must match the .desktop basename so Wayland compositors
// (e.g. KWin) attach our launcher icon to the window.
platform_specific: iced::window::settings::PlatformSpecific {
application_id: "peerspeak".to_string(),
..Default::default()
},
// We save the final size ourselves on CloseRequested, then exit.
exit_on_close_request: false,
..Default::default()
})
.run()
}
/// Build the window icon from an embedded 128×128 straight-RGBA blob rendered
/// from `assets/icons/peerspeak.svg`. Using `from_rgba` (always available) keeps
/// us off iced's heavy `image` feature — the blob is raw pixels, no decoder.
fn window_icon() -> Option<iced::window::Icon> {
const RGBA: &[u8] = include_bytes!("../../assets/icons/peerspeak-128.rgba");
iced::window::icon::from_rgba(RGBA.to_vec(), 128, 128).ok()
}
/// True when running under a Wayland compositor (winit will use its Wayland
/// backend). Mirrors winit's own selection: it prefers Wayland when
/// `WAYLAND_DISPLAY` is set, otherwise falls back to X11 via `DISPLAY`.
fn is_wayland() -> bool {
std::env::var_os("WAYLAND_DISPLAY").is_some_and(|v| !v.is_empty())
}
/// Decide the initial window position from the saved coordinates.
///
/// Position restore only works on **X11** — Wayland's xdg-shell gives clients no
/// way to place their own window, so we center there and let the compositor
/// decide. Returns `Centered` when we're on Wayland or have no saved position.
fn initial_window_position(
saved_x: Option<i32>,
saved_y: Option<i32>,
is_wayland: bool,
) -> iced::window::Position {
match (saved_x, saved_y) {
(Some(x), Some(y)) if !is_wayland => {
iced::window::Position::Specific(iced::Point::new(x as f32, y as f32))
}
_ => iced::window::Position::Centered,
}
}
fn subscription(_state: &AppState) -> Subscription<AppMessage> {
let core_sub = Subscription::run(core_subscription).map(AppMessage::UiEventReceived);
let event_sub = iced::event::listen().map(AppMessage::EventOccurred);
Subscription::batch(vec![core_sub, event_sub])
}
/// Reconnect-chime edge trigger for `UiEvent::PeerConnecting`. Marks the peer as
/// connecting and returns `Some(Sound::ReconnectAttempt)` exactly once per outage:
/// only when the peer had a live link before (a genuine reconnect, not a first
/// dial) AND we weren't already in the connecting state (so the supervisor's
/// repeated redials while still down don't re-chime). Pure so the once-per-
/// disconnect behavior is unit-testable without a GUI or audio.
fn reconnect_attempt_chime(
connecting: &mut HashSet<EndpointId>,
ever_connected: &HashSet<EndpointId>,
id: EndpointId,
) -> Option<Sound> {
let is_reconnect_attempt = ever_connected.contains(&id);
let was_already_connecting = connecting.contains(&id);
connecting.insert(id);
(is_reconnect_attempt && !was_already_connecting).then_some(Sound::ReconnectAttempt)
}
/// Reconnect-chime edge trigger for `UiEvent::PeerConnected`. Clears the connecting
/// state, records that we've linked with this peer at least once, and returns
/// `Some(Sound::Reconnected)` only if it had connected before (a true reconnect, not
/// the first link). Pure so the logic is unit-testable.
fn reconnected_chime(
connecting: &mut HashSet<EndpointId>,
ever_connected: &mut HashSet<EndpointId>,
id: EndpointId,
) -> Option<Sound> {
let was_reconnect = ever_connected.contains(&id);
connecting.remove(&id);
ever_connected.insert(id);
was_reconnect.then_some(Sound::Reconnected)
}
fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
match message {
AppMessage::NicknameChanged(val) => {
state.name = val;
}
AppMessage::TicketInputChanged(val) => {
state.ticket_input = val;
}
AppMessage::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 => {
let _ = state.controller.send(CoreCommand::ToggleMute);
state.is_muted = !state.is_muted;
notify::play(Sound::MicToggle, state.config.custom_sound_mic_toggle.as_deref());
}
AppMessage::ToggleDeafenPressed => {
let _ = state.controller.send(CoreCommand::ToggleDeafen);
state.is_deafened = !state.is_deafened;
notify::play(Sound::MicToggle, state.config.custom_sound_mic_toggle.as_deref());
}
AppMessage::UiEventReceived(event) => {
match event {
UiEvent::RoomJoined { ticket, self_id } => {
// 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());
}
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 } => {
// The Discoverable time-box elapsed; core dropped us back to
// `mode` (Normal) and stopped publishing. Mirror + persist so the
// presence picker reflects it, and tell the user why it changed.
state.config.presence_mode = mode;
state.config.save();
state.status_message =
"Discoverable timed out — back to Normal".to_string();
}
UiEvent::Error(err) => {
state.status_message = format!("Error: {}", err);
}
}
}
AppMessage::CopyToClipboard => {
if !state.ticket.is_empty() {
return iced::clipboard::write(state.ticket.clone());
}
}
AppMessage::CopyText(s) => {
return iced::clipboard::write(s);
}
AppMessage::TogglePtt(enabled) => {
state.ptt_enabled = enabled;
let _ = state.controller.send(CoreCommand::SetPttMode(enabled));
}
AppMessage::StartSettingHotkey => {
state.is_setting_hotkey = true;
}
AppMessage::PeerVolumeChanged(id, vol) => {
state.peer_volumes.insert(id, vol);
let _ = state.controller.send(CoreCommand::SetPeerVolume(id, vol));
}
AppMessage::TogglePeerMute(id) => {
let now_muted = if state.locally_muted.contains(&id) {
state.locally_muted.remove(&id);
false
} else {
state.locally_muted.insert(id);
true
};
let _ = state.controller.send(CoreCommand::SetPeerMuted(id, now_muted));
}
AppMessage::InputDeviceSelected(dev) => {
state.config.input_device = dev.name.clone();
state.config.save();
state.selected_input = Some(dev);
}
AppMessage::OutputDeviceSelected(dev) => {
state.config.output_device = dev.name.clone();
state.config.save();
state.selected_output = Some(dev);
}
AppMessage::InputVolumeChanged(vol) => {
// Live apply; disk write deferred to release (PersistConfig).
state.config.input_volume = vol;
let _ = state.controller.send(CoreCommand::SetInputVolume(vol));
}
AppMessage::OutputVolumeChanged(vol) => {
state.config.output_volume = vol;
let _ = state.controller.send(CoreCommand::SetOutputVolume(vol));
}
AppMessage::PersistConfig => {
state.config.save();
}
AppMessage::NoiseGateChanged(val) => {
state.config.noise_gate_threshold = val;
state.config.save();
let _ = state.controller.send(CoreCommand::SetNoiseGateThreshold(val));
}
AppMessage::NoiseGateDragging(val) => {
// Live drag: apply immediately, defer the disk write to release.
state.config.noise_gate_threshold = val;
let _ = state.controller.send(CoreCommand::SetNoiseGateThreshold(val));
}
AppMessage::NetworkModeSelected(mode) => {
state.config.network_mode = mode;
state.config.save();
// Applied on the next join, since the endpoint is rebuilt then.
let _ = state.controller.send(CoreCommand::SetNetworkMode(mode));
}
AppMessage::RecordingModeSelected(mode) => {
state.config.recording_mode = mode;
state.config.save();
// Takes effect on the next recording start.
let _ = state.controller.send(CoreCommand::SetRecordingMode(mode));
}
AppMessage::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;
}
AppMessage::CloseLayoutPicker => {
state.layout_picker_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::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. `xdg-open` receives the URL as a single argv entry
// (no shell), so there's no injection surface.
if (url.starts_with("http://") || url.starts_with("https://"))
&& let Err(e) = std::process::Command::new("xdg-open").arg(&url).spawn()
{
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 state.is_setting_hotkey {
state.ptt_hotkey = key.clone();
state.is_setting_hotkey = false;
} else if state.ptt_enabled && key == state.ptt_hotkey && !state.ptt_active {
state.ptt_active = true;
let _ = state.controller.send(CoreCommand::SetPttActive(true));
}
}
AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyReleased { key, .. })) => {
if state.ptt_enabled && key == state.ptt_hotkey && state.ptt_active {
state.ptt_active = false;
let _ = state.controller.send(CoreCommand::SetPttActive(false));
}
}
AppMessage::EventOccurred(Event::Window(iced::window::Event::Resized(size))) => {
state.window_size = size;
// Remember the size in-memory; it's written to disk once on close.
// Guard against bogus tiny/zero sizes some compositors emit transiently.
if size.width >= 200.0 && size.height >= 200.0 {
state.config.window_width = size.width;
state.config.window_height = size.height;
}
// Keep divider positions valid for the new window dimensions. (Saved
// with the next drag-release or other config write; not worth a disk
// write on every resize tick.)
state.config.participants_width =
clamp_participants_width(state.config.participants_width, size.width);
state.config.chat_height =
clamp_chat_height(state.config.chat_height, size.height);
state.config.controls_width =
clamp_controls_width(state.config.controls_width, size.width);
state.config.chat_drawer_width =
clamp_chat_drawer_width(state.config.chat_drawer_width, size.width);
}
AppMessage::EventOccurred(Event::Window(iced::window::Event::Moved(position))) => {
// Remember the position in-memory; written to disk once on close.
// Negative coords are valid (a monitor left of/above the primary), so
// we don't clamp. On Wayland iced doesn't report position, so this
// arm simply never fires there and window_x/y stay None.
state.config.window_x = Some(position.x as i32);
state.config.window_y = Some(position.y as i32);
}
AppMessage::EventOccurred(Event::Window(iced::window::Event::CloseRequested)) => {
// We took over the close path (exit_on_close_request:false) so we can
// persist the final window size + position before quitting. Both are
// already mirrored into config by the Resized/Moved handlers above.
state.config.save();
return iced::exit();
}
AppMessage::EventOccurred(_) => {}
AppMessage::NavigateToSettings => {
state.current_screen = Screen::Settings;
}
AppMessage::NavigateBack => {
state.config.save();
// Release the mic when leaving Settings if the test was running.
if state.mic_test_active {
state.mic_test_active = false;
state.mic_level = 0.0;
let _ = state.controller.send(CoreCommand::SetMicMonitor {
enabled: false,
input_device: None,
});
}
if state.ticket.is_empty() {
state.current_screen = Screen::Home;
} else {
state.current_screen = Screen::Room;
}
}
}
Task::none()
}
/// One-line explanation of a network posture for the settings picker.
fn network_mode_hint(mode: NetworkMode) -> &'static str {
match mode {
NetworkMode::RelayNoDiscovery => "n0 relay for NAT traversal; no presence published to n0 DNS.",
NetworkMode::N0Full => "n0 relay + DNS discovery. Most reliable, most metadata shared.",
NetworkMode::DirectOnly => "Fully serverless. May fail behind strict/CGNAT networks.",
}
}
/// One-line explanation of a recording mode for the settings picker.
fn recording_mode_hint(mode: RecordingMode) -> &'static str {
match mode {
RecordingMode::Mixed => "One WAV: your mic blended with everyone you hear.",
RecordingMode::Multitrack => "One WAV per person + your mic, sample-aligned — mix it yourself.",
RecordingMode::Both => "Per-person stems + your mic AND a ready-made mixed WAV.",
}
}
/// 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(36).color(color_blue);
let subtitle = text("NAT-traversing full-mesh voice chat").size(16).color(color_subtext);
let nickname_input = column![
text("Nickname").size(14).color(color_subtext),
vertical_space(4.0),
text_input("Enter nickname...", &state.name)
.on_input(AppMessage::NicknameChanged)
.style(t_style)
.padding(10)
];
// 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(30)
.width(380)
.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 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);
if state.config.recents.is_empty() {
rows = rows.push(
text("No recent rooms yet — they'll appear here after you join one.")
.size(12)
.color(color_subtext),
);
}
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),
);
}
container(
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),
)
.style(c_style(color_mantle, color_surface, 12.0))
.padding(24)
.width(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 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 state.friends.list().is_empty() {
friend_rows = friend_rows.push(
text("No friends yet — add one by their node ID below.")
.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);
container(
column![
text("FRIENDS").size(18).color(color_text),
text("Who's online — click Join to hop into a friend's room.")
.size(11)
.color(color_subtext),
vertical_space(10.0),
readonly_warning,
friend_rows,
vertical_space(12.0),
text("Add a friend").size(13).color(color_subtext),
add_form,
vertical_space(14.0),
text("Your presence").size(13).color(color_subtext),
presence_picker,
]
.spacing(6),
)
.style(c_style(color_mantle, color_surface, 12.0))
.padding(24)
.width(460)
.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;
// Style Helpers
let c_style = move |bg: Color, b_color: Color, radius: f32| {
move |_theme: &Theme| container::Style {
text_color: Some(color_text),
background: Some(Background::Color(bg)),
border: Border {
color: b_color,
width: if b_color == Color::TRANSPARENT { 0.0 } else { 1.0 },
radius: radius.into(),
},
..Default::default()
}
};
let b_style = move |bg: Color, hover_bg: Color, text_c: Color, radius: f32| {
move |_theme: &Theme, status: button::Status| {
let active_bg = match status {
button::Status::Hovered => hover_bg,
_ => bg,
};
button::Style {
background: Some(Background::Color(active_bg)),
text_color: text_c,
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: radius.into(),
},
..Default::default()
}
}
};
let t_style = move |_theme: &Theme, _status: text_input::Status| {
text_input::Style {
background: Background::Color(color_crust),
border: Border {
color: color_surface,
width: 1.0,
radius: 6.0.into(),
},
icon: color_subtext,
placeholder: color_overlay,
value: color_text,
selection: color_blue,
}
};
let top_bar = row![
horizontal_space(),
tooltip(
button(
Canvas::new(LayoutIcon { fg: color_text })
.width(iced::Length::Fixed(18.0))
.height(iced::Length::Fixed(18.0))
)
.on_press(AppMessage::OpenLayoutPicker)
.style(b_style(color_surface, color_blue, color_text, 6.0))
.padding(8),
container(text("Room layout").size(11).color(color_text))
.padding(8)
.style(c_style(color_crust, color_surface, 6.0)),
iced::widget::tooltip::Position::Bottom,
)
.gap(8),
button(
row![
icon(IconKind::Settings, 15.0, color_text),
text("Settings").size(14),
]
.spacing(6)
.align_y(iced::alignment::Vertical::Center)
)
.on_press(AppMessage::NavigateToSettings)
.style(b_style(color_surface, color_blue, color_text, 6.0))
.padding(8)
].width(iced::Length::Fill).padding(10).spacing(8);
if state.current_screen == Screen::Settings {
let path_field = |label: &'static str, sound: Sound| {
let path = state.custom_sound_path(sound);
let validation_widget = match notify::validate_custom_path(path) {
None => text(""),
Some(true) => text("✓ File found").size(10).color(color_green),
Some(false) => text("✗ File not found").size(10).color(color_red),
};
// 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);
// 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()
};
// --- 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),
checkbox(state.config.echo_cancellation_enabled)
.label("Echo cancellation")
.on_toggle(AppMessage::ToggleEchoCancellation),
text("Cancels speaker echo + suppresses noise (PipeWire). Takes effect on your next room join.").size(11).color(color_subtext),
].spacing(8).width(iced::Length::Fill),
]
.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,
]
.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(color_crust, Color::TRANSPARENT, 0.0));
return with_regenerate_confirm(settings_screen.into(), state);
}
if state.current_screen == Screen::Home {
// --- HOME SCREEN ---
// Two cards: Connect (left) + the live Friends list (right). They sit
// side-by-side when the window is wide enough, and stack vertically on a
// narrow window so the Friends card never gets crushed — below ~860px the
// fixed-width Connect card would otherwise squeeze it until its node-ID
// field and remove button clip away. `responsive` measures the available
// width each layout pass and picks the orientation accordingly.
// Three cards: Recents | Connect | Friends, side-by-side when there's room.
// Three 380460px cards need ~1280px to fit in a row, so below that the
// `responsive` measure stacks them in a column (Connect first — the primary
// action) rather than letting the row clip. Recents always shows (empty-
// state hint when no history) for parity with the Friends card.
let body = responsive(move |size| {
let cards: Element<AppMessage> = if size.width < 1280.0 {
column![connect_card(state), recents_card(state), friends_panel(state)]
.spacing(20)
.align_x(iced::alignment::Horizontal::Center)
.into()
} else {
row![recents_card(state), connect_card(state), friends_panel(state)]
.spacing(20)
.align_y(iced::alignment::Vertical::Top)
.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(color_crust, Color::TRANSPARENT, 0.0));
with_layout_picker(home.into(), state)
} else {
// --- ROOM SCREEN ---
let participant_count = state.peers.len() + 1; // peers + you
let call_secs = state.call_started.map(|t| t.elapsed().as_secs()).unwrap_or(0);
// 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 card = container(card_content)
.style(c_style(
if is_speaking { color_base } else { color_mantle },
if is_connecting { color_yellow } else if is_speaking { color_green } else { color_surface },
6.0
))
.padding(12);
peers_list = peers_list.push(card);
}
let scroll_peers = scrollable(peers_list);
let peers_panel = container(
column![
text("Room Participants").size(18).color(color_blue),
vertical_space(10.0),
scroll_peers
]
)
.style(c_style(color_mantle, Color::TRANSPARENT, 0.0))
.padding(15)
.height(iced::Length::Fill);
// Control Panel Column
let mute_text = if state.is_muted { "Unmute Mic" } else { "Mute Mic" };
let mute_bg = if state.is_muted { color_red } else { color_surface };
let mute_hover = if state.is_muted { color_maroon } else { color_blue };
let mute_fg = if state.is_muted { color_crust } else { color_text };
let deafen_text = if state.is_deafened { "Undeafen Audio" } else { "Deafen Audio" };
let deafen_bg = if state.is_deafened { color_red } else { color_surface };
let deafen_hover = if state.is_deafened { color_maroon } else { color_blue };
let deafen_fg = if state.is_deafened { color_crust } else { color_text };
let mute_kind = if state.is_muted { IconKind::MicOff } else { IconKind::Mic };
let deafen_kind = if state.is_deafened { IconKind::Deafen } else { IconKind::Headphones };
let ctrl_buttons = column![
button(btn_content(mute_kind, mute_text, mute_fg))
.on_press(AppMessage::ToggleMutePressed)
.style(b_style(mute_bg, mute_hover, mute_fg, 8.0))
.padding(14)
.width(iced::Length::Fill),
vertical_space(10.0),
button(btn_content(deafen_kind, deafen_text, deafen_fg))
.on_press(AppMessage::ToggleDeafenPressed)
.style(b_style(deafen_bg, deafen_hover, deafen_fg, 8.0))
.padding(14)
.width(iced::Length::Fill),
vertical_space(20.0),
checkbox(state.ptt_enabled).label("Push-to-Talk").on_toggle(AppMessage::TogglePtt),
vertical_space(10.0),
if state.ptt_enabled {
column![
text(format!("Hotkey: {}", if state.is_setting_hotkey { "Press any key...".to_string() } else { format!("{:?}", state.ptt_hotkey) })).size(14).color(color_subtext),
button(text("Set Hotkey").size(12).align_x(iced::alignment::Horizontal::Center))
.on_press(AppMessage::StartSettingHotkey)
.style(b_style(color_surface, color_blue, color_text, 6.0))
.padding(8)
.width(iced::Length::Fill)
].spacing(8)
} else {
column![]
},
vertical_space(20.0),
// Echo cancellation — same flag + message as the Settings checkbox, so
// toggling here and there stay in sync automatically (single source of
// truth: config.echo_cancellation_enabled). Tooltip is explicit that it
// applies on the NEXT join (the PipeWire-module AEC is wired at join
// time, not hot-swappable mid-call).
tooltip(
checkbox(state.config.echo_cancellation_enabled)
.label("Echo cancellation")
.on_toggle(AppMessage::ToggleEchoCancellation),
container(
text("Cancels speaker echo + suppresses noise. Applies on your next room join.")
.size(11)
.color(color_text),
)
.padding(8)
.max_width(260.0)
.style(c_style(color_crust, color_surface, 6.0)),
iced::widget::tooltip::Position::Top,
)
.gap(8),
vertical_space(20.0),
{
let (rec_kind, rec_label, rec_bg, rec_hover, rec_fg) = if state.recording {
(IconKind::Stop, "Stop Recording", color_red, color_maroon, color_crust)
} else {
(IconKind::Record, "Record Call", color_surface, color_blue, color_text)
};
button(btn_content(rec_kind, rec_label, rec_fg))
.on_press(AppMessage::ToggleRecording)
.style(b_style(rec_bg, rec_hover, rec_fg, 8.0))
.padding(14)
.width(iced::Length::Fill)
},
vertical_space(20.0),
{
// Screen share. 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(color_crust, Color::TRANSPARENT, 0.0));
with_pixelpass_help(with_layout_picker(room.into(), 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()
}
/// 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,
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::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", "Recording", "Profile", "Appearance", "Network", "Notifications"]
);
assert_eq!(SettingsCategory::Audio.hint(), "Devices, mic gate, echo");
assert_eq!(SettingsCategory::Profile.hint(), "Avatar and identity");
}
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));
}
}