415 lines
12 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, 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<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 swap_key: String,
pub swap_key_binding: bool,
pub capture_power: CapturePowerStatus,
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(),
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<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 set_swap_key(&mut self, swap_key: impl Into<String>) {
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<String>) {
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<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())
}
})
}
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);
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());
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"]);
}
}