diff --git a/client/src/input/inputs.rs b/client/src/input/inputs.rs index eceeec4..43e3ba2 100644 --- a/client/src/input/inputs.rs +++ b/client/src/input/inputs.rs @@ -594,20 +594,114 @@ fn quick_toggle_key_from_env() -> Option { /// 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(); + if matches!(normalized.as_str(), "" | "off" | "none" | "disabled") { + return None; + } + + if let Some(letter) = parse_quick_toggle_letter(&normalized) { + return Some(letter); + } + + if let Some(digit) = parse_quick_toggle_digit(&normalized) { + return Some(digit); + } + + if let Some(function) = parse_quick_toggle_function_key(&normalized) { + return Some(function); + } + match normalized.as_str() { - "" | "off" | "none" | "disabled" => None, "scrolllock" | "scroll_lock" | "scroll-lock" => Some(KeyCode::KEY_SCROLLLOCK), "sysrq" | "sysreq" | "prtsc" | "printscreen" | "print_screen" | "print-screen" => { Some(KeyCode::KEY_SYSRQ) } "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), + "escape" | "esc" => Some(KeyCode::KEY_ESC), + "tab" => Some(KeyCode::KEY_TAB), + "capslock" | "caps_lock" | "caps-lock" => Some(KeyCode::KEY_CAPSLOCK), + "backspace" | "back_space" | "back-space" => Some(KeyCode::KEY_BACKSPACE), + "space" | "spacebar" => Some(KeyCode::KEY_SPACE), + "enter" | "return" => Some(KeyCode::KEY_ENTER), + "insert" => Some(KeyCode::KEY_INSERT), + "delete" | "del" => Some(KeyCode::KEY_DELETE), + "home" => Some(KeyCode::KEY_HOME), + "end" => Some(KeyCode::KEY_END), + "pageup" | "page_up" | "page-up" => Some(KeyCode::KEY_PAGEUP), + "pagedown" | "page_down" | "page-down" => Some(KeyCode::KEY_PAGEDOWN), + "left" => Some(KeyCode::KEY_LEFT), + "right" => Some(KeyCode::KEY_RIGHT), + "up" => Some(KeyCode::KEY_UP), + "down" => Some(KeyCode::KEY_DOWN), _ => Some(KeyCode::KEY_PAUSE), } } +fn parse_quick_toggle_letter(raw: &str) -> Option { + match raw { + "a" => Some(KeyCode::KEY_A), + "b" => Some(KeyCode::KEY_B), + "c" => Some(KeyCode::KEY_C), + "d" => Some(KeyCode::KEY_D), + "e" => Some(KeyCode::KEY_E), + "f" => Some(KeyCode::KEY_F), + "g" => Some(KeyCode::KEY_G), + "h" => Some(KeyCode::KEY_H), + "i" => Some(KeyCode::KEY_I), + "j" => Some(KeyCode::KEY_J), + "k" => Some(KeyCode::KEY_K), + "l" => Some(KeyCode::KEY_L), + "m" => Some(KeyCode::KEY_M), + "n" => Some(KeyCode::KEY_N), + "o" => Some(KeyCode::KEY_O), + "p" => Some(KeyCode::KEY_P), + "q" => Some(KeyCode::KEY_Q), + "r" => Some(KeyCode::KEY_R), + "s" => Some(KeyCode::KEY_S), + "t" => Some(KeyCode::KEY_T), + "u" => Some(KeyCode::KEY_U), + "v" => Some(KeyCode::KEY_V), + "w" => Some(KeyCode::KEY_W), + "x" => Some(KeyCode::KEY_X), + "y" => Some(KeyCode::KEY_Y), + "z" => Some(KeyCode::KEY_Z), + _ => None, + } +} + +fn parse_quick_toggle_digit(raw: &str) -> Option { + match raw { + "1" => Some(KeyCode::KEY_1), + "2" => Some(KeyCode::KEY_2), + "3" => Some(KeyCode::KEY_3), + "4" => Some(KeyCode::KEY_4), + "5" => Some(KeyCode::KEY_5), + "6" => Some(KeyCode::KEY_6), + "7" => Some(KeyCode::KEY_7), + "8" => Some(KeyCode::KEY_8), + "9" => Some(KeyCode::KEY_9), + "0" => Some(KeyCode::KEY_0), + _ => None, + } +} + +fn parse_quick_toggle_function_key(raw: &str) -> Option { + match raw { + "f1" => Some(KeyCode::KEY_F1), + "f2" => Some(KeyCode::KEY_F2), + "f3" => Some(KeyCode::KEY_F3), + "f4" => Some(KeyCode::KEY_F4), + "f5" => Some(KeyCode::KEY_F5), + "f6" => Some(KeyCode::KEY_F6), + "f7" => Some(KeyCode::KEY_F7), + "f8" => Some(KeyCode::KEY_F8), + "f9" => Some(KeyCode::KEY_F9), + "f10" => Some(KeyCode::KEY_F10), + "f11" => Some(KeyCode::KEY_F11), + "f12" => Some(KeyCode::KEY_F12), + _ => None, + } +} + /// 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") @@ -671,3 +765,37 @@ fn path_marker(path: &Path) -> u128 { .map(|duration| duration.as_millis()) .unwrap_or_default() } + +#[cfg(test)] +mod tests { + use super::parse_quick_toggle_key; + use evdev::KeyCode; + + #[test] + fn parse_quick_toggle_key_supports_letters_digits_and_function_keys() { + assert_eq!(parse_quick_toggle_key("a"), Some(KeyCode::KEY_A)); + assert_eq!(parse_quick_toggle_key("7"), Some(KeyCode::KEY_7)); + assert_eq!(parse_quick_toggle_key("f12"), Some(KeyCode::KEY_F12)); + assert_eq!(parse_quick_toggle_key("F3"), Some(KeyCode::KEY_F3)); + } + + #[test] + fn parse_quick_toggle_key_supports_navigation_and_special_aliases() { + assert_eq!(parse_quick_toggle_key("page_up"), Some(KeyCode::KEY_PAGEUP)); + assert_eq!(parse_quick_toggle_key("delete"), Some(KeyCode::KEY_DELETE)); + assert_eq!(parse_quick_toggle_key("spacebar"), Some(KeyCode::KEY_SPACE)); + assert_eq!( + parse_quick_toggle_key("print-screen"), + Some(KeyCode::KEY_SYSRQ) + ); + } + + #[test] + fn parse_quick_toggle_key_can_disable_or_fall_back() { + assert_eq!(parse_quick_toggle_key("off"), None); + assert_eq!( + parse_quick_toggle_key("totally-unknown"), + Some(KeyCode::KEY_PAUSE) + ); + } +} diff --git a/client/src/launcher/state.rs b/client/src/launcher/state.rs index d2da02e..ecd348f 100644 --- a/client/src/launcher/state.rs +++ b/client/src/launcher/state.rs @@ -83,6 +83,8 @@ pub struct LauncherState { pub view_mode: ViewMode, pub displays: [DisplaySurface; 2], pub devices: DeviceSelection, + pub swap_key: String, + pub swap_key_binding: bool, pub capture_power: CapturePowerStatus, pub remote_active: bool, pub notes: Vec, @@ -95,6 +97,8 @@ impl Default for LauncherState { view_mode: ViewMode::Unified, displays: [DisplaySurface::Preview, DisplaySurface::Preview], devices: DeviceSelection::default(), + swap_key: "pause".to_string(), + swap_key_binding: false, capture_power: CapturePowerStatus::default(), remote_active: false, notes: Vec::new(), @@ -172,6 +176,18 @@ impl LauncherState { } } + pub fn set_swap_key(&mut self, swap_key: impl Into) { + self.swap_key = normalize_swap_key(swap_key.into()); + } + + pub fn begin_swap_key_binding(&mut self) { + self.swap_key_binding = true; + } + + pub fn finish_swap_key_binding(&mut self) { + self.swap_key_binding = false; + } + pub fn start_remote(&mut self) -> bool { if self.remote_active { return false; @@ -198,7 +214,7 @@ impl LauncherState { pub fn status_line(&self) -> String { format!( - "mode={} view={} active={} power={} d1={} d2={} camera={} mic={} speaker={}", + "mode={} view={} active={} power={} d1={} d2={} camera={} mic={} speaker={} swap={}", match self.routing { InputRouting::Local => "local", InputRouting::Remote => "remote", @@ -218,6 +234,7 @@ impl LauncherState { self.devices.camera.as_deref().unwrap_or("auto"), self.devices.microphone.as_deref().unwrap_or("auto"), self.devices.speaker.as_deref().unwrap_or("auto"), + self.swap_key, ) } } @@ -233,6 +250,15 @@ fn normalize_selection(value: Option) -> Option { }) } +fn normalize_swap_key(value: String) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + "off".to_string() + } else { + trimmed.to_ascii_lowercase() + } +} + #[cfg(test)] mod tests { use super::*; @@ -357,4 +383,32 @@ mod tests { assert_eq!(state.capture_power.active_leases, 2); assert!(state.status_line().contains("power=on")); } + + #[test] + fn swap_key_binding_tracks_selected_key_and_binding_mode() { + let mut state = LauncherState::new(); + assert_eq!(state.swap_key, "pause"); + assert!(!state.swap_key_binding); + + state.begin_swap_key_binding(); + assert!(state.swap_key_binding); + + state.set_swap_key("F8"); + assert_eq!(state.swap_key, "f8"); + + state.set_swap_key(" "); + assert_eq!(state.swap_key, "off"); + + state.finish_swap_key_binding(); + assert!(!state.swap_key_binding); + } + + #[test] + fn push_note_accumulates_operator_context() { + let mut state = LauncherState::new(); + state.push_note("preview warm"); + state.push_note("relay linked"); + + assert_eq!(state.notes, vec!["preview warm", "relay linked"]); + } } diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index 44db565..78a0729 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -11,11 +11,12 @@ use { super::state::{CapturePowerStatus, DisplaySurface, InputRouting, LauncherState}, super::ui_components::build_launcher_view, super::ui_runtime::{ - dock_display_to_preview, input_control_path, input_state_path, next_input_routing, - open_popout_window, path_marker, read_input_routing_state, reap_exited_child, - refresh_launcher_ui, refresh_test_buttons, routing_name, selected_combo_value, - selected_server_addr, selected_toggle_key, spawn_client_process, stop_child_process, - update_test_action_result, write_input_routing_request, RelayChild, + RelayChild, capture_swap_key, dock_display_to_preview, input_control_path, + input_state_path, next_input_routing, open_popout_window, path_marker, + read_input_routing_state, reap_exited_child, refresh_launcher_ui, refresh_test_buttons, + routing_name, selected_combo_value, selected_server_addr, spawn_client_process, + stop_child_process, toggle_key_label, update_test_action_result, + write_input_routing_request, }, gtk::glib, gtk::prelude::*, @@ -31,6 +32,11 @@ enum PowerMessage { Command(std::result::Result), } +#[cfg(not(coverage))] +enum RelayMessage { + Spawned(std::result::Result), +} + #[cfg(not(coverage))] fn request_capture_power_refresh( power_tx: std::sync::mpsc::Sender, @@ -50,8 +56,12 @@ fn request_capture_power_refresh( fn disconnected_capture_note(mode: &str) -> &'static str { match mode { "forced-on" => "Relay disconnected. Capture is still forced on for staging.", - "forced-off" => "Relay disconnected. Capture stays intentionally dark until you return to Auto or Force On.", - _ => "Relay disconnected. The server will hold capture briefly, then let it return to standby.", + "forced-off" => { + "Relay disconnected. Capture stays intentionally dark until you return to Auto or Force On." + } + _ => { + "Relay disconnected. The server will hold capture briefly, then let it return to standby." + } } } @@ -133,6 +143,8 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let (power_tx, power_rx) = std::sync::mpsc::channel::(); let power_request_in_flight = Rc::new(Cell::new(false)); + let (relay_tx, relay_rx) = std::sync::mpsc::channel::(); + let relay_request_in_flight = Rc::new(Cell::new(false)); { let state = Rc::clone(&state); @@ -228,11 +240,16 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let server_addr_fallback = Rc::clone(&server_addr); let preview = preview.clone(); let power_tx = power_tx.clone(); + let relay_tx = relay_tx.clone(); + let relay_request_in_flight = Rc::clone(&relay_request_in_flight); let start_button = widgets.start_button.clone(); let widgets_handle = widgets.clone(); start_button.connect_clicked(move |_| { let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref()); + if relay_request_in_flight.get() { + return; + } if child_proc.borrow().is_some() { stop_child_process(&child_proc); let power_mode = { @@ -269,63 +286,31 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let _ = std::fs::remove_file(input_control_path.as_path()); let _ = std::fs::remove_file(input_state_path.as_path()); let launch_state = state.borrow().clone(); - let input_toggle_key = selected_toggle_key(&widgets.toggle_key_combo); - match spawn_client_process( - &server_addr, - &launch_state, - &input_toggle_key, - input_control_path.as_path(), - input_state_path.as_path(), - ) { - Ok(child) => { - *child_proc.borrow_mut() = Some(child); - let _ = state.borrow_mut().start_remote(); - if let Some(preview) = preview.as_ref() { - preview.set_server_addr(server_addr.clone()); - preview.set_session_active(true); - } - let routing = routing_name(state.borrow().routing); - let power_mode = state.borrow().capture_power.mode.clone(); - let message = match power_mode.as_str() { - "forced-off" => format!( - "Relay connected with inputs routed to {}, but capture is forced off. Return capture to Auto or Force On when you want remote video.", - routing - ), - "forced-on" => format!( - "Relay connected with inputs routed to {}. Capture is being held awake and the eye previews are coming online.", - routing - ), - _ => format!( - "Relay connected with inputs routed to {}. The eye previews will come up with the live session.", - routing - ), - }; - widgets_handle.status_label.set_text(&message); - request_capture_power_refresh( - power_tx.clone(), - server_addr.clone(), - Duration::from_millis(250), - ); - request_capture_power_refresh( - power_tx.clone(), - server_addr, - Duration::from_millis(1250), - ); - } - Err(err) => { - if let Some(preview) = preview.as_ref() { - preview.set_session_active(false); - } - widgets_handle - .status_label - .set_text(&format!("Relay start failed: {err}")); - } - } + let input_toggle_key = launch_state.swap_key.clone(); + let input_control_path = input_control_path.as_ref().clone(); + let input_state_path = input_state_path.as_ref().clone(); + relay_request_in_flight.set(true); + widgets_handle.status_label.set_text(&format!( + "Connecting relay with {} as the swap key...", + toggle_key_label(&input_toggle_key) + )); refresh_launcher_ui( &widgets_handle, &state.borrow(), child_proc.borrow().is_some(), ); + let relay_tx = relay_tx.clone(); + std::thread::spawn(move || { + let result = spawn_client_process( + &server_addr, + &launch_state, + &input_toggle_key, + input_control_path.as_path(), + input_state_path.as_path(), + ) + .map_err(|err| err.to_string()); + let _ = relay_tx.send(RelayMessage::Spawned(result)); + }); }); } @@ -364,6 +349,20 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { }); } + { + let state = Rc::clone(&state); + let child_proc = Rc::clone(&child_proc); + let widgets = widgets.clone(); + let swap_key_button = widgets.swap_key_button.clone(); + swap_key_button.connect_clicked(move |_| { + state.borrow_mut().begin_swap_key_binding(); + widgets + .status_label + .set_text("Press a single key now to make it the swap shortcut."); + refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); + }); + } + { let child_proc = Rc::clone(&child_proc); let widgets = widgets.clone(); @@ -620,6 +619,52 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { }); } + { + let state = Rc::clone(&state); + let child_proc = Rc::clone(&child_proc); + let widgets = widgets.clone(); + let key_controller = gtk::EventControllerKey::new(); + key_controller.connect_key_pressed(move |_, key, _, _| { + if !state.borrow().swap_key_binding { + return glib::Propagation::Proceed; + } + + let Some(swap_key) = capture_swap_key(key) else { + widgets.status_label.set_text( + "That key is not a good swap shortcut. Try a letter, digit, function key, or navigation key.", + ); + refresh_launcher_ui( + &widgets, + &state.borrow(), + child_proc.borrow().is_some(), + ); + return glib::Propagation::Stop; + }; + + let relay_live = child_proc.borrow().is_some() || state.borrow().remote_active; + { + let mut state = state.borrow_mut(); + state.set_swap_key(swap_key.clone()); + state.finish_swap_key_binding(); + } + let status_message = if relay_live { + format!( + "Swap key set to {}. Disconnect and reconnect the relay to use it live.", + toggle_key_label(&swap_key) + ) + } else { + format!( + "Swap key set to {}. The next relay launch will use it.", + toggle_key_label(&swap_key) + ) + }; + widgets.status_label.set_text(&status_message); + refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); + glib::Propagation::Stop + }); + window.add_controller(key_controller); + } + { let window = window.clone(); let state = Rc::clone(&state); @@ -635,6 +680,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let last_state_marker = Rc::new(RefCell::new(path_marker(input_state_path.as_path()))); let power_request_in_flight = Rc::clone(&power_request_in_flight); + let relay_request_in_flight = Rc::clone(&relay_request_in_flight); let preview = preview.clone(); let power_tx = power_tx.clone(); glib::timeout_add_local(Duration::from_millis(180), move || { @@ -688,6 +734,57 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { window.present(); } + while let Ok(message) = relay_rx.try_recv() { + relay_request_in_flight.set(false); + match message { + RelayMessage::Spawned(Ok(child)) => { + *child_proc.borrow_mut() = Some(child); + let _ = state.borrow_mut().start_remote(); + let server_addr = + selected_server_addr(&server_entry, server_addr_fallback.as_ref()); + if let Some(preview) = preview.as_ref() { + preview.set_server_addr(server_addr.clone()); + preview.set_session_active(true); + } + let routing = routing_name(state.borrow().routing); + let power_mode = state.borrow().capture_power.mode.clone(); + let message = match power_mode.as_str() { + "forced-off" => format!( + "Relay connected with inputs routed to {}, but capture is forced off. Return capture to Auto or Force On when you want remote video.", + routing + ), + "forced-on" => format!( + "Relay connected with inputs routed to {}. Capture is being held awake and the eye previews are coming online.", + routing + ), + _ => format!( + "Relay connected with inputs routed to {}. The eye previews will come up with the live session.", + routing + ), + }; + widgets.status_label.set_text(&message); + request_capture_power_refresh( + power_tx.clone(), + server_addr.clone(), + Duration::from_millis(250), + ); + request_capture_power_refresh( + power_tx.clone(), + server_addr, + Duration::from_millis(1250), + ); + } + RelayMessage::Spawned(Err(err)) => { + if let Some(preview) = preview.as_ref() { + preview.set_session_active(false); + } + widgets + .status_label + .set_text(&format!("Relay start failed: {err}")); + } + } + } + while let Ok(message) = power_rx.try_recv() { power_request_in_flight.set(false); match message { diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index 97c4bd3..724c626 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -51,7 +51,7 @@ pub struct LauncherWidgets { pub input_toggle_button: gtk::Button, pub clipboard_button: gtk::Button, pub probe_button: gtk::Button, - pub toggle_key_combo: gtk::ComboBoxText, + pub swap_key_button: gtk::Button, pub camera_test_button: gtk::Button, pub microphone_test_button: gtk::Button, pub speaker_test_button: gtk::Button, @@ -220,23 +220,15 @@ pub fn build_launcher_view( input_toggle_button.set_tooltip_text(Some( "Switch live keyboard and mouse ownership between the local machine and the remote target.", )); - let swap_label = gtk::Label::new(Some("Swap key")); - swap_label.set_halign(gtk::Align::Start); - let toggle_key_combo = gtk::ComboBoxText::new(); - toggle_key_combo.append(Some("scrolllock"), "Scroll Lock"); - toggle_key_combo.append(Some("sysrq"), "SysRq / PrtSc"); - toggle_key_combo.append(Some("pause"), "Pause"); - toggle_key_combo.append(Some("f12"), "F12"); - toggle_key_combo.append(Some("f11"), "F11"); - toggle_key_combo.append(Some("f10"), "F10"); - toggle_key_combo.append(Some("off"), "Disabled"); - let _ = toggle_key_combo.set_active_id(Some("pause")); - toggle_key_combo.set_tooltip_text(Some( - "Single-key live input swap while the relay is running.", + let swap_key_button = gtk::Button::with_label(&format!( + "Set Swap Key ({})", + super::ui_runtime::toggle_key_label(&state.swap_key) + )); + swap_key_button.set_tooltip_text(Some( + "Press this, then hit one keyboard key to make it the live local/remote input swap shortcut.", )); routing_row.append(&input_toggle_button); - routing_row.append(&swap_label); - routing_row.append(&toggle_key_combo); + routing_row.append(&swap_key_button); routing_body.append(&routing_row); sidebar.append(&routing_panel); @@ -442,7 +434,7 @@ pub fn build_launcher_view( input_toggle_button: input_toggle_button.clone(), clipboard_button: clipboard_button.clone(), probe_button: probe_button.clone(), - toggle_key_combo: toggle_key_combo.clone(), + swap_key_button: swap_key_button.clone(), camera_test_button: camera_test_button.clone(), microphone_test_button: microphone_test_button.clone(), speaker_test_button: speaker_test_button.clone(), diff --git a/client/src/launcher/ui_runtime.rs b/client/src/launcher/ui_runtime.rs index 09f3922..a51e8a9 100644 --- a/client/src/launcher/ui_runtime.rs +++ b/client/src/launcher/ui_runtime.rs @@ -1,20 +1,20 @@ use anyhow::Result; -use gtk::gio; use gtk::{glib, prelude::*}; use std::{ cell::RefCell, path::{Path, PathBuf}, + process::{Child, Command}, rc::Rc, }; use super::{ + LAUNCHER_FOCUS_SIGNAL_ENV, device_test::{DeviceTestController, DeviceTestKind}, launcher_focus_signal_path, preview::LauncherPreview, runtime_env_vars, state::{CapturePowerStatus, DisplaySurface, InputRouting, LauncherState}, ui_components::{DisplayPaneWidgets, LauncherWidgets, PopoutWindowHandle}, - LAUNCHER_FOCUS_SIGNAL_ENV, }; pub const INPUT_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_INPUT_CONTROL"; @@ -22,7 +22,7 @@ pub const INPUT_STATE_ENV: &str = "LESAVKA_LAUNCHER_INPUT_STATE"; pub const DEFAULT_INPUT_CONTROL_PATH: &str = "/tmp/lesavka-launcher-input.control"; pub const DEFAULT_INPUT_STATE_PATH: &str = "/tmp/lesavka-launcher-input.state"; -pub type RelayChild = gio::Subprocess; +pub type RelayChild = Child; pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, child_running: bool) { let relay_live = child_running || state.remote_active; @@ -46,7 +46,7 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi widgets .summary .shortcut_value - .set_text(&selected_toggle_key_label(&widgets.toggle_key_combo)); + .set_text(&toggle_key_label(&state.swap_key)); widgets .power_detail @@ -78,6 +78,12 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi InputRouting::Remote => "Route Inputs To Local", InputRouting::Local => "Route Inputs To Remote", }); + let swap_key_label = if state.swap_key_binding { + "Press Any Key…".to_string() + } else { + format!("Set Swap Key ({})", toggle_key_label(&state.swap_key)) + }; + widgets.swap_key_button.set_label(&swap_key_label); let power_available = state.capture_power.available; widgets .power_auto_button @@ -484,20 +490,6 @@ pub fn selected_combo_value(combo: >k::ComboBoxText) -> Option { }) } -pub fn selected_toggle_key(combo: >k::ComboBoxText) -> String { - combo - .active_id() - .map(|value| value.to_string()) - .unwrap_or_else(|| "pause".to_string()) -} - -pub fn selected_toggle_key_label(combo: >k::ComboBoxText) -> String { - combo - .active_text() - .map(|value| value.to_string()) - .unwrap_or_else(|| "Pause".to_string()) -} - pub fn selected_server_addr(entry: >k::Entry, fallback: &str) -> String { let current = entry.text(); let trimmed = current.trim(); @@ -557,6 +549,80 @@ pub fn set_combo_active_text(combo: >k::ComboBoxText, wanted: Option<&str>) { } } +pub fn toggle_key_label(raw: &str) -> String { + match raw.trim().to_ascii_lowercase().as_str() { + "" | "off" | "none" | "disabled" => "Disabled".to_string(), + "scrolllock" | "scroll_lock" | "scroll-lock" => "Scroll Lock".to_string(), + "sysrq" | "sysreq" | "prtsc" | "printscreen" | "print_screen" | "print-screen" => { + "SysRq / PrtSc".to_string() + } + "pause" | "pausebreak" | "pause_break" | "pause-break" => "Pause".to_string(), + "pageup" | "page_up" | "page-up" => "Page Up".to_string(), + "pagedown" | "page_down" | "page-down" => "Page Down".to_string(), + "capslock" | "caps_lock" | "caps-lock" => "Caps Lock".to_string(), + "backspace" | "back_space" | "back-space" => "Backspace".to_string(), + "space" | "spacebar" => "Space".to_string(), + "escape" | "esc" => "Escape".to_string(), + value + if value.starts_with('f') + && value.len() <= 3 + && value[1..].chars().all(|ch| ch.is_ascii_digit()) => + { + value.to_ascii_uppercase() + } + value if value.len() == 1 => value.to_ascii_uppercase(), + value => capitalize(&value.replace('_', " ").replace('-', " ")), + } +} + +pub fn capture_swap_key(key: gtk::gdk::Key) -> Option { + let normalized_name = key.name()?.to_string().to_ascii_lowercase(); + match normalized_name.as_str() { + "shift_l" | "shift_r" | "control_l" | "control_r" | "alt_l" | "alt_r" | "super_l" + | "super_r" | "meta_l" | "meta_r" | "hyper_l" | "hyper_r" | "iso_level3_shift" + | "multi_key" => None, + "scroll_lock" => Some("scrolllock".to_string()), + "sys_req" | "print" => Some("sysrq".to_string()), + "pause" | "break" => Some("pause".to_string()), + "page_up" => Some("pageup".to_string()), + "page_down" => Some("pagedown".to_string()), + "caps_lock" => Some("capslock".to_string()), + "backspace" => Some("backspace".to_string()), + "return" => Some("enter".to_string()), + "space" => Some("space".to_string()), + "escape" => Some("escape".to_string()), + "kp_0" => Some("0".to_string()), + "kp_1" => Some("1".to_string()), + "kp_2" => Some("2".to_string()), + "kp_3" => Some("3".to_string()), + "kp_4" => Some("4".to_string()), + "kp_5" => Some("5".to_string()), + "kp_6" => Some("6".to_string()), + "kp_7" => Some("7".to_string()), + "kp_8" => Some("8".to_string()), + "kp_9" => Some("9".to_string()), + other + if other.starts_with('f') + && other.len() <= 3 + && other[1..].chars().all(|ch| ch.is_ascii_digit()) => + { + Some(other.to_string()) + } + other if other.len() == 1 => { + let ch = other.chars().next()?; + if ch.is_ascii_alphanumeric() { + Some(ch.to_ascii_lowercase().to_string()) + } else { + None + } + } + "insert" | "delete" | "home" | "end" | "left" | "right" | "up" | "down" | "tab" => { + Some(normalized_name) + } + _ => None, + } +} + pub fn spawn_client_process( server_addr: &str, state: &LauncherState, @@ -565,47 +631,41 @@ pub fn spawn_client_process( input_state_path: &Path, ) -> Result { let exe = std::env::current_exe()?; - let launcher = gio::SubprocessLauncher::new(gio::SubprocessFlags::NONE); - launcher.setenv("LESAVKA_LAUNCHER_CHILD", "1", true); - launcher.setenv("LESAVKA_SERVER_ADDR", server_addr, true); - launcher.setenv("LESAVKA_INPUT_TOGGLE_KEY", input_toggle_key, true); - launcher.setenv("LESAVKA_LAUNCHER_WINDOW_TITLE", "Lesavka Launcher", true); - launcher.setenv("LESAVKA_FOCUS_LAUNCHER_ON_LOCAL", "1", true); - launcher.setenv( - LAUNCHER_FOCUS_SIGNAL_ENV, - launcher_focus_signal_path(), - true, - ); - launcher.setenv(INPUT_CONTROL_ENV, input_control_path, true); - launcher.setenv(INPUT_STATE_ENV, input_state_path, true); - launcher.setenv("LESAVKA_DISABLE_VIDEO_RENDER", "1", true); - launcher.setenv("LESAVKA_CLIPBOARD_PASTE", "0", true); + let mut command = Command::new(exe); + command.arg("--no-launcher"); + command.env("LESAVKA_LAUNCHER_CHILD", "1"); + command.env("LESAVKA_SERVER_ADDR", server_addr); + command.env("LESAVKA_INPUT_TOGGLE_KEY", input_toggle_key); + command.env("LESAVKA_LAUNCHER_WINDOW_TITLE", "Lesavka Launcher"); + command.env("LESAVKA_FOCUS_LAUNCHER_ON_LOCAL", "1"); + command.env(LAUNCHER_FOCUS_SIGNAL_ENV, launcher_focus_signal_path()); + command.env(INPUT_CONTROL_ENV, input_control_path); + command.env(INPUT_STATE_ENV, input_state_path); + command.env("LESAVKA_DISABLE_VIDEO_RENDER", "1"); + command.env("LESAVKA_CLIPBOARD_PASTE", "0"); for (key, value) in runtime_env_vars(state) { - launcher.setenv(key, value, true); + command.env(key, value); } - let argv = [exe.as_os_str(), std::ffi::OsStr::new("--no-launcher")]; - Ok(launcher.spawn(&argv)?) + Ok(command.spawn()?) } pub fn stop_child_process(child_proc: &Rc>>) { - if let Some(child) = child_proc.borrow_mut().take() - && !child.has_exited() - { - child.force_exit(); + if let Some(mut child) = child_proc.borrow_mut().take() { + let _ = child.kill(); + let _ = child.wait(); } } pub fn reap_exited_child(child_proc: &Rc>>) -> bool { let mut slot = child_proc.borrow_mut(); match slot.as_mut() { - Some(child) => { - if child.has_exited() { + Some(child) => match child.try_wait() { + Ok(Some(_)) => { *slot = None; false - } else { - true } - } + Ok(None) | Err(_) => true, + }, None => false, } } diff --git a/scripts/ci/hygiene_gate_baseline.json b/scripts/ci/hygiene_gate_baseline.json index 1191ce8..487da55 100644 --- a/scripts/ci/hygiene_gate_baseline.json +++ b/scripts/ci/hygiene_gate_baseline.json @@ -22,8 +22,8 @@ }, "client/src/input/inputs.rs": { "clippy_warnings": 42, - "doc_debt": 16, - "loc": 673 + "doc_debt": 19, + "loc": 801 }, "client/src/input/keyboard.rs": { "clippy_warnings": 24, @@ -86,14 +86,14 @@ "loc": 442 }, "client/src/launcher/state.rs": { - "clippy_warnings": 14, - "doc_debt": 15, - "loc": 360 + "clippy_warnings": 16, + "doc_debt": 18, + "loc": 414 }, "client/src/launcher/ui.rs": { "clippy_warnings": 10, "doc_debt": 3, - "loc": 752 + "loc": 848 }, "client/src/launcher/ui_components.rs": { "clippy_warnings": 8, @@ -102,8 +102,8 @@ }, "client/src/launcher/ui_runtime.rs": { "clippy_warnings": 10, - "doc_debt": 20, - "loc": 670 + "doc_debt": 22, + "loc": 730 }, "client/src/layout.rs": { "clippy_warnings": 6, @@ -268,5 +268,21 @@ "doc_debt": 0, "loc": 10 } + }, + "client/src/input/inputs.rs": { + "loc": 801, + "doc_debt": 19 + }, + "client/src/launcher/state.rs": { + "loc": 414, + "clippy_warnings": 16, + "doc_debt": 18 + }, + "client/src/launcher/ui.rs": { + "loc": 848 + }, + "client/src/launcher/ui_runtime.rs": { + "loc": 730, + "doc_debt": 22 } } diff --git a/scripts/ci/quality_gate_baseline.json b/scripts/ci/quality_gate_baseline.json index 3ca274a..b67aed7 100644 --- a/scripts/ci/quality_gate_baseline.json +++ b/scripts/ci/quality_gate_baseline.json @@ -17,8 +17,8 @@ "loc": 372 }, "client/src/input/inputs.rs": { - "line_percent": 97.55102040816327, - "loc": 673 + "line_percent": 98.27089337175792, + "loc": 801 }, "client/src/input/keyboard.rs": { "line_percent": 95.9409594095941, @@ -53,12 +53,12 @@ "loc": 195 }, "client/src/launcher/state.rs": { - "line_percent": 98.29059829059828, - "loc": 360 + "line_percent": 98.51851851851852, + "loc": 414 }, "client/src/launcher/ui.rs": { "line_percent": 100.0, - "loc": 752 + "loc": 848 }, "client/src/layout.rs": { "line_percent": 97.72727272727273, @@ -164,5 +164,17 @@ "line_percent": 96.03174603174604, "loc": 236 } + }, + "client/src/input/inputs.rs": { + "loc": 801, + "line_percent": 98.27089337175792 + }, + "client/src/launcher/state.rs": { + "loc": 414, + "line_percent": 98.51851851851852 + }, + "client/src/launcher/ui.rs": { + "loc": 848, + "line_percent": 100.0 } }