launcher: add live pop-out/input toggles and quick handoff
This commit is contained in:
parent
5d37916272
commit
7225ed6a1a
@ -1,10 +1,11 @@
|
||||
// client/src/input/inputs.rs
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
#[cfg(not(coverage))]
|
||||
use anyhow::bail;
|
||||
use anyhow::{Context, Result};
|
||||
use evdev::{AbsoluteAxisCode, Device, EventType, KeyCode, RelativeAxisCode};
|
||||
use std::collections::HashSet;
|
||||
use std::time::Instant;
|
||||
use tokio::{
|
||||
sync::broadcast::Sender,
|
||||
time::{Duration, interval},
|
||||
@ -30,6 +31,10 @@ pub struct InputAggregator {
|
||||
keyboards: Vec<KeyboardAggregator>,
|
||||
mice: Vec<MouseAggregator>,
|
||||
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 {
|
||||
@ -49,6 +54,7 @@ impl InputAggregator {
|
||||
paste_tx: Option<UnboundedSender<String>>,
|
||||
capture_remote_boot: bool,
|
||||
) -> Self {
|
||||
let quick_toggle_key = quick_toggle_key_from_env();
|
||||
Self {
|
||||
kbd_tx,
|
||||
mou_tx,
|
||||
@ -62,6 +68,10 @@ impl InputAggregator {
|
||||
keyboards: Vec::new(),
|
||||
mice: Vec::new(),
|
||||
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 {
|
||||
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 {
|
||||
let chord_released = if self.pending_keys.is_empty() {
|
||||
@ -268,6 +280,8 @@ impl InputAggregator {
|
||||
kbd.process_events();
|
||||
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_left = self.keyboards.iter().any(|k| k.magic_left());
|
||||
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
|
||||
@ -390,7 +431,8 @@ fn classify_device(dev: &Device) -> DeviceKind {
|
||||
let keyset = dev.supported_keys();
|
||||
|
||||
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;
|
||||
}
|
||||
@ -465,3 +507,33 @@ enum DeviceKind {
|
||||
Mouse,
|
||||
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))
|
||||
}
|
||||
|
||||
@ -3,7 +3,9 @@ use anyhow::Result;
|
||||
#[cfg(not(coverage))]
|
||||
use {
|
||||
super::devices::DeviceCatalog,
|
||||
super::diagnostics::{DiagnosticsLog, PerformanceSample, SnapshotReport, quality_probe_command},
|
||||
super::diagnostics::{
|
||||
DiagnosticsLog, PerformanceSample, SnapshotReport, quality_probe_command,
|
||||
},
|
||||
super::runtime_env_vars,
|
||||
super::state::{InputRouting, LauncherState, ViewMode},
|
||||
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 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");
|
||||
button_row.append(&start_button);
|
||||
button_row.append(&stop_button);
|
||||
button_row.append(&view_toggle_button);
|
||||
button_row.append(&input_toggle_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()));
|
||||
probe_hint.set_halign(gtk::Align::Start);
|
||||
@ -152,7 +159,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
root.append(&probe_hint);
|
||||
|
||||
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_halign(gtk::Align::Start);
|
||||
@ -169,6 +176,8 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
let microphone_combo = microphone_combo.clone();
|
||||
let speaker_combo = speaker_combo.clone();
|
||||
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 |_| {
|
||||
{
|
||||
@ -188,6 +197,11 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
state.select_microphone(selected_combo_value(µphone_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() {
|
||||
status_label.set_text("Session already running");
|
||||
@ -196,13 +210,11 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
|
||||
let spawn_result = {
|
||||
let mut state = state.borrow_mut();
|
||||
let _ = state.start_remote();
|
||||
spawn_client_process(server_addr.as_ref(), &state)
|
||||
launch_or_restart_client(&child_proc, server_addr.as_ref(), &mut state)
|
||||
};
|
||||
|
||||
match spawn_result {
|
||||
Ok(child) => {
|
||||
*child_proc.borrow_mut() = Some(child);
|
||||
Ok(()) => {
|
||||
diagnostics.borrow_mut().record(PerformanceSample {
|
||||
rtt_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 status_label = status_label.clone();
|
||||
stop_button.connect_clicked(move |_| {
|
||||
if let Some(mut child) = child_proc.borrow_mut().take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
stop_child_process(&child_proc);
|
||||
let _ = state.borrow_mut().stop_remote();
|
||||
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 diagnostics = Rc::clone(&diagnostics);
|
||||
@ -313,6 +430,64 @@ fn spawn_client_process(server_addr: &str, state: &LauncherState) -> Result<Chil
|
||||
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: >k::Button,
|
||||
input_toggle_button: >k::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))]
|
||||
fn now_unix_seconds() -> u64 {
|
||||
SystemTime::now()
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
// client/src/output/video.rs
|
||||
use crate::output::{display, layout};
|
||||
use anyhow::Context;
|
||||
use gstreamer as gst;
|
||||
use gstreamer::prelude::{Cast, ElementExt, GstBinExt, ObjectExt};
|
||||
@ -8,13 +9,12 @@ use gstreamer_video::prelude::VideoOverlayExt;
|
||||
use lesavka_common::lesavka::VideoPacket;
|
||||
use std::process::Command;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use crate::output::{display, layout};
|
||||
pub struct MonitorWindow {
|
||||
_pipeline: gst::Pipeline,
|
||||
src: gst_app::AppSrc,
|
||||
}
|
||||
pub struct UnifiedMonitorWindow {
|
||||
_pipeline: gst::Pipeline,
|
||||
pipeline: gst::Pipeline,
|
||||
left_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])?;
|
||||
pipeline.set_state(gst::State::Playing)?;
|
||||
Ok(Self {
|
||||
_pipeline: pipeline,
|
||||
pipeline,
|
||||
left_src,
|
||||
right_src,
|
||||
})
|
||||
@ -460,7 +460,7 @@ impl UnifiedMonitorWindow {
|
||||
pipeline.set_state(gst::State::Playing)?;
|
||||
|
||||
Ok(Self {
|
||||
_pipeline: pipeline,
|
||||
pipeline,
|
||||
left_src,
|
||||
right_src,
|
||||
})
|
||||
@ -490,3 +490,10 @@ impl UnifiedMonitorWindow {
|
||||
let _ = src.push_buffer(buf);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::all)]
|
||||
impl Drop for UnifiedMonitorWindow {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.pipeline.set_state(gst::State::Null);
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,7 +23,7 @@
|
||||
"client/src/input/inputs.rs": {
|
||||
"clippy_warnings": 40,
|
||||
"doc_debt": 9,
|
||||
"loc": 467
|
||||
"loc": 539
|
||||
},
|
||||
"client/src/input/keyboard.rs": {
|
||||
"clippy_warnings": 24,
|
||||
@ -73,7 +73,7 @@
|
||||
"client/src/launcher/ui.rs": {
|
||||
"clippy_warnings": 4,
|
||||
"doc_debt": 4,
|
||||
"loc": 332
|
||||
"loc": 507
|
||||
},
|
||||
"client/src/layout.rs": {
|
||||
"clippy_warnings": 6,
|
||||
@ -113,7 +113,7 @@
|
||||
"client/src/output/video.rs": {
|
||||
"clippy_warnings": 36,
|
||||
"doc_debt": 2,
|
||||
"loc": 492
|
||||
"loc": 499
|
||||
},
|
||||
"client/src/paste.rs": {
|
||||
"clippy_warnings": 2,
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
},
|
||||
"client/src/input/inputs.rs": {
|
||||
"line_percent": 97.0059880239521,
|
||||
"loc": 467
|
||||
"loc": 539
|
||||
},
|
||||
"client/src/input/keyboard.rs": {
|
||||
"line_percent": 95.27559055118111,
|
||||
@ -54,7 +54,7 @@
|
||||
},
|
||||
"client/src/launcher/ui.rs": {
|
||||
"line_percent": 100.0,
|
||||
"loc": 332
|
||||
"loc": 507
|
||||
},
|
||||
"client/src/layout.rs": {
|
||||
"line_percent": 97.72727272727273,
|
||||
@ -78,7 +78,7 @@
|
||||
},
|
||||
"client/src/output/video.rs": {
|
||||
"line_percent": 96.11650485436894,
|
||||
"loc": 492
|
||||
"loc": 499
|
||||
},
|
||||
"client/src/paste.rs": {
|
||||
"line_percent": 96.29629629629629,
|
||||
|
||||
@ -26,6 +26,7 @@ mod inputs_contract {
|
||||
use evdev::uinput::VirtualDevice;
|
||||
use serial_test::serial;
|
||||
use std::thread;
|
||||
use temp_env::with_var;
|
||||
|
||||
fn open_virtual_device(vdev: &mut VirtualDevice) -> Option<evdev::Device> {
|
||||
for _ in 0..40 {
|
||||
@ -260,7 +261,10 @@ mod inputs_contract {
|
||||
|
||||
let mut agg = new_aggregator();
|
||||
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!(
|
||||
!agg.keyboards.is_empty() || !agg.mice.is_empty(),
|
||||
"init should discover at least one virtual input device"
|
||||
@ -273,7 +277,10 @@ mod inputs_contract {
|
||||
agg.pending_kill = true;
|
||||
|
||||
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!(agg.released);
|
||||
}
|
||||
@ -285,7 +292,10 @@ mod inputs_contract {
|
||||
agg.pending_keys.insert(evdev::KeyCode::KEY_A);
|
||||
|
||||
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!(agg.released);
|
||||
}
|
||||
@ -311,20 +321,27 @@ mod inputs_contract {
|
||||
agg.mice.push(mouse);
|
||||
|
||||
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);
|
||||
|
||||
agg.released = true;
|
||||
agg.pending_release = false;
|
||||
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");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[serial]
|
||||
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;
|
||||
};
|
||||
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;
|
||||
|
||||
let result = tokio::time::timeout(Duration::from_millis(120), agg.run()).await;
|
||||
assert!(result.is_err(), "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");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -267,7 +267,7 @@ exit 0
|
||||
.expect("add right appsrc");
|
||||
|
||||
let window = UnifiedMonitorWindow {
|
||||
_pipeline: pipeline,
|
||||
pipeline,
|
||||
left_src,
|
||||
right_src,
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user