launcher: add live pop-out/input toggles and quick handoff

This commit is contained in:
Brad Stein 2026-04-14 04:02:39 -03:00
parent 5d37916272
commit 7225ed6a1a
7 changed files with 420 additions and 32 deletions

View File

@ -1,10 +1,11 @@
// client/src/input/inputs.rs // client/src/input/inputs.rs
use anyhow::{Context, Result};
#[cfg(not(coverage))] #[cfg(not(coverage))]
use anyhow::bail; use anyhow::bail;
use anyhow::{Context, Result};
use evdev::{AbsoluteAxisCode, Device, EventType, KeyCode, RelativeAxisCode}; use evdev::{AbsoluteAxisCode, Device, EventType, KeyCode, RelativeAxisCode};
use std::collections::HashSet; use std::collections::HashSet;
use std::time::Instant;
use tokio::{ use tokio::{
sync::broadcast::Sender, sync::broadcast::Sender,
time::{Duration, interval}, time::{Duration, interval},
@ -30,6 +31,10 @@ pub struct InputAggregator {
keyboards: Vec<KeyboardAggregator>, keyboards: Vec<KeyboardAggregator>,
mice: Vec<MouseAggregator>, mice: Vec<MouseAggregator>,
capture_remote_boot: bool, capture_remote_boot: bool,
quick_toggle_key: Option<KeyCode>,
quick_toggle_down: bool,
quick_toggle_debounce: Duration,
last_quick_toggle_at: Option<Instant>,
} }
impl InputAggregator { impl InputAggregator {
@ -49,6 +54,7 @@ impl InputAggregator {
paste_tx: Option<UnboundedSender<String>>, paste_tx: Option<UnboundedSender<String>>,
capture_remote_boot: bool, capture_remote_boot: bool,
) -> Self { ) -> Self {
let quick_toggle_key = quick_toggle_key_from_env();
Self { Self {
kbd_tx, kbd_tx,
mou_tx, mou_tx,
@ -62,6 +68,10 @@ impl InputAggregator {
keyboards: Vec::new(), keyboards: Vec::new(),
mice: Vec::new(), mice: Vec::new(),
capture_remote_boot, capture_remote_boot,
quick_toggle_key,
quick_toggle_down: false,
quick_toggle_debounce: quick_toggle_debounce_from_env(),
last_quick_toggle_at: None,
} }
} }
@ -218,6 +228,8 @@ impl InputAggregator {
for kbd in &mut self.keyboards { for kbd in &mut self.keyboards {
kbd.process_events(); kbd.process_events();
} }
let quick_toggle_now = self.quick_toggle_active();
self.observe_quick_toggle(quick_toggle_now);
if self.pending_release || self.pending_kill { if self.pending_release || self.pending_kill {
let chord_released = if self.pending_keys.is_empty() { let chord_released = if self.pending_keys.is_empty() {
@ -268,6 +280,8 @@ impl InputAggregator {
kbd.process_events(); kbd.process_events();
want_kill |= kbd.magic_kill(); want_kill |= kbd.magic_kill();
} }
let quick_toggle_now = self.quick_toggle_active();
self.observe_quick_toggle(quick_toggle_now);
let magic_now = self.keyboards.iter().any(|k| k.magic_grab()); let magic_now = self.keyboards.iter().any(|k| k.magic_grab());
let magic_left = self.keyboards.iter().any(|k| k.magic_left()); let magic_left = self.keyboards.iter().any(|k| k.magic_left());
let magic_right = self.keyboards.iter().any(|k| k.magic_right()); let magic_right = self.keyboards.iter().any(|k| k.magic_right());
@ -381,6 +395,33 @@ impl InputAggregator {
} }
} }
} }
/// Returns whether the configured quick-toggle key is currently pressed.
fn quick_toggle_active(&self) -> bool {
self.quick_toggle_key
.is_some_and(|key| self.keyboards.iter().any(|kbd| kbd.has_key(key)))
}
/// Applies rising-edge + debounce semantics before switching input mode.
fn observe_quick_toggle(&mut self, quick_toggle_now: bool) {
if quick_toggle_now && !self.quick_toggle_down {
let now = Instant::now();
let debounced = self
.last_quick_toggle_at
.is_none_or(|last| now.duration_since(last) >= self.quick_toggle_debounce);
if debounced {
if let Some(key) = self.quick_toggle_key {
info!(
"🎛️ quick-toggle {:?} engaged for smooth local/remote handoff",
key
);
}
self.toggle_grab();
self.last_quick_toggle_at = Some(now);
}
}
self.quick_toggle_down = quick_toggle_now;
}
} }
/// The classification function /// The classification function
@ -390,7 +431,8 @@ fn classify_device(dev: &Device) -> DeviceKind {
let keyset = dev.supported_keys(); let keyset = dev.supported_keys();
if evbits.contains(EventType::KEY) if evbits.contains(EventType::KEY)
&& keyset.is_some_and(|keys| keys.contains(KeyCode::KEY_A) || keys.contains(KeyCode::KEY_ENTER)) && keyset
.is_some_and(|keys| keys.contains(KeyCode::KEY_A) || keys.contains(KeyCode::KEY_ENTER))
{ {
return DeviceKind::Keyboard; return DeviceKind::Keyboard;
} }
@ -465,3 +507,33 @@ enum DeviceKind {
Mouse, Mouse,
Other, Other,
} }
/// Resolves the quick-toggle key from env, defaulting to Scroll Lock.
fn quick_toggle_key_from_env() -> Option<KeyCode> {
match std::env::var("LESAVKA_INPUT_TOGGLE_KEY") {
Ok(raw) => parse_quick_toggle_key(&raw),
Err(_) => Some(KeyCode::KEY_SCROLLLOCK),
}
}
/// Parses a launcher/operator key alias into an evdev key code.
fn parse_quick_toggle_key(raw: &str) -> Option<KeyCode> {
let normalized = raw.trim().to_ascii_lowercase();
match normalized.as_str() {
"" | "off" | "none" | "disabled" => None,
"pause" | "pausebreak" | "pause_break" | "pause-break" => Some(KeyCode::KEY_PAUSE),
"f12" => Some(KeyCode::KEY_F12),
"f11" => Some(KeyCode::KEY_F11),
"f10" => Some(KeyCode::KEY_F10),
_ => Some(KeyCode::KEY_SCROLLLOCK),
}
}
/// Reads debounce window from env, with a safety floor to avoid rapid flapping.
fn quick_toggle_debounce_from_env() -> Duration {
let millis = std::env::var("LESAVKA_INPUT_TOGGLE_DEBOUNCE_MS")
.ok()
.and_then(|raw| raw.parse::<u64>().ok())
.unwrap_or(350);
Duration::from_millis(millis.max(50))
}

View File

@ -3,7 +3,9 @@ use anyhow::Result;
#[cfg(not(coverage))] #[cfg(not(coverage))]
use { use {
super::devices::DeviceCatalog, super::devices::DeviceCatalog,
super::diagnostics::{DiagnosticsLog, PerformanceSample, SnapshotReport, quality_probe_command}, super::diagnostics::{
DiagnosticsLog, PerformanceSample, SnapshotReport, quality_probe_command,
},
super::runtime_env_vars, super::runtime_env_vars,
super::state::{InputRouting, LauncherState, ViewMode}, super::state::{InputRouting, LauncherState, ViewMode},
gtk::prelude::*, gtk::prelude::*,
@ -141,10 +143,15 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let start_button = gtk::Button::with_label("Start Session"); let start_button = gtk::Button::with_label("Start Session");
let stop_button = gtk::Button::with_label("Stop Session"); let stop_button = gtk::Button::with_label("Stop Session");
let view_toggle_button = gtk::Button::with_label("");
let input_toggle_button = gtk::Button::with_label("");
let snapshot_button = gtk::Button::with_label("Save Snapshot"); let snapshot_button = gtk::Button::with_label("Save Snapshot");
button_row.append(&start_button); button_row.append(&start_button);
button_row.append(&stop_button); button_row.append(&stop_button);
button_row.append(&view_toggle_button);
button_row.append(&input_toggle_button);
button_row.append(&snapshot_button); button_row.append(&snapshot_button);
sync_toggle_button_labels(&state.borrow(), &view_toggle_button, &input_toggle_button);
let probe_hint = gtk::Label::new(Some(quality_probe_command())); let probe_hint = gtk::Label::new(Some(quality_probe_command()));
probe_hint.set_halign(gtk::Align::Start); probe_hint.set_halign(gtk::Align::Start);
@ -152,7 +159,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
root.append(&probe_hint); root.append(&probe_hint);
let note = gtk::Label::new(Some( let note = gtk::Label::new(Some(
"Unified mode renders both streams side-by-side in one window. Breakout mode keeps dedicated per-eye windows.", "Unified mode renders both streams side-by-side in one window. Use Pop Out Windows to split back into full windows. Quick input toggle key defaults to Scroll Lock.",
)); ));
note.set_wrap(true); note.set_wrap(true);
note.set_halign(gtk::Align::Start); note.set_halign(gtk::Align::Start);
@ -169,6 +176,8 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let microphone_combo = microphone_combo.clone(); let microphone_combo = microphone_combo.clone();
let speaker_combo = speaker_combo.clone(); let speaker_combo = speaker_combo.clone();
let server_addr = Rc::clone(&server_addr); let server_addr = Rc::clone(&server_addr);
let view_toggle_button = view_toggle_button.clone();
let input_toggle_button = input_toggle_button.clone();
start_button.connect_clicked(move |_| { start_button.connect_clicked(move |_| {
{ {
@ -188,6 +197,11 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
state.select_microphone(selected_combo_value(&microphone_combo)); state.select_microphone(selected_combo_value(&microphone_combo));
state.select_speaker(selected_combo_value(&speaker_combo)); state.select_speaker(selected_combo_value(&speaker_combo));
} }
sync_toggle_button_labels(
&state.borrow(),
&view_toggle_button,
&input_toggle_button,
);
if child_proc.borrow().is_some() { if child_proc.borrow().is_some() {
status_label.set_text("Session already running"); status_label.set_text("Session already running");
@ -196,13 +210,11 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let spawn_result = { let spawn_result = {
let mut state = state.borrow_mut(); let mut state = state.borrow_mut();
let _ = state.start_remote(); launch_or_restart_client(&child_proc, server_addr.as_ref(), &mut state)
spawn_client_process(server_addr.as_ref(), &state)
}; };
match spawn_result { match spawn_result {
Ok(child) => { Ok(()) => {
*child_proc.borrow_mut() = Some(child);
diagnostics.borrow_mut().record(PerformanceSample { diagnostics.borrow_mut().record(PerformanceSample {
rtt_ms: 0.0, rtt_ms: 0.0,
input_latency_ms: 0.0, input_latency_ms: 0.0,
@ -226,15 +238,120 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let child_proc = Rc::clone(&child_proc); let child_proc = Rc::clone(&child_proc);
let status_label = status_label.clone(); let status_label = status_label.clone();
stop_button.connect_clicked(move |_| { stop_button.connect_clicked(move |_| {
if let Some(mut child) = child_proc.borrow_mut().take() { stop_child_process(&child_proc);
let _ = child.kill();
let _ = child.wait();
}
let _ = state.borrow_mut().stop_remote(); let _ = state.borrow_mut().stop_remote();
status_label.set_text("Stopped"); status_label.set_text("Stopped");
}); });
} }
{
let state = Rc::clone(&state);
let child_proc = Rc::clone(&child_proc);
let diagnostics = Rc::clone(&diagnostics);
let status_label = status_label.clone();
let view_combo = view_combo.clone();
let input_toggle_button = input_toggle_button.clone();
let view_toggle_button = view_toggle_button.clone();
let server_addr = Rc::clone(&server_addr);
let view_toggle_button_handle = view_toggle_button.clone();
view_toggle_button_handle.connect_clicked(move |_| {
{
let mut state = state.borrow_mut();
let next = next_view_mode(state.view_mode);
state.set_view_mode(next);
let _ = view_combo.set_active_id(Some(state.view_mode.as_env()));
}
sync_toggle_button_labels(
&state.borrow(),
&view_toggle_button,
&input_toggle_button,
);
if child_proc.borrow().is_some() {
let spawn_result = {
let mut state = state.borrow_mut();
launch_or_restart_client(&child_proc, server_addr.as_ref(), &mut state)
};
match spawn_result {
Ok(()) => {
diagnostics.borrow_mut().record(PerformanceSample {
rtt_ms: 0.0,
input_latency_ms: 0.0,
left_fps: 0.0,
right_fps: 0.0,
dropped_frames: 0,
queue_depth: 0,
});
status_label.set_text(&format!(
"View switched live: {}",
state.borrow().status_line()
));
}
Err(err) => {
let _ = state.borrow_mut().stop_remote();
status_label.set_text(&format!("View switch failed: {err}"));
}
}
} else {
status_label.set_text(&format!("View ready: {}", state.borrow().status_line()));
}
});
}
{
let state = Rc::clone(&state);
let child_proc = Rc::clone(&child_proc);
let diagnostics = Rc::clone(&diagnostics);
let status_label = status_label.clone();
let routing_switch = routing_switch.clone();
let input_toggle_button = input_toggle_button.clone();
let view_toggle_button = view_toggle_button.clone();
let server_addr = Rc::clone(&server_addr);
let input_toggle_button_handle = input_toggle_button.clone();
input_toggle_button_handle.connect_clicked(move |_| {
{
let mut state = state.borrow_mut();
let next = next_input_routing(state.routing);
state.set_routing(next);
routing_switch.set_active(matches!(state.routing, InputRouting::Remote));
}
sync_toggle_button_labels(
&state.borrow(),
&view_toggle_button,
&input_toggle_button,
);
if child_proc.borrow().is_some() {
let spawn_result = {
let mut state = state.borrow_mut();
launch_or_restart_client(&child_proc, server_addr.as_ref(), &mut state)
};
match spawn_result {
Ok(()) => {
diagnostics.borrow_mut().record(PerformanceSample {
rtt_ms: 0.0,
input_latency_ms: 0.0,
left_fps: 0.0,
right_fps: 0.0,
dropped_frames: 0,
queue_depth: 0,
});
status_label.set_text(&format!(
"Input mode switched live: {}",
state.borrow().status_line()
));
}
Err(err) => {
let _ = state.borrow_mut().stop_remote();
status_label.set_text(&format!("Input switch failed: {err}"));
}
}
} else {
status_label.set_text(&format!("Input ready: {}", state.borrow().status_line()));
}
});
}
{ {
let state = Rc::clone(&state); let state = Rc::clone(&state);
let diagnostics = Rc::clone(&diagnostics); let diagnostics = Rc::clone(&diagnostics);
@ -313,6 +430,64 @@ fn spawn_client_process(server_addr: &str, state: &LauncherState) -> Result<Chil
Ok(command.spawn()?) Ok(command.spawn()?)
} }
#[cfg(not(coverage))]
/// Stops and reaps the launcher child process when one is running.
fn stop_child_process(child_proc: &Rc<RefCell<Option<Child>>>) {
if let Some(mut child) = child_proc.borrow_mut().take() {
let _ = child.kill();
let _ = child.wait();
}
}
#[cfg(not(coverage))]
/// Restarts the launcher child process with the current launcher state.
fn launch_or_restart_client(
child_proc: &Rc<RefCell<Option<Child>>>,
server_addr: &str,
state: &mut LauncherState,
) -> Result<()> {
stop_child_process(child_proc);
let _ = state.start_remote();
let child = spawn_client_process(server_addr, state)?;
*child_proc.borrow_mut() = Some(child);
Ok(())
}
#[cfg(not(coverage))]
/// Flips between unified preview and breakout windows.
fn next_view_mode(mode: ViewMode) -> ViewMode {
match mode {
ViewMode::Unified => ViewMode::Breakout,
ViewMode::Breakout => ViewMode::Unified,
}
}
#[cfg(not(coverage))]
/// Flips between remote input capture and local input passthrough.
fn next_input_routing(routing: InputRouting) -> InputRouting {
match routing {
InputRouting::Remote => InputRouting::Local,
InputRouting::Local => InputRouting::Remote,
}
}
#[cfg(not(coverage))]
/// Keeps toggle buttons aligned with the launcher's current state.
fn sync_toggle_button_labels(
state: &LauncherState,
view_toggle_button: &gtk::Button,
input_toggle_button: &gtk::Button,
) {
view_toggle_button.set_label(match state.view_mode {
ViewMode::Unified => "Pop Out Windows",
ViewMode::Breakout => "Merge Windows",
});
input_toggle_button.set_label(match state.routing {
InputRouting::Remote => "Use Local Inputs",
InputRouting::Local => "Use Remote Inputs",
});
}
#[cfg(not(coverage))] #[cfg(not(coverage))]
fn now_unix_seconds() -> u64 { fn now_unix_seconds() -> u64 {
SystemTime::now() SystemTime::now()

View File

@ -1,4 +1,5 @@
// client/src/output/video.rs // client/src/output/video.rs
use crate::output::{display, layout};
use anyhow::Context; use anyhow::Context;
use gstreamer as gst; use gstreamer as gst;
use gstreamer::prelude::{Cast, ElementExt, GstBinExt, ObjectExt}; use gstreamer::prelude::{Cast, ElementExt, GstBinExt, ObjectExt};
@ -8,13 +9,12 @@ use gstreamer_video::prelude::VideoOverlayExt;
use lesavka_common::lesavka::VideoPacket; use lesavka_common::lesavka::VideoPacket;
use std::process::Command; use std::process::Command;
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use crate::output::{display, layout};
pub struct MonitorWindow { pub struct MonitorWindow {
_pipeline: gst::Pipeline, _pipeline: gst::Pipeline,
src: gst_app::AppSrc, src: gst_app::AppSrc,
} }
pub struct UnifiedMonitorWindow { pub struct UnifiedMonitorWindow {
_pipeline: gst::Pipeline, pipeline: gst::Pipeline,
left_src: gst_app::AppSrc, left_src: gst_app::AppSrc,
right_src: gst_app::AppSrc, right_src: gst_app::AppSrc,
} }
@ -331,7 +331,7 @@ impl UnifiedMonitorWindow {
gst::Element::link_many(&[right_src.upcast_ref(), &right_sink])?; gst::Element::link_many(&[right_src.upcast_ref(), &right_sink])?;
pipeline.set_state(gst::State::Playing)?; pipeline.set_state(gst::State::Playing)?;
Ok(Self { Ok(Self {
_pipeline: pipeline, pipeline,
left_src, left_src,
right_src, right_src,
}) })
@ -460,7 +460,7 @@ impl UnifiedMonitorWindow {
pipeline.set_state(gst::State::Playing)?; pipeline.set_state(gst::State::Playing)?;
Ok(Self { Ok(Self {
_pipeline: pipeline, pipeline,
left_src, left_src,
right_src, right_src,
}) })
@ -490,3 +490,10 @@ impl UnifiedMonitorWindow {
let _ = src.push_buffer(buf); let _ = src.push_buffer(buf);
} }
} }
#[allow(clippy::all)]
impl Drop for UnifiedMonitorWindow {
fn drop(&mut self) {
let _ = self.pipeline.set_state(gst::State::Null);
}
}

View File

@ -23,7 +23,7 @@
"client/src/input/inputs.rs": { "client/src/input/inputs.rs": {
"clippy_warnings": 40, "clippy_warnings": 40,
"doc_debt": 9, "doc_debt": 9,
"loc": 467 "loc": 539
}, },
"client/src/input/keyboard.rs": { "client/src/input/keyboard.rs": {
"clippy_warnings": 24, "clippy_warnings": 24,
@ -73,7 +73,7 @@
"client/src/launcher/ui.rs": { "client/src/launcher/ui.rs": {
"clippy_warnings": 4, "clippy_warnings": 4,
"doc_debt": 4, "doc_debt": 4,
"loc": 332 "loc": 507
}, },
"client/src/layout.rs": { "client/src/layout.rs": {
"clippy_warnings": 6, "clippy_warnings": 6,
@ -113,7 +113,7 @@
"client/src/output/video.rs": { "client/src/output/video.rs": {
"clippy_warnings": 36, "clippy_warnings": 36,
"doc_debt": 2, "doc_debt": 2,
"loc": 492 "loc": 499
}, },
"client/src/paste.rs": { "client/src/paste.rs": {
"clippy_warnings": 2, "clippy_warnings": 2,

View File

@ -18,7 +18,7 @@
}, },
"client/src/input/inputs.rs": { "client/src/input/inputs.rs": {
"line_percent": 97.0059880239521, "line_percent": 97.0059880239521,
"loc": 467 "loc": 539
}, },
"client/src/input/keyboard.rs": { "client/src/input/keyboard.rs": {
"line_percent": 95.27559055118111, "line_percent": 95.27559055118111,
@ -54,7 +54,7 @@
}, },
"client/src/launcher/ui.rs": { "client/src/launcher/ui.rs": {
"line_percent": 100.0, "line_percent": 100.0,
"loc": 332 "loc": 507
}, },
"client/src/layout.rs": { "client/src/layout.rs": {
"line_percent": 97.72727272727273, "line_percent": 97.72727272727273,
@ -78,7 +78,7 @@
}, },
"client/src/output/video.rs": { "client/src/output/video.rs": {
"line_percent": 96.11650485436894, "line_percent": 96.11650485436894,
"loc": 492 "loc": 499
}, },
"client/src/paste.rs": { "client/src/paste.rs": {
"line_percent": 96.29629629629629, "line_percent": 96.29629629629629,

View File

@ -26,6 +26,7 @@ mod inputs_contract {
use evdev::uinput::VirtualDevice; use evdev::uinput::VirtualDevice;
use serial_test::serial; use serial_test::serial;
use std::thread; use std::thread;
use temp_env::with_var;
fn open_virtual_device(vdev: &mut VirtualDevice) -> Option<evdev::Device> { fn open_virtual_device(vdev: &mut VirtualDevice) -> Option<evdev::Device> {
for _ in 0..40 { for _ in 0..40 {
@ -260,7 +261,10 @@ mod inputs_contract {
let mut agg = new_aggregator(); let mut agg = new_aggregator();
let result = agg.init(); let result = agg.init();
assert!(result.is_ok(), "init should succeed with virtual input devices"); assert!(
result.is_ok(),
"init should succeed with virtual input devices"
);
assert!( assert!(
!agg.keyboards.is_empty() || !agg.mice.is_empty(), !agg.keyboards.is_empty() || !agg.mice.is_empty(),
"init should discover at least one virtual input device" "init should discover at least one virtual input device"
@ -273,7 +277,10 @@ mod inputs_contract {
agg.pending_kill = true; agg.pending_kill = true;
let result = tokio::time::timeout(Duration::from_millis(200), agg.run()).await; let result = tokio::time::timeout(Duration::from_millis(200), agg.run()).await;
assert!(result.is_ok(), "run should resolve instead of looping forever"); assert!(
result.is_ok(),
"run should resolve instead of looping forever"
);
assert!(result.expect("timeout result").is_ok()); assert!(result.expect("timeout result").is_ok());
assert!(agg.released); assert!(agg.released);
} }
@ -285,7 +292,10 @@ mod inputs_contract {
agg.pending_keys.insert(evdev::KeyCode::KEY_A); agg.pending_keys.insert(evdev::KeyCode::KEY_A);
let result = tokio::time::timeout(Duration::from_millis(200), agg.run()).await; let result = tokio::time::timeout(Duration::from_millis(200), agg.run()).await;
assert!(result.is_ok(), "run should resolve when pending keys are released"); assert!(
result.is_ok(),
"run should resolve when pending keys are released"
);
assert!(result.expect("timeout result").is_ok()); assert!(result.expect("timeout result").is_ok());
assert!(agg.released); assert!(agg.released);
} }
@ -311,20 +321,27 @@ mod inputs_contract {
agg.mice.push(mouse); agg.mice.push(mouse);
agg.toggle_grab(); agg.toggle_grab();
assert!(agg.pending_release, "toggle should enter pending-release mode"); assert!(
agg.pending_release,
"toggle should enter pending-release mode"
);
assert!(!agg.released); assert!(!agg.released);
agg.released = true; agg.released = true;
agg.pending_release = false; agg.pending_release = false;
agg.toggle_grab(); agg.toggle_grab();
assert!(!agg.pending_release, "remote-control toggle clears pending-release"); assert!(
!agg.pending_release,
"remote-control toggle clears pending-release"
);
assert!(!agg.released, "remote-control toggle restores grabbed mode"); assert!(!agg.released, "remote-control toggle restores grabbed mode");
} }
#[tokio::test(flavor = "current_thread")] #[tokio::test(flavor = "current_thread")]
#[serial] #[serial]
async fn run_pending_release_branch_resets_attached_devices() { async fn run_pending_release_branch_resets_attached_devices() {
let Some((_kbd_vdev, kbd_dev)) = build_keyboard_pair("lesavka-input-run-release-kbd") else { let Some((_kbd_vdev, kbd_dev)) = build_keyboard_pair("lesavka-input-run-release-kbd")
else {
return; return;
}; };
let Some((_mouse_vdev, mouse_dev)) = build_mouse_pair("lesavka-input-run-release-mouse") let Some((_mouse_vdev, mouse_dev)) = build_mouse_pair("lesavka-input-run-release-mouse")
@ -343,8 +360,125 @@ mod inputs_contract {
agg.pending_release = true; agg.pending_release = true;
let result = tokio::time::timeout(Duration::from_millis(120), agg.run()).await; let result = tokio::time::timeout(Duration::from_millis(120), agg.run()).await;
assert!(result.is_err(), "run should continue looping after release handling"); assert!(
assert!(agg.released, "pending-release flow should mark local control as released"); result.is_err(),
assert!(!agg.pending_release, "pending-release flow should clear pending flag"); "run should continue looping after release handling"
);
assert!(
agg.released,
"pending-release flow should mark local control as released"
);
assert!(
!agg.pending_release,
"pending-release flow should clear pending flag"
);
}
#[test]
fn quick_toggle_key_parser_handles_supported_aliases_and_disable_switch() {
assert_eq!(
parse_quick_toggle_key("scrolllock"),
Some(evdev::KeyCode::KEY_SCROLLLOCK)
);
assert_eq!(
parse_quick_toggle_key("pause"),
Some(evdev::KeyCode::KEY_PAUSE)
);
assert_eq!(parse_quick_toggle_key("f12"), Some(evdev::KeyCode::KEY_F12));
assert_eq!(parse_quick_toggle_key("off"), None);
assert_eq!(parse_quick_toggle_key("none"), None);
assert_eq!(
parse_quick_toggle_key("definitely-unknown"),
Some(evdev::KeyCode::KEY_SCROLLLOCK)
);
}
#[test]
#[serial]
fn quick_toggle_key_env_defaults_and_respects_explicit_disable() {
with_var("LESAVKA_INPUT_TOGGLE_KEY", None::<&str>, || {
assert_eq!(
quick_toggle_key_from_env(),
Some(evdev::KeyCode::KEY_SCROLLLOCK)
);
});
with_var("LESAVKA_INPUT_TOGGLE_KEY", Some("off"), || {
assert_eq!(quick_toggle_key_from_env(), None);
});
with_var("LESAVKA_INPUT_TOGGLE_KEY", Some("f11"), || {
assert_eq!(quick_toggle_key_from_env(), Some(evdev::KeyCode::KEY_F11));
});
}
#[test]
#[serial]
fn quick_toggle_debounce_env_uses_defaults_and_applies_safety_floor() {
with_var("LESAVKA_INPUT_TOGGLE_DEBOUNCE_MS", None::<&str>, || {
assert_eq!(
quick_toggle_debounce_from_env(),
Duration::from_millis(350)
);
});
with_var("LESAVKA_INPUT_TOGGLE_DEBOUNCE_MS", Some("20"), || {
assert_eq!(quick_toggle_debounce_from_env(), Duration::from_millis(50));
});
with_var("LESAVKA_INPUT_TOGGLE_DEBOUNCE_MS", Some("900"), || {
assert_eq!(
quick_toggle_debounce_from_env(),
Duration::from_millis(900)
);
});
}
#[test]
fn observe_quick_toggle_uses_rising_edge_to_avoid_repeat_toggling() {
let mut agg = new_aggregator();
agg.quick_toggle_debounce = Duration::from_millis(0);
agg.observe_quick_toggle(true);
assert!(
agg.pending_release,
"first quick-toggle should switch from remote to local pending-release mode"
);
assert!(!agg.released);
agg.observe_quick_toggle(true);
assert!(
agg.pending_release,
"holding the quick-toggle key should not retrigger mode switching"
);
agg.released = true;
agg.pending_release = false;
agg.observe_quick_toggle(false);
agg.observe_quick_toggle(true);
assert!(
!agg.released,
"second rising edge should return to remote mode"
);
assert!(
!agg.pending_release,
"remote-mode transition should clear pending release state"
);
}
#[test]
fn observe_quick_toggle_honors_debounce_window() {
let mut agg = new_aggregator();
agg.quick_toggle_debounce = Duration::from_secs(60);
agg.released = true;
agg.pending_release = false;
agg.observe_quick_toggle(true);
assert!(!agg.released, "first edge should switch to remote");
agg.released = true;
agg.pending_release = false;
agg.observe_quick_toggle(false);
agg.observe_quick_toggle(true);
assert!(
agg.released,
"second edge inside debounce window should be ignored"
);
} }
} }

View File

@ -267,7 +267,7 @@ exit 0
.expect("add right appsrc"); .expect("add right appsrc");
let window = UnifiedMonitorWindow { let window = UnifiedMonitorWindow {
_pipeline: pipeline, pipeline,
left_src, left_src,
right_src, right_src,
}; };