diff --git a/client/src/input/inputs.rs b/client/src/input/inputs.rs index 3492508..faa33bf 100644 --- a/client/src/input/inputs.rs +++ b/client/src/input/inputs.rs @@ -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, mice: Vec, capture_remote_boot: bool, + quick_toggle_key: Option, + quick_toggle_down: bool, + quick_toggle_debounce: Duration, + last_quick_toggle_at: Option, } impl InputAggregator { @@ -49,6 +54,7 @@ impl InputAggregator { paste_tx: Option>, 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 { + 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 { + 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::().ok()) + .unwrap_or(350); + Duration::from_millis(millis.max(50)) +} diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index e89f948..f9e81bc 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -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>>) { + 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>>, + 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() diff --git a/client/src/output/video.rs b/client/src/output/video.rs index 257ea70..9b5b897 100644 --- a/client/src/output/video.rs +++ b/client/src/output/video.rs @@ -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); + } +} diff --git a/scripts/ci/hygiene_gate_baseline.json b/scripts/ci/hygiene_gate_baseline.json index 43a08e2..0dab04d 100644 --- a/scripts/ci/hygiene_gate_baseline.json +++ b/scripts/ci/hygiene_gate_baseline.json @@ -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, diff --git a/scripts/ci/quality_gate_baseline.json b/scripts/ci/quality_gate_baseline.json index 2272f7b..2e79647 100644 --- a/scripts/ci/quality_gate_baseline.json +++ b/scripts/ci/quality_gate_baseline.json @@ -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, diff --git a/testing/tests/client_inputs_contract.rs b/testing/tests/client_inputs_contract.rs index 66d9a50..7d7be1f 100644 --- a/testing/tests/client_inputs_contract.rs +++ b/testing/tests/client_inputs_contract.rs @@ -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 { 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" + ); } } diff --git a/testing/tests/client_output_video_include_contract.rs b/testing/tests/client_output_video_include_contract.rs index f6c638a..f775fda 100644 --- a/testing/tests/client_output_video_include_contract.rs +++ b/testing/tests/client_output_video_include_contract.rs @@ -267,7 +267,7 @@ exit 0 .expect("add right appsrc"); let window = UnifiedMonitorWindow { - _pipeline: pipeline, + pipeline, left_src, right_src, };