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
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))
}

View File

@ -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(&microphone_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: &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))]
fn now_unix_seconds() -> u64 {
SystemTime::now()

View File

@ -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);
}
}

View File

@ -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,

View File

@ -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,

View File

@ -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"
);
}
}

View File

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