307 lines
9.3 KiB
Rust
Raw Normal View History

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, Default, Serialize, Deserialize)]
pub struct DeviceSelection {
pub camera: Option<String>,
pub microphone: Option<String>,
pub speaker: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LauncherState {
pub routing: InputRouting,
pub view_mode: ViewMode,
pub displays: [DisplaySurface; 2],
pub devices: DeviceSelection,
pub remote_active: bool,
pub notes: Vec<String>,
}
impl Default for LauncherState {
fn default() -> Self {
Self {
routing: InputRouting::Remote,
2026-04-14 14:38:03 -03:00
view_mode: ViewMode::Unified,
displays: [DisplaySurface::Preview, DisplaySurface::Preview],
devices: DeviceSelection::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<String>) {
self.devices.camera = normalize_selection(camera);
}
pub fn select_microphone(&mut self, microphone: Option<String>) {
self.devices.microphone = normalize_selection(microphone);
}
pub fn select_speaker(&mut self, speaker: Option<String>) {
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 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<String>) {
self.notes.push(note.into());
}
pub fn status_line(&self) -> String {
format!(
"mode={} view={} active={} d1={} d2={} camera={} mic={} speaker={}",
match self.routing {
InputRouting::Local => "local",
InputRouting::Remote => "remote",
},
match self.view_mode {
ViewMode::Unified => "unified",
ViewMode::Breakout => "breakout",
},
self.remote_active,
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"),
)
}
}
fn normalize_selection(value: Option<String>) -> Option<String> {
value.and_then(|v| {
let trimmed = v.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("auto") {
None
} else {
Some(trimmed.to_string())
}
})
}
#[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);
2026-04-14 14:38:03 -03:00
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());
}
#[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"));
}
}