use serde::{Deserialize, Serialize}; use super::devices::DeviceCatalog; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum InputRouting { Local, Remote, } impl InputRouting { pub fn as_env(self) -> &'static str { match self { Self::Local => "0", Self::Remote => "1", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ViewMode { Unified, Breakout, } impl ViewMode { pub fn as_env(self) -> &'static str { match self { Self::Unified => "unified", Self::Breakout => "breakout", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum DisplaySurface { Preview, Window, } impl DisplaySurface { pub fn label(self) -> &'static str { match self { Self::Preview => "preview", Self::Window => "window", } } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CapturePowerStatus { pub available: bool, pub enabled: bool, pub unit: String, pub detail: String, pub active_leases: u32, pub mode: String, } impl Default for CapturePowerStatus { fn default() -> Self { Self { available: false, enabled: false, unit: "relay.service".to_string(), detail: "unknown".to_string(), active_leases: 0, mode: "auto".to_string(), } } } #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] pub struct DeviceSelection { pub camera: Option, pub microphone: Option, pub speaker: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LauncherState { pub routing: InputRouting, pub view_mode: ViewMode, pub displays: [DisplaySurface; 2], pub devices: DeviceSelection, pub swap_key: String, pub swap_key_binding: bool, pub capture_power: CapturePowerStatus, pub remote_active: bool, pub notes: Vec, } impl Default for LauncherState { fn default() -> Self { Self { routing: InputRouting::Remote, view_mode: ViewMode::Unified, displays: [DisplaySurface::Preview, DisplaySurface::Preview], devices: DeviceSelection::default(), swap_key: "pause".to_string(), swap_key_binding: false, capture_power: CapturePowerStatus::default(), remote_active: false, notes: Vec::new(), } } } impl LauncherState { pub fn new() -> Self { Self::default() } pub fn set_routing(&mut self, routing: InputRouting) { self.routing = routing; } pub fn set_view_mode(&mut self, view_mode: ViewMode) { self.view_mode = view_mode; self.displays = match view_mode { ViewMode::Unified => [DisplaySurface::Preview, DisplaySurface::Preview], ViewMode::Breakout => [DisplaySurface::Window, DisplaySurface::Window], }; } pub fn display_surface(&self, monitor_id: usize) -> DisplaySurface { self.displays .get(monitor_id) .copied() .unwrap_or(DisplaySurface::Preview) } pub fn set_display_surface(&mut self, monitor_id: usize, surface: DisplaySurface) { if let Some(slot) = self.displays.get_mut(monitor_id) { *slot = surface; self.view_mode = if self .displays .iter() .any(|display| matches!(display, DisplaySurface::Window)) { ViewMode::Breakout } else { ViewMode::Unified }; } } pub fn breakout_count(&self) -> usize { self.displays .iter() .filter(|surface| matches!(surface, DisplaySurface::Window)) .count() } pub fn select_camera(&mut self, camera: Option) { self.devices.camera = normalize_selection(camera); } pub fn select_microphone(&mut self, microphone: Option) { self.devices.microphone = normalize_selection(microphone); } pub fn select_speaker(&mut self, speaker: Option) { self.devices.speaker = normalize_selection(speaker); } pub fn apply_catalog_defaults(&mut self, catalog: &DeviceCatalog) { if self.devices.camera.is_none() { self.devices.camera = catalog.cameras.first().cloned(); } if self.devices.microphone.is_none() { self.devices.microphone = catalog.microphones.first().cloned(); } if self.devices.speaker.is_none() { self.devices.speaker = catalog.speakers.first().cloned(); } } pub fn set_swap_key(&mut self, swap_key: impl Into) { self.swap_key = normalize_swap_key(swap_key.into()); } pub fn begin_swap_key_binding(&mut self) { self.swap_key_binding = true; } pub fn finish_swap_key_binding(&mut self) { self.swap_key_binding = false; } pub fn start_remote(&mut self) -> bool { if self.remote_active { return false; } self.remote_active = true; true } pub fn stop_remote(&mut self) -> bool { if !self.remote_active { return false; } self.remote_active = false; true } pub fn push_note(&mut self, note: impl Into) { self.notes.push(note.into()); } pub fn set_capture_power(&mut self, power: CapturePowerStatus) { self.capture_power = power; } pub fn status_line(&self) -> String { format!( "mode={} view={} active={} power={} d1={} d2={} camera={} mic={} speaker={} swap={}", match self.routing { InputRouting::Local => "local", InputRouting::Remote => "remote", }, match self.view_mode { ViewMode::Unified => "unified", ViewMode::Breakout => "breakout", }, self.remote_active, if self.capture_power.enabled { "on" } else { "off" }, self.displays[0].label(), self.displays[1].label(), self.devices.camera.as_deref().unwrap_or("auto"), self.devices.microphone.as_deref().unwrap_or("auto"), self.devices.speaker.as_deref().unwrap_or("auto"), self.swap_key, ) } } fn normalize_selection(value: Option) -> Option { value.and_then(|v| { let trimmed = v.trim(); if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("auto") { None } else { Some(trimmed.to_string()) } }) } fn normalize_swap_key(value: String) -> String { let trimmed = value.trim(); if trimmed.is_empty() { "off".to_string() } else { trimmed.to_ascii_lowercase() } } #[cfg(test)] mod tests { use super::*; #[test] fn routing_and_view_env_values_are_stable() { assert_eq!(InputRouting::Local.as_env(), "0"); assert_eq!(InputRouting::Remote.as_env(), "1"); assert_eq!(ViewMode::Unified.as_env(), "unified"); assert_eq!(ViewMode::Breakout.as_env(), "breakout"); } #[test] fn defaults_pick_remote_unified_and_inactive_session() { let state = LauncherState::new(); assert_eq!(state.routing, InputRouting::Remote); assert_eq!(state.view_mode, ViewMode::Unified); assert_eq!(state.display_surface(0), DisplaySurface::Preview); assert_eq!(state.display_surface(1), DisplaySurface::Preview); assert!(!state.remote_active); assert!(state.devices.camera.is_none()); assert!(state.devices.microphone.is_none()); assert!(state.devices.speaker.is_none()); assert_eq!(state.capture_power.unit, "relay.service"); assert_eq!(state.capture_power.mode, "auto"); } #[test] fn display_surface_updates_global_view_summary() { let mut state = LauncherState::new(); state.set_display_surface(1, DisplaySurface::Window); assert_eq!(state.view_mode, ViewMode::Breakout); assert_eq!(state.breakout_count(), 1); state.set_display_surface(1, DisplaySurface::Preview); assert_eq!(state.view_mode, ViewMode::Unified); assert_eq!(state.breakout_count(), 0); state.set_view_mode(ViewMode::Breakout); assert_eq!(state.display_surface(0), DisplaySurface::Window); assert_eq!(state.display_surface(1), DisplaySurface::Window); } #[test] fn selecting_auto_or_blank_clears_explicit_device() { let mut state = LauncherState::new(); state.select_camera(Some("/dev/video0".to_string())); assert_eq!(state.devices.camera.as_deref(), Some("/dev/video0")); state.select_camera(Some("auto".to_string())); assert!(state.devices.camera.is_none()); state.select_microphone(Some(" ".to_string())); assert!(state.devices.microphone.is_none()); } #[test] fn catalog_defaults_fill_only_missing_values() { let mut state = LauncherState::new(); state.select_camera(Some("/dev/video-special".to_string())); let catalog = DeviceCatalog { cameras: vec!["/dev/video0".to_string()], microphones: vec!["alsa_input.usb".to_string()], speakers: vec!["alsa_output.usb".to_string()], }; state.apply_catalog_defaults(&catalog); assert_eq!(state.devices.camera.as_deref(), Some("/dev/video-special")); assert_eq!(state.devices.microphone.as_deref(), Some("alsa_input.usb")); assert_eq!(state.devices.speaker.as_deref(), Some("alsa_output.usb")); } #[test] fn start_and_stop_remote_only_report_changes_once() { let mut state = LauncherState::new(); assert!(state.start_remote()); assert!(!state.start_remote()); assert!(state.remote_active); assert!(state.stop_remote()); assert!(!state.stop_remote()); assert!(!state.remote_active); } #[test] fn status_line_mentions_all_user_visible_controls() { let mut state = LauncherState::new(); state.set_routing(InputRouting::Local); state.set_view_mode(ViewMode::Unified); state.select_camera(Some("/dev/video0".to_string())); state.select_microphone(Some("alsa_input.usb".to_string())); state.select_speaker(Some("alsa_output.usb".to_string())); state.start_remote(); let status = state.status_line(); assert!(status.contains("mode=local")); assert!(status.contains("view=unified")); assert!(status.contains("active=true")); assert!(status.contains("d1=preview")); assert!(status.contains("d2=preview")); assert!(status.contains("camera=/dev/video0")); assert!(status.contains("mic=alsa_input.usb")); assert!(status.contains("speaker=alsa_output.usb")); } #[test] fn capture_power_status_updates_snapshot_state() { let mut state = LauncherState::new(); state.set_capture_power(CapturePowerStatus { available: true, enabled: true, unit: "relay.service".to_string(), detail: "active/running".to_string(), active_leases: 2, mode: "forced-on".to_string(), }); assert!(state.capture_power.available); assert!(state.capture_power.enabled); assert_eq!(state.capture_power.active_leases, 2); assert!(state.status_line().contains("power=on")); } #[test] fn swap_key_binding_tracks_selected_key_and_binding_mode() { let mut state = LauncherState::new(); assert_eq!(state.swap_key, "pause"); assert!(!state.swap_key_binding); state.begin_swap_key_binding(); assert!(state.swap_key_binding); state.set_swap_key("F8"); assert_eq!(state.swap_key, "f8"); state.set_swap_key(" "); assert_eq!(state.swap_key, "off"); state.finish_swap_key_binding(); assert!(!state.swap_key_binding); } #[test] fn push_note_accumulates_operator_context() { let mut state = LauncherState::new(); state.push_note("preview warm"); state.push_note("relay linked"); assert_eq!(state.notes, vec!["preview warm", "relay linked"]); } }