Add three in-call room layouts — 3-Column (Participants | Chat | Controls), Bottom Dock (Participants+Controls over a full-width Chat strip), and Drawer (Participants | Controls with a collapsible Chat panel) — chosen via one persisted RoomLayout config setting and applied live. Picker UX: a square layout button (drawn LayoutIcon glyph) in the top bar of the launch and in-call screens opens a popup gallery (dimmed click-to-dismiss backdrop + centered panel) of clickable schematic thumbnails; the Settings screen shows the same thumbnails inline (no button). Thumbnails are drawn with the canvas widget (new LayoutThumb program — colored panel boxes, blue border on the selected one), so no image-decoding dependency is added. Each layout's panel boundaries are draggable (DividerKind gains Controls + ChatDrawer for the 3-column right divider and the drawer's left edge; new clamp_controls_width / clamp_chat_drawer_width, persisted + re-clamped on resize). Participants width is shared across layouts but capped per layout at render time so a fixed panel can't starve the Fill panel (e.g. a wide Participants width set in the dock layout won't collapse Chat in 3-column or Controls in the drawer). The Drawer layout adds a header chat-toggle. +1 clamp test (now 127 tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
346 lines
13 KiB
Rust
346 lines
13 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
|
|
/// Relay/discovery posture, trading connectivity against how much the n0
|
|
/// infrastructure learns about you. See the network module for details.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
pub enum NetworkMode {
|
|
/// n0 relay for NAT traversal, but no DNS presence beacon. Peer addresses
|
|
/// come from the join ticket and gossip, so n0 only sees relayed-call
|
|
/// metadata, never a standing "I'm online" record. Default.
|
|
#[default]
|
|
RelayNoDiscovery,
|
|
/// Full n0 defaults: relay plus DNS publish/resolve (most convenient,
|
|
/// most phone-home).
|
|
N0Full,
|
|
/// No relay, no discovery: direct hole-punching only. Fully serverless,
|
|
/// but fails behind symmetric/CGNAT NATs with no fallback.
|
|
DirectOnly,
|
|
}
|
|
|
|
impl NetworkMode {
|
|
/// All variants, for presentation in a picker.
|
|
pub const ALL: [NetworkMode; 3] =
|
|
[NetworkMode::RelayNoDiscovery, NetworkMode::N0Full, NetworkMode::DirectOnly];
|
|
}
|
|
|
|
/// Arrangement of the in-call room screen, chosen via the layout picker.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
pub enum RoomLayout {
|
|
/// Participants | Chat | Controls, all columns visible at once.
|
|
ThreeColumn,
|
|
/// Participants + Controls on top, full-width Chat docked along the bottom.
|
|
#[default]
|
|
BottomDock,
|
|
/// Participants | Controls, with a collapsible Chat drawer on the right edge.
|
|
Drawer,
|
|
}
|
|
|
|
impl RoomLayout {
|
|
/// All variants, in picker display order.
|
|
pub const ALL: [RoomLayout; 3] =
|
|
[RoomLayout::ThreeColumn, RoomLayout::BottomDock, RoomLayout::Drawer];
|
|
}
|
|
|
|
impl std::fmt::Display for RoomLayout {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(match self {
|
|
RoomLayout::ThreeColumn => "3-Column",
|
|
RoomLayout::BottomDock => "Bottom Dock",
|
|
RoomLayout::Drawer => "Drawer",
|
|
})
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for NetworkMode {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
let label = match self {
|
|
NetworkMode::RelayNoDiscovery => "Relay, no presence beacon",
|
|
NetworkMode::N0Full => "n0 defaults (relay + DNS)",
|
|
NetworkMode::DirectOnly => "Direct only (no relay)",
|
|
};
|
|
f.write_str(label)
|
|
}
|
|
}
|
|
|
|
fn default_true() -> bool {
|
|
true
|
|
}
|
|
|
|
fn default_volume() -> f32 {
|
|
1.0
|
|
}
|
|
|
|
fn default_username() -> String {
|
|
"Peer".to_string()
|
|
}
|
|
|
|
fn default_participants_width() -> f32 {
|
|
540.0
|
|
}
|
|
|
|
fn default_chat_height() -> f32 {
|
|
180.0
|
|
}
|
|
|
|
fn default_controls_width() -> f32 {
|
|
280.0
|
|
}
|
|
|
|
fn default_chat_drawer_width() -> f32 {
|
|
320.0
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct AppConfig {
|
|
/// Last nickname used to join/create a room; pre-filled on the launch screen.
|
|
#[serde(default = "default_username")]
|
|
pub username: String,
|
|
pub input_device: String,
|
|
pub output_device: String,
|
|
pub noise_gate_threshold: f32,
|
|
/// App-internal capture gain applied to the mic before encode (1.0 = unity).
|
|
#[serde(default = "default_volume")]
|
|
pub input_volume: f32,
|
|
/// App-internal playback gain applied to the mixed output (1.0 = unity).
|
|
#[serde(default = "default_volume")]
|
|
pub output_volume: f32,
|
|
#[serde(default)]
|
|
pub network_mode: NetworkMode,
|
|
/// Route audio through PipeWire's echo-cancel module (AEC + noise suppression).
|
|
/// Takes effect on the next room join. Off by default.
|
|
#[serde(default)]
|
|
pub echo_cancellation_enabled: bool,
|
|
#[serde(default = "default_true")]
|
|
pub notifications_enabled: bool,
|
|
/// Persisted room-screen divider positions (px): Participants panel width and
|
|
/// Chat dock height. Re-clamped to the window on load and on resize.
|
|
#[serde(default = "default_participants_width")]
|
|
pub participants_width: f32,
|
|
#[serde(default = "default_chat_height")]
|
|
pub chat_height: f32,
|
|
/// Controls panel width for the 3-column layout (px).
|
|
#[serde(default = "default_controls_width")]
|
|
pub controls_width: f32,
|
|
/// Chat drawer width for the drawer layout (px).
|
|
#[serde(default = "default_chat_drawer_width")]
|
|
pub chat_drawer_width: f32,
|
|
/// Chosen arrangement of the in-call room screen.
|
|
#[serde(default)]
|
|
pub room_layout: RoomLayout,
|
|
#[serde(default)]
|
|
pub custom_sound_self_join: Option<String>,
|
|
#[serde(default)]
|
|
pub custom_sound_peer_join: Option<String>,
|
|
#[serde(default)]
|
|
pub custom_sound_peer_leave: Option<String>,
|
|
#[serde(default)]
|
|
pub custom_sound_reconnect_attempt: Option<String>,
|
|
#[serde(default)]
|
|
pub custom_sound_reconnected: Option<String>,
|
|
#[serde(default)]
|
|
pub custom_sound_self_leave: Option<String>,
|
|
#[serde(default)]
|
|
pub custom_sound_mic_toggle: Option<String>,
|
|
#[serde(default)]
|
|
pub custom_sound_reconnect_failed: Option<String>,
|
|
}
|
|
|
|
impl Default for AppConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
username: default_username(),
|
|
input_device: "".to_string(),
|
|
output_device: "".to_string(),
|
|
noise_gate_threshold: 0.01,
|
|
input_volume: 1.0,
|
|
output_volume: 1.0,
|
|
network_mode: NetworkMode::default(),
|
|
echo_cancellation_enabled: false,
|
|
notifications_enabled: true,
|
|
participants_width: default_participants_width(),
|
|
chat_height: default_chat_height(),
|
|
controls_width: default_controls_width(),
|
|
chat_drawer_width: default_chat_drawer_width(),
|
|
room_layout: RoomLayout::default(),
|
|
custom_sound_self_join: None,
|
|
custom_sound_peer_join: None,
|
|
custom_sound_peer_leave: None,
|
|
custom_sound_reconnect_attempt: None,
|
|
custom_sound_reconnected: None,
|
|
custom_sound_self_leave: None,
|
|
custom_sound_mic_toggle: None,
|
|
custom_sound_reconnect_failed: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AppConfig {
|
|
fn config_path() -> Option<PathBuf> {
|
|
dirs::config_dir().map(|mut p| {
|
|
p.push("peerspeak");
|
|
p.push("config.json");
|
|
p
|
|
})
|
|
}
|
|
|
|
pub fn load() -> Self {
|
|
if let Some(path) = Self::config_path()
|
|
&& let Ok(contents) = fs::read_to_string(&path)
|
|
&& let Ok(config) = serde_json::from_str(&contents) {
|
|
return config;
|
|
}
|
|
Self::default()
|
|
}
|
|
|
|
pub fn save(&self) {
|
|
if let Some(path) = Self::config_path() {
|
|
if let Some(dir) = path.parent() {
|
|
let _ = fs::create_dir_all(dir);
|
|
}
|
|
if let Ok(json) = serde_json::to_string_pretty(self) {
|
|
let _ = fs::write(path, json);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_round_trip() {
|
|
let original = AppConfig::default();
|
|
let serialized = serde_json::to_string(&original).unwrap();
|
|
let deserialized: AppConfig = serde_json::from_str(&serialized).unwrap();
|
|
assert_eq!(original, deserialized);
|
|
}
|
|
|
|
#[test]
|
|
fn test_backward_compat_default_fill() {
|
|
let minimal_json = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
|
|
let deserialized: AppConfig = serde_json::from_str(minimal_json).unwrap();
|
|
|
|
assert_eq!(deserialized.network_mode, NetworkMode::RelayNoDiscovery);
|
|
assert!(!deserialized.echo_cancellation_enabled);
|
|
assert!(deserialized.notifications_enabled);
|
|
// Configs predating the volume sliders must load at unity gain.
|
|
assert_eq!(deserialized.input_volume, 1.0);
|
|
assert_eq!(deserialized.output_volume, 1.0);
|
|
// Configs predating the draggable dividers must load the default sizes.
|
|
assert_eq!(deserialized.participants_width, 540.0);
|
|
assert_eq!(deserialized.chat_height, 180.0);
|
|
// Configs predating the remembered username load the default nickname.
|
|
assert_eq!(deserialized.username, "Peer");
|
|
// Configs predating the layout picker load the default layout + sizes.
|
|
assert_eq!(deserialized.room_layout, RoomLayout::BottomDock);
|
|
assert_eq!(deserialized.controls_width, 280.0);
|
|
assert_eq!(deserialized.chat_drawer_width, 320.0);
|
|
assert!(deserialized.custom_sound_self_join.is_none());
|
|
assert!(deserialized.custom_sound_peer_join.is_none());
|
|
assert!(deserialized.custom_sound_peer_leave.is_none());
|
|
assert!(deserialized.custom_sound_reconnect_attempt.is_none());
|
|
assert!(deserialized.custom_sound_reconnected.is_none());
|
|
assert!(deserialized.custom_sound_self_leave.is_none());
|
|
assert!(deserialized.custom_sound_mic_toggle.is_none());
|
|
assert!(deserialized.custom_sound_reconnect_failed.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_input_output_volume_fields() {
|
|
// Default impl is unity gain.
|
|
let def = AppConfig::default();
|
|
assert_eq!(def.input_volume, 1.0);
|
|
assert_eq!(def.output_volume, 1.0);
|
|
|
|
// Missing in JSON → unity (serde default).
|
|
let missing = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
|
|
let cfg_missing: AppConfig = serde_json::from_str(missing).unwrap();
|
|
assert_eq!(cfg_missing.input_volume, 1.0);
|
|
assert_eq!(cfg_missing.output_volume, 1.0);
|
|
|
|
// Explicit non-unity values are preserved across a round-trip.
|
|
let cfg = AppConfig {
|
|
input_volume: 1.5,
|
|
output_volume: 0.25,
|
|
..AppConfig::default()
|
|
};
|
|
let round_tripped: AppConfig =
|
|
serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
|
|
assert_eq!(round_tripped.input_volume, 1.5);
|
|
assert_eq!(round_tripped.output_volume, 0.25);
|
|
}
|
|
|
|
#[test]
|
|
fn test_notifications_enabled_specifically() {
|
|
let missing_notifications = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
|
|
let config_missing: AppConfig = serde_json::from_str(missing_notifications).unwrap();
|
|
assert!(config_missing.notifications_enabled);
|
|
|
|
let explicit_false = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01,"notifications_enabled":false}"#;
|
|
let config_false: AppConfig = serde_json::from_str(explicit_false).unwrap();
|
|
assert!(!config_false.notifications_enabled);
|
|
|
|
let explicit_true = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01,"notifications_enabled":true}"#;
|
|
let config_true: AppConfig = serde_json::from_str(explicit_true).unwrap();
|
|
assert!(config_true.notifications_enabled);
|
|
}
|
|
|
|
#[test]
|
|
fn test_network_mode() {
|
|
// NetworkMode::default() == RelayNoDiscovery
|
|
assert_eq!(NetworkMode::default(), NetworkMode::RelayNoDiscovery);
|
|
|
|
// ALL.len() == 3 and contains all three variants
|
|
assert_eq!(NetworkMode::ALL.len(), 3);
|
|
assert!(NetworkMode::ALL.contains(&NetworkMode::RelayNoDiscovery));
|
|
assert!(NetworkMode::ALL.contains(&NetworkMode::N0Full));
|
|
assert!(NetworkMode::ALL.contains(&NetworkMode::DirectOnly));
|
|
|
|
// each variant serde round-trips to itself
|
|
for mode in NetworkMode::ALL {
|
|
let serialized = serde_json::to_string(&mode).unwrap();
|
|
let deserialized: NetworkMode = serde_json::from_str(&serialized).unwrap();
|
|
assert_eq!(mode, deserialized);
|
|
}
|
|
|
|
// the three Display strings are non-empty and distinct
|
|
let display_0 = NetworkMode::RelayNoDiscovery.to_string();
|
|
let display_1 = NetworkMode::N0Full.to_string();
|
|
let display_2 = NetworkMode::DirectOnly.to_string();
|
|
|
|
assert!(!display_0.is_empty());
|
|
assert!(!display_1.is_empty());
|
|
assert!(!display_2.is_empty());
|
|
|
|
assert_ne!(display_0, display_1);
|
|
assert_ne!(display_1, display_2);
|
|
assert_ne!(display_0, display_2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_unknown_field_tolerance() {
|
|
// Unknown/extra field tolerance: a config JSON containing an extra unrecognized key should still deserialize.
|
|
// Assert whatever actually happens and leave a comment; do not add deny_unknown_fields.
|
|
let json_with_extra = r#"{
|
|
"input_device": "",
|
|
"output_device": "",
|
|
"noise_gate_threshold": 0.01,
|
|
"unrecognized_field_xyz_123": "some_value"
|
|
}"#;
|
|
let deserialized_res: Result<AppConfig, _> = serde_json::from_str(json_with_extra);
|
|
|
|
// Assert that deserialization succeeds even with unrecognized/unknown fields.
|
|
// This confirms that serde does not reject unknown fields (i.e. default behavior).
|
|
assert!(deserialized_res.is_ok(), "Config deserialization failed when an unknown field was present");
|
|
|
|
let config = deserialized_res.unwrap();
|
|
assert_eq!(config.input_device, "");
|
|
assert_eq!(config.output_device, "");
|
|
assert_eq!(config.noise_gate_threshold, 0.01);
|
|
}
|
|
}
|
|
|