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
|
// 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))
|
||||||
|
}
|
||||||
|
|||||||
@ -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(µphone_combo));
|
state.select_microphone(selected_combo_value(µphone_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: >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))]
|
#[cfg(not(coverage))]
|
||||||
fn now_unix_seconds() -> u64 {
|
fn now_unix_seconds() -> u64 {
|
||||||
SystemTime::now()
|
SystemTime::now()
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user