From 29807eeb413fcd3f7caf2f3c1a0102210c42c112 Mon Sep 17 00:00:00 2001 From: Aslan Devecioglu Date: Wed, 29 Jul 2026 23:05:25 +0200 Subject: [PATCH 01/11] feat(input): add independent mouse-movement, scroll & gamepad visualizers Phase 1 of the input-visualizer expansion. Each visualizer is an independent, opt-in toggle (all default off except keyboard): mouse movement (dot-in-ring), scroll wheel, mouse clicks, gamepad buttons, and gamepad sticks/triggers. Rust: new RawInput variants (MouseMotion/Scroll/GamepadButton/GamepadAxis/GamepadConnection); gilrs gamepad backend; evdev REL_X/Y + wheel and rdev MouseMove/Wheel capture; a ~60Hz coalesced device-state emitter; scroll + gamepad labels overridable via keyLabelOverrides. Frontend: devices.js renders a mouse ring and gamepad stick/trigger widget; new Mouse & Gamepad settings sections. Also fixes the PR #3 save bug where the link-type field clobbered keyLabelOverrides. --- src-tauri/Cargo.lock | 96 +++++++++++- src-tauri/Cargo.toml | 3 + src-tauri/src/config.rs | 21 +++ src-tauri/src/input/evdev_backend.rs | 56 ++++++- src-tauri/src/input/gamepad.rs | 119 ++++++++++++++ src-tauri/src/input/mod.rs | 224 ++++++++++++++++++++++++++- src-tauri/src/input/rdev_backend.rs | 18 +++ src-tauri/src/keymap.rs | 120 ++++++++++++++ src/devices.js | 122 +++++++++++++++ src/index.html | 14 ++ src/key_labels.js | 6 +- src/settings.js | 21 +++ src/style.css | 102 ++++++++++++ 13 files changed, 913 insertions(+), 9 deletions(-) create mode 100644 src-tauri/src/input/gamepad.rs create mode 100644 src/devices.js diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3cd0941..c76d1f5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1170,7 +1170,7 @@ dependencies = [ "bitvec", "cfg-if", "libc", - "nix", + "nix 0.29.0", ] [[package]] @@ -1531,6 +1531,40 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "gilrs" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "902fb00d3f6398e635be22e5c837b303c501835cca7ac11a47bba138f7aafdd8" +dependencies = [ + "fnv", + "gilrs-core", + "log", + "uuid", + "vec_map", +] + +[[package]] +name = "gilrs-core" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc7f0ce6237abcc0523f2a5502b1e3fe5802daaae47ac14e166fe49551301ea9" +dependencies = [ + "inotify", + "js-sys", + "libc", + "libudev-sys", + "log", + "nix 0.31.3", + "objc2-core-foundation", + "objc2-io-kit", + "uuid", + "vec_map", + "wasm-bindgen", + "web-sys", + "windows 0.61.3", +] + [[package]] name = "gio" version = "0.18.4" @@ -2004,6 +2038,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" +dependencies = [ + "bitflags 2.13.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2216,6 +2270,16 @@ dependencies = [ "libc", ] +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -2405,6 +2469,18 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -2590,6 +2666,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "bitflags 2.13.0", + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-io-surface" version = "0.3.2" @@ -4598,6 +4685,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + [[package]] name = "version-compare" version = "0.2.1" @@ -5600,6 +5693,7 @@ version = "2.0.0" dependencies = [ "active-win-pos-rs", "evdev", + "gilrs", "libc", "objc2", "rdev", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e59bbce..c8ce4c1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,6 +18,9 @@ tauri-plugin-single-instance = "2" tauri-plugin-dialog = "2" tts = "0.26" active-win-pos-rs = "0.9" +# Cross-platform gamepad/controller input (XInput on Windows, IOKit/GameController +# on macOS, evdev on Linux — works on X11 and Wayland). +gilrs = "0.11" # Windows & macOS: global hook via the OS APIs (WH_KEYBOARD_LL / CGEventTap), # with OS-native key-to-character translation for any keyboard language. diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 8cf33d5..c9f8c20 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -38,6 +38,21 @@ pub struct Config { pub show_keyboard_click: bool, pub show_mouse_click: bool, pub show_mouse_coordinates: bool, + /// Show a dot-in-a-ring widget that reacts to mouse movement. + pub show_mouse_movement: bool, + /// Show scroll-wheel ticks as popup tokens (⤒/⤓/⇤/⇥). + pub show_mouse_scroll: bool, + /// Show gamepad/controller input: buttons as popups, sticks/triggers in a widget. + pub show_gamepad: bool, + /// How far the mouse-movement dot travels per pixel moved (higher = more sensitive). + #[serde(deserialize_with = "lenient_f64")] + pub mouse_movement_sensitivity: f64, + /// Seconds for the mouse-movement dot to spring back to center once still. + #[serde(deserialize_with = "lenient_f64")] + pub mouse_movement_decay_seconds: f64, + /// Overall scale of the mouse/gamepad widgets (1.0 = default size). + #[serde(deserialize_with = "lenient_f64")] + pub device_widget_scale: f64, pub only_keys_with_modifiers: bool, pub show_space_as_unicode: bool, pub text_to_symbols: bool, @@ -92,6 +107,12 @@ impl Default for Config { show_keyboard_click: true, show_mouse_click: false, show_mouse_coordinates: false, + show_mouse_movement: false, + show_mouse_scroll: false, + show_gamepad: false, + mouse_movement_sensitivity: 1.0, + mouse_movement_decay_seconds: 0.4, + device_widget_scale: 1.0, only_keys_with_modifiers: false, show_space_as_unicode: false, text_to_symbols: true, diff --git a/src-tauri/src/input/evdev_backend.rs b/src-tauri/src/input/evdev_backend.rs index 593de99..f361685 100644 --- a/src-tauri/src/input/evdev_backend.rs +++ b/src-tauri/src/input/evdev_backend.rs @@ -19,6 +19,14 @@ use super::{BackendIssue, Mods, RawInput}; const RESCAN_INTERVAL: Duration = Duration::from_secs(3); +/// Raw event forwarded from a device reader thread to the translator thread. +/// Keys need xkb translation (which owns non-Send state); relative-axis events +/// (mouse movement / scroll) are forwarded straight through. +enum RawEvent { + Key(evdev::KeyCode, i32), + Rel(evdev::RelativeAxisCode, i32), +} + pub fn spawn_listener( tx: Sender, layout_override: Option, @@ -26,7 +34,7 @@ pub fn spawn_listener( ) { // xkb::State is not Send, so device reader threads forward raw // (keycode, value) pairs to one translator thread that owns the state. - let (raw_tx, raw_rx) = std::sync::mpsc::channel::<(evdev::KeyCode, i32)>(); + let (raw_tx, raw_rx) = std::sync::mpsc::channel::(); std::thread::spawn({ let layout = layout_override.clone(); @@ -39,8 +47,11 @@ pub fn spawn_listener( return; } }; - for (code, value) in raw_rx { - handle_key_event(code, value, &tx, &mut state); + for event in raw_rx { + match event { + RawEvent::Key(code, value) => handle_key_event(code, value, &tx, &mut state), + RawEvent::Rel(axis, value) => handle_rel_event(axis, value, &tx), + } } } }); @@ -106,7 +117,7 @@ fn is_mouse(device: &Device) -> bool { fn spawn_device_reader( path: PathBuf, mut device: Device, - raw_tx: Sender<(evdev::KeyCode, i32)>, + raw_tx: Sender, open_paths: Arc>>, ) { std::thread::spawn(move || { @@ -116,8 +127,14 @@ fn spawn_device_reader( Err(_) => break, // device unplugged or read error: drop the reader }; for event in events { - if let evdev::EventSummary::Key(_, code, value) = event.destructure() { - let _ = raw_tx.send((code, value)); + match event.destructure() { + evdev::EventSummary::Key(_, code, value) => { + let _ = raw_tx.send(RawEvent::Key(code, value)); + } + evdev::EventSummary::RelativeAxis(_, axis, value) => { + let _ = raw_tx.send(RawEvent::Rel(axis, value)); + } + _ => {} } } } @@ -311,6 +328,33 @@ fn parse_kv_layout(text: &str, layout_key: &str, variant_key: &str) -> Option<(S (layout != "n/a").then_some((layout, variant)) } +/// Forwards mouse movement (REL_X/REL_Y) and scroll (REL_WHEEL/REL_HWHEEL). +/// Emitted per axis; the overlay accumulates them, so a separate X and Y event +/// is fine. Works on X11 and Wayland alike (we read the device directly). +fn handle_rel_event(axis: evdev::RelativeAxisCode, value: i32, tx: &Sender) { + let input = match axis { + evdev::RelativeAxisCode::REL_X => RawInput::MouseMotion { + dx: value as f64, + dy: 0.0, + }, + evdev::RelativeAxisCode::REL_Y => RawInput::MouseMotion { + dx: 0.0, + dy: value as f64, + }, + // REL_WHEEL is positive-up; keep that convention (dy>0 = up). + evdev::RelativeAxisCode::REL_WHEEL => RawInput::Scroll { + dx: 0.0, + dy: value as f64, + }, + evdev::RelativeAxisCode::REL_HWHEEL => RawInput::Scroll { + dx: value as f64, + dy: 0.0, + }, + _ => return, + }; + let _ = tx.send(input); +} + fn mouse_button(code: evdev::KeyCode) -> Option { match code { evdev::KeyCode::BTN_LEFT => Some(1), diff --git a/src-tauri/src/input/gamepad.rs b/src-tauri/src/input/gamepad.rs new file mode 100644 index 0000000..932477b --- /dev/null +++ b/src-tauri/src/input/gamepad.rs @@ -0,0 +1,119 @@ +//! Cross-platform gamepad/controller capture via gilrs (XInput on Windows, +//! IOKit/GameController on macOS, evdev on Linux — works on X11 and Wayland). +//! +//! Like the mouse-button path, this always runs and feeds the shared RawInput +//! channel; the consumer thread decides whether to show anything based on the +//! `show_gamepad` config flag. + +use std::sync::mpsc::Sender; +use std::time::Duration; + +use gilrs::{Axis, Button, Gilrs}; + +use super::RawInput; + +pub fn spawn_listener(tx: Sender) { + std::thread::spawn(move || { + let mut gilrs = match Gilrs::new() { + Ok(gilrs) => gilrs, + Err(err) => { + eprintln!("YAKC: gamepad support unavailable: {err}"); + return; + } + }; + + // A controller may already be connected at startup. + if gilrs.gamepads().next().is_some() { + let _ = tx.send(RawInput::GamepadConnection { connected: true }); + } + + loop { + while let Some(event) = gilrs.next_event() { + match event.event { + gilrs::EventType::Connected => { + let _ = tx.send(RawInput::GamepadConnection { connected: true }); + } + gilrs::EventType::Disconnected => { + let _ = tx.send(RawInput::GamepadConnection { connected: false }); + } + gilrs::EventType::ButtonPressed(button, _) => { + if let Some(id) = button_id(button) { + let _ = tx.send(RawInput::GamepadButton { id, pressed: true }); + } + } + gilrs::EventType::ButtonReleased(button, _) => { + if let Some(id) = button_id(button) { + let _ = tx.send(RawInput::GamepadButton { id, pressed: false }); + } + } + gilrs::EventType::ButtonChanged(button, value, _) => { + // Analog triggers report their travel (0.0..1.0) here. + if let Some(axis) = trigger_axis(button) { + let _ = tx.send(RawInput::GamepadAxis { + axis, + value: value as f64, + }); + } + } + gilrs::EventType::AxisChanged(axis, value, _) => { + if let Some(axis) = stick_axis(axis) { + let _ = tx.send(RawInput::GamepadAxis { + axis, + value: value as f64, + }); + } + } + _ => {} + } + } + // gilrs is poll-based; a short sleep keeps this thread near-idle + // while staying responsive (~250 Hz). + std::thread::sleep(Duration::from_millis(4)); + } + }); +} + +/// Maps gilrs buttons to the shared ids used by keymap.rs / known_keys(). +fn button_id(button: Button) -> Option<&'static str> { + Some(match button { + Button::South => "gp_a", + Button::East => "gp_b", + Button::West => "gp_x", + Button::North => "gp_y", + Button::LeftTrigger => "gp_lb", + Button::RightTrigger => "gp_rb", + Button::LeftTrigger2 => "gp_lt", + Button::RightTrigger2 => "gp_rt", + Button::Select => "gp_back", + Button::Start => "gp_start", + Button::Mode => "gp_guide", + Button::LeftThumb => "gp_ls", + Button::RightThumb => "gp_rs", + Button::DPadUp => "dpad_up", + Button::DPadDown => "dpad_down", + Button::DPadLeft => "dpad_left", + Button::DPadRight => "dpad_right", + _ => return None, + }) +} + +/// Analog trigger travel is surfaced as an axis so the widget can draw a bar. +fn trigger_axis(button: Button) -> Option<&'static str> { + Some(match button { + Button::LeftTrigger2 => "lt", + Button::RightTrigger2 => "rt", + _ => return None, + }) +} + +fn stick_axis(axis: Axis) -> Option<&'static str> { + Some(match axis { + Axis::LeftStickX => "ls_x", + Axis::LeftStickY => "ls_y", + Axis::RightStickX => "rs_x", + Axis::RightStickY => "rs_y", + Axis::LeftZ => "lt", + Axis::RightZ => "rt", + _ => return None, + }) +} diff --git a/src-tauri/src/input/mod.rs b/src-tauri/src/input/mod.rs index 160c474..46e7b26 100644 --- a/src-tauri/src/input/mod.rs +++ b/src-tauri/src/input/mod.rs @@ -1,11 +1,15 @@ use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{mpsc, Arc}; +use std::sync::{mpsc, Arc, Mutex}; +use std::time::Duration; +use serde::Serialize; use tauri::{AppHandle, Emitter, Manager}; use crate::config::SharedConfig; use crate::{keymap, tts}; +mod gamepad; + #[cfg(any(target_os = "windows", target_os = "macos"))] mod rdev_backend; #[cfg(any(target_os = "windows", target_os = "macos"))] @@ -43,6 +47,32 @@ pub enum RawInput { MouseButton { button: u8, }, + /// Relative mouse movement since the last event. Emitted per axis on Linux + /// (evdev sends REL_X and REL_Y separately), combined on Windows/macOS. + MouseMotion { + dx: f64, + dy: f64, + }, + /// Scroll wheel: positive dy = up, positive dx = right (one tick per notch). + Scroll { + dx: f64, + dy: f64, + }, + /// A gamepad button changed state. `id` is a shared id ("gp_a", "dpad_up", …). + GamepadButton { + id: &'static str, + pressed: bool, + }, + /// A gamepad analog axis moved. `axis` is a shared id ("ls_x", "rt", …); + /// sticks range -1.0..1.0, triggers 0.0..1.0. + GamepadAxis { + axis: &'static str, + value: f64, + }, + /// A gamepad connected (true) or disconnected (false); drives widget visibility. + GamepadConnection { + connected: bool, + }, } /// A problem a platform backend ran into that needs user-visible handling. @@ -111,11 +141,142 @@ impl Hotkey { } } +/// Latest analog device state, updated by the consumer thread and sampled by +/// the emitter thread at ~60 Hz. Mouse motion accumulates between samples; +/// gamepad axes hold their latest value. +#[derive(Default)] +struct DeviceState { + mouse_dx: f64, + mouse_dy: f64, + ls_x: f64, + ls_y: f64, + rs_x: f64, + rs_y: f64, + lt: f64, + rt: f64, + gamepad_connected: bool, +} + +impl DeviceState { + fn set_axis(&mut self, axis: &str, value: f64) { + match axis { + "ls_x" => self.ls_x = value, + "ls_y" => self.ls_y = value, + "rs_x" => self.rs_x = value, + "rs_y" => self.rs_y = value, + "lt" => self.lt = value, + "rt" => self.rt = value, + _ => {} + } + } + + fn reset_gamepad(&mut self) { + self.ls_x = 0.0; + self.ls_y = 0.0; + self.rs_x = 0.0; + self.rs_y = 0.0; + self.lt = 0.0; + self.rt = 0.0; + } +} + +/// Snapshot emitted to the overlay's device widget. Carries the config knobs +/// the widget needs so the frontend never has to fetch config separately. +#[derive(Debug, Clone, Serialize, Default, PartialEq)] +#[serde(rename_all = "camelCase")] +struct DeviceSnapshot { + mouse_dx: f64, + mouse_dy: f64, + ls_x: f64, + ls_y: f64, + rs_x: f64, + rs_y: f64, + lt: f64, + rt: f64, + gamepad_connected: bool, + show_mouse_movement: bool, + show_gamepad: bool, + sensitivity: f64, + decay_seconds: f64, + scale: f64, +} + +/// Emits `device-state` to the overlay at ~60 Hz, but only when something +/// changed (mouse moved, an axis moved, or a controller connected). The +/// overlay runs its own animation loop for smooth decay, so idle frames are +/// unnecessary. +fn spawn_device_emitter(app: AppHandle, config: SharedConfig, state: Arc>) { + std::thread::spawn(move || { + // Axes/connection last emitted, to detect change (mouse delta excluded). + let mut last = DeviceSnapshot::default(); + loop { + std::thread::sleep(Duration::from_millis(16)); + let cfg = match config.read() { + Ok(cfg) => cfg.clone(), + Err(_) => continue, + }; + if !cfg.show_mouse_movement && !cfg.show_gamepad { + continue; + } + + let snapshot = { + let mut guard = match state.lock() { + Ok(guard) => guard, + Err(_) => continue, + }; + let snapshot = DeviceSnapshot { + mouse_dx: guard.mouse_dx, + mouse_dy: guard.mouse_dy, + ls_x: guard.ls_x, + ls_y: guard.ls_y, + rs_x: guard.rs_x, + rs_y: guard.rs_y, + lt: guard.lt, + rt: guard.rt, + gamepad_connected: guard.gamepad_connected, + show_mouse_movement: cfg.show_mouse_movement, + show_gamepad: cfg.show_gamepad, + sensitivity: cfg.mouse_movement_sensitivity, + decay_seconds: cfg.mouse_movement_decay_seconds, + scale: cfg.device_widget_scale, + }; + // Drain the accumulated motion; axes persist. + guard.mouse_dx = 0.0; + guard.mouse_dy = 0.0; + snapshot + }; + + let moved = snapshot.mouse_dx != 0.0 || snapshot.mouse_dy != 0.0; + // Compare everything except the (already-drained) mouse delta. + let axes_changed = DeviceSnapshot { + mouse_dx: 0.0, + mouse_dy: 0.0, + ..snapshot.clone() + } != last; + if !moved && !axes_changed { + continue; + } + last = DeviceSnapshot { + mouse_dx: 0.0, + mouse_dy: 0.0, + ..snapshot.clone() + }; + let _ = app.emit_to("overlay", "device-state", &snapshot); + } + }); +} + /// Spawns the platform input backend and the consumer thread that turns raw /// events into popup labels, TTS, and hotkey toggles. pub fn start(app: AppHandle, config: SharedConfig, capturing: Arc) { let (tx, rx) = mpsc::channel::(); + let device_state = Arc::new(Mutex::new(DeviceState::default())); + spawn_device_emitter(app.clone(), config.clone(), device_state.clone()); + + // Gamepad runs on every platform and feeds the same channel. + gamepad::spawn_listener(tx.clone()); + let on_issue = { let app = app.clone(); move |issue: BackendIssue| handle_issue(&app, issue) @@ -166,6 +327,41 @@ pub fn start(app: AppHandle, config: SharedConfig, capturing: Arc) { continue; } + // Analog motion / gamepad axes / connection feed the device widget + // (via the 60 Hz emitter), never the popup stack. + match &event { + RawInput::MouseMotion { dx, dy } => { + if cfg.show_mouse_movement { + if let Ok(mut guard) = device_state.lock() { + guard.mouse_dx += dx; + guard.mouse_dy += dy; + } + } + continue; + } + RawInput::GamepadAxis { axis, value } => { + if cfg.show_gamepad { + if let Ok(mut guard) = device_state.lock() { + // Any axis activity proves a controller is present, + // even if we missed the connect event at startup. + guard.gamepad_connected = true; + guard.set_axis(axis, *value); + } + } + continue; + } + RawInput::GamepadConnection { connected } => { + if let Ok(mut guard) = device_state.lock() { + guard.gamepad_connected = *connected; + if !*connected { + guard.reset_gamepad(); + } + } + continue; + } + _ => {} + } + let op = match &event { RawInput::Key { text, @@ -191,6 +387,32 @@ pub fn start(app: AppHandle, config: SharedConfig, capturing: Arc) { text: keymap::format_mouse(*button, coords, &cfg), }) } + RawInput::Scroll { dx, dy } => { + if !cfg.show_mouse_scroll { + continue; + } + Some(keymap::PopupOp::Append { + text: keymap::format_scroll(*dx, *dy, &cfg), + }) + } + RawInput::GamepadButton { id, pressed } => { + if !cfg.show_gamepad { + continue; + } + if let Ok(mut guard) = device_state.lock() { + guard.gamepad_connected = true; + } + // Only presses produce a popup token; releases just kept the + // connected flag fresh above. + if !*pressed { + continue; + } + Some(keymap::PopupOp::Append { + text: keymap::format_gamepad_button(id, &cfg), + }) + } + // Analog / connection variants were handled above. + _ => continue, }; let Some(op) = op else { continue }; diff --git a/src-tauri/src/input/rdev_backend.rs b/src-tauri/src/input/rdev_backend.rs index b834c39..166e316 100644 --- a/src-tauri/src/input/rdev_backend.rs +++ b/src-tauri/src/input/rdev_backend.rs @@ -17,6 +17,9 @@ pub fn spawn_listener( // Keys currently held down: a KeyPress for one of these is an // auto-repeat (the OS hooks deliver repeats as fresh KeyPress events). let mut held: std::collections::HashSet = std::collections::HashSet::new(); + // rdev reports absolute cursor positions; we forward relative deltas so + // the movement widget behaves the same as the Linux (evdev) path. + let mut last_pos: Option<(f64, f64)> = None; let callback = move |event: Event| { match event.event_type { @@ -57,6 +60,21 @@ pub fn spawn_listener( }; let _ = tx.send(RawInput::MouseButton { button }); } + EventType::MouseMove { x, y } => { + if let Some((px, py)) = last_pos { + let (dx, dy) = (x - px, y - py); + if dx != 0.0 || dy != 0.0 { + let _ = tx.send(RawInput::MouseMotion { dx, dy }); + } + } + last_pos = Some((x, y)); + } + EventType::Wheel { delta_x, delta_y } => { + let _ = tx.send(RawInput::Scroll { + dx: delta_x as f64, + dy: delta_y as f64, + }); + } _ => {} } }; diff --git a/src-tauri/src/keymap.rs b/src-tauri/src/keymap.rs index 3bd552b..6a53f71 100644 --- a/src-tauri/src/keymap.rs +++ b/src-tauri/src/keymap.rs @@ -1,3 +1,4 @@ +#[cfg(test)] use std::collections::HashMap; use serde::Serialize; @@ -236,6 +237,71 @@ pub fn format_mouse(button: u8, coords: Option<(i32, i32)>, config: &Config) -> format!(" MOUSE{button} ") } +/// Scroll-wheel directions, as (id, default label). Ids are overridable via +/// `keyLabelOverrides`, exactly like named keys. +const SCROLL_KEYS: &[(&str, &str)] = &[ + ("scrollup", "Scroll↑"), + ("scrolldown", "Scroll↓"), + ("scrollleft", "Scroll←"), + ("scrollright", "Scroll→"), +]; + +/// Gamepad buttons, as (id, default label). Shared with the gamepad backend. +const GAMEPAD_BUTTONS: &[(&str, &str)] = &[ + ("gp_a", "A"), + ("gp_b", "B"), + ("gp_x", "X"), + ("gp_y", "Y"), + ("gp_lb", "LB"), + ("gp_rb", "RB"), + ("gp_lt", "LT"), + ("gp_rt", "RT"), + ("gp_back", "BACK"), + ("gp_start", "START"), + ("gp_guide", "GUIDE"), + ("gp_ls", "L3"), + ("gp_rs", "R3"), + ("dpad_up", "D↑"), + ("dpad_down", "D↓"), + ("dpad_left", "D←"), + ("dpad_right", "D→"), +]; + +/// Resolves a label for an id, honoring `keyLabelOverrides` first, then the +/// table default, then an uppercased fallback. +fn labeled(id: &str, table: &[(&str, &str)], config: &Config) -> String { + if let Some(overridden) = config.key_label_overrides.get(id) { + return overridden.clone(); + } + table + .iter() + .find(|(key, _)| *key == id) + .map(|(_, label)| label.to_string()) + .unwrap_or_else(|| id.to_uppercase()) +} + +/// Popup label for a scroll tick. `dy > 0` is up, `dx > 0` is right; the +/// dominant axis wins. +pub fn format_scroll(dx: f64, dy: f64, config: &Config) -> String { + let id = if dy.abs() >= dx.abs() { + if dy > 0.0 { + "scrollup" + } else { + "scrolldown" + } + } else if dx > 0.0 { + "scrollright" + } else { + "scrollleft" + }; + format!(" {} ", labeled(id, SCROLL_KEYS, config)) +} + +/// Popup label for a gamepad button press. +pub fn format_gamepad_button(id: &str, config: &Config) -> String { + format!(" {} ", labeled(id, GAMEPAD_BUTTONS, config)) +} + /// A known key that can be overridden, exposed to the settings UI. #[derive(Debug, Clone, Serialize)] pub struct KnownKey { @@ -305,6 +371,22 @@ pub fn known_keys() -> Vec { }); } + for (id, label) in SCROLL_KEYS { + keys.push(KnownKey { + id: id.to_string(), + default_label: label.to_string(), + group: "mouse".into(), + }); + } + + for (id, label) in GAMEPAD_BUTTONS { + keys.push(KnownKey { + id: id.to_string(), + default_label: label.to_string(), + group: "gamepad".into(), + }); + } + keys } @@ -506,6 +588,44 @@ mod tests { assert_eq!(format_mouse(2, None, &config), " MOUSE2 "); } + #[test] + fn scroll_labels_pick_dominant_axis() { + let config = Config::default(); + assert_eq!(format_scroll(0.0, 1.0, &config), " Scroll↑ "); + assert_eq!(format_scroll(0.0, -1.0, &config), " Scroll↓ "); + assert_eq!(format_scroll(1.0, 0.0, &config), " Scroll→ "); + assert_eq!(format_scroll(-1.0, 0.0, &config), " Scroll← "); + // Vertical wins ties / mixed input. + assert_eq!(format_scroll(0.5, 1.0, &config), " Scroll↑ "); + } + + #[test] + fn scroll_and_gamepad_labels_honor_overrides() { + let config = Config { + key_label_overrides: HashMap::from([ + ("scrollup".into(), "WHEEL-UP".into()), + ("gp_a".into(), "✕".into()), + ]), + ..Config::default() + }; + assert_eq!(format_scroll(0.0, 2.0, &config), " WHEEL-UP "); + assert_eq!(format_gamepad_button("gp_a", &config), " ✕ "); + // Non-overridden buttons keep their default label. + assert_eq!(format_gamepad_button("gp_lt", &config), " LT "); + assert_eq!(format_gamepad_button("dpad_up", &config), " D↑ "); + } + + #[test] + fn known_keys_include_scroll_and_gamepad_groups() { + let keys = known_keys(); + assert!(keys + .iter() + .any(|k| k.id == "scrollup" && k.group == "mouse")); + assert!(keys + .iter() + .any(|k| k.id == "gp_a" && k.group == "gamepad" && k.default_label == "A")); + } + #[test] fn named_key_override_takes_priority() { let config = Config { diff --git a/src/devices.js b/src/devices.js new file mode 100644 index 0000000..7b64221 --- /dev/null +++ b/src/devices.js @@ -0,0 +1,122 @@ +/** + * YAKC device widgets: renders mouse-movement (a dot inside a ring) and + * gamepad sticks/triggers from the `device-state` events the Rust side emits + * at ~60 Hz. Discrete inputs (scroll, gamepad buttons, mouse buttons) are shown + * as popups by overlay.js; this file only handles the analog widgets. + * + * Wrapped in an IIFE: overlay.js and devices.js are classic scripts sharing one + * global scope, so top-level `const core` / `tauriEvent` would collide and this + * file would fail to load. + */ + +(() => { +const { event: tauriEvent, core } = window.__TAURI__; + +// How much the mouse dot deflects per pixel moved, and the ring/stick radii. +const MOUSE_GAIN = 0.02; +const MOUSE_MAX_OFFSET = 33; // px, matches #mouseRing radius minus dot size +const STICK_MAX_OFFSET = 26; // px, matches .stick radius minus dot size + +const flags = { mouse: false, gamepad: false, connected: false }; +const params = { sensitivity: 1, decay: 0.4, scale: 1 }; + +// Mouse dot offset, normalized to -1..1; springs back to center when idle. +let mx = 0; +let my = 0; +// Latest gamepad analog values. +const gp = { lsx: 0, lsy: 0, rsx: 0, rsy: 0, lt: 0, rt: 0 }; + +let deviceLayer, mouseWidget, gamepadWidget, mouseDot, leftDot, rightDot, ltFill, rtFill; + +function clamp01(v) { + return Math.max(0, Math.min(1, v)); +} + +function applyVisibility() { + const showMouse = flags.mouse; + const showGamepad = flags.gamepad && flags.connected; + mouseWidget.hidden = !showMouse; + gamepadWidget.hidden = !showGamepad; + deviceLayer.hidden = !(showMouse || showGamepad); +} + +function onDeviceState(s) { + if (!s) return; + flags.mouse = s.showMouseMovement; + flags.gamepad = s.showGamepad; + flags.connected = s.gamepadConnected; + params.sensitivity = s.sensitivity || 1; + params.decay = s.decaySeconds > 0 ? s.decaySeconds : 0.0001; + params.scale = s.scale || 1; + deviceLayer.style.setProperty("--dev-scale", params.scale); + + // Accumulate mouse motion as an impulse toward the movement direction. + mx = Math.max(-1, Math.min(1, mx + s.mouseDx * MOUSE_GAIN * params.sensitivity)); + my = Math.max(-1, Math.min(1, my + s.mouseDy * MOUSE_GAIN * params.sensitivity)); + + gp.lsx = s.lsX; + gp.lsy = s.lsY; + gp.rsx = s.rsX; + gp.rsy = s.rsY; + gp.lt = s.lt; + gp.rt = s.rt; + + applyVisibility(); +} + +/** Config drives initial + toggle-off visibility (device-state only fires on activity). */ +function onConfig(c) { + if (!c) return; + flags.mouse = c.showMouseMovement; + flags.gamepad = c.showGamepad; + params.scale = c.deviceWidgetScale || 1; + deviceLayer.style.setProperty("--dev-scale", params.scale); + deviceLayer.style.setProperty("--dev-color", c.popupFontColor || "#ffffff"); + applyVisibility(); +} + +let lastTs = 0; +function frame(ts) { + const dt = lastTs ? (ts - lastTs) / 1000 : 0; + lastTs = ts; + + // Spring the mouse dot back to center with the configured time constant. + const factor = Math.exp(-dt / params.decay); + mx *= factor; + my *= factor; + if (Math.abs(mx) < 0.001) mx = 0; + if (Math.abs(my) < 0.001) my = 0; + mouseDot.style.transform = `translate(${mx * MOUSE_MAX_OFFSET}px, ${my * MOUSE_MAX_OFFSET}px)`; + + // Gamepad: invert Y so pushing the stick up moves the dot up. + leftDot.style.transform = `translate(${gp.lsx * STICK_MAX_OFFSET}px, ${-gp.lsy * STICK_MAX_OFFSET}px)`; + rightDot.style.transform = `translate(${gp.rsx * STICK_MAX_OFFSET}px, ${-gp.rsy * STICK_MAX_OFFSET}px)`; + ltFill.style.height = `${clamp01(gp.lt) * 100}%`; + rtFill.style.height = `${clamp01(gp.rt) * 100}%`; + + requestAnimationFrame(frame); +} + +async function init() { + deviceLayer = document.getElementById("deviceLayer"); + mouseWidget = document.getElementById("mouseWidget"); + gamepadWidget = document.getElementById("gamepadWidget"); + mouseDot = document.getElementById("mouseDot"); + leftDot = document.querySelector("#leftStick .stick-dot"); + rightDot = document.querySelector("#rightStick .stick-dot"); + ltFill = document.getElementById("ltFill"); + rtFill = document.getElementById("rtFill"); + + try { + onConfig(await core.invoke("get_config")); + } catch { + // overlay.js surfaces config errors; widgets just stay hidden. + } + + await tauriEvent.listen("device-state", (e) => onDeviceState(e.payload)); + await tauriEvent.listen("config-updated", (e) => onConfig(e.payload)); + requestAnimationFrame(frame); +} + +window.addEventListener("DOMContentLoaded", init); +})(); diff --git a/src/index.html b/src/index.html index bdf90fa..a91a7e0 100644 --- a/src/index.html +++ b/src/index.html @@ -6,9 +6,23 @@ YAKC - Yet Another Key Caster +
+ diff --git a/src/key_labels.js b/src/key_labels.js index 233f2c4..3cbfe58 100644 --- a/src/key_labels.js +++ b/src/key_labels.js @@ -22,6 +22,10 @@ function groupLabel(group) { return "Function keys (F1–F12)"; case "system": return "System (Escape, Caps Lock, Print Screen, …)"; + case "mouse": + return "Mouse (scroll wheel)"; + case "gamepad": + return "Gamepad / controller"; default: return "Other"; } @@ -37,7 +41,7 @@ function buildForm() { (groups[key.group] ??= []).push(key); } - const groupOrder = ["modifier", "editing", "navigation", "numpad", "function", "system", "other"]; + const groupOrder = ["modifier", "editing", "navigation", "numpad", "function", "system", "mouse", "gamepad", "other"]; for (const g of groupOrder) { const keys = groups[g]; diff --git a/src/settings.js b/src/settings.js index 7da5737..181ddd1 100644 --- a/src/settings.js +++ b/src/settings.js @@ -54,6 +54,22 @@ const SECTIONS = [ { key: "keyLabelOverrides", type: "link", label: "Key label overrides", hint: "Customize display text for any key (modifiers, arrows, F-keys, …)" }, ], }, + { + title: "Mouse (movement & scroll)", + fields: [ + { key: "showMouseMovement", type: "bool", label: "Show mouse movement", hint: "A dot-in-a-ring widget that reacts to how you move the mouse (works on Wayland too)" }, + { key: "showMouseScroll", type: "bool", label: "Show scroll wheel", hint: "Scroll ticks appear as popup tokens (Scroll↑ / Scroll↓)" }, + { key: "mouseMovementSensitivity", type: "number", label: "Movement sensitivity", min: 0.1, step: 0.1 }, + { key: "mouseMovementDecaySeconds", type: "number", label: "Spring-back time (s)", min: 0.05, step: 0.05, hint: "How long the dot takes to return to center once the mouse stops" }, + { key: "deviceWidgetScale", type: "number", label: "Widget size (scale)", min: 0.3, max: 5, step: 0.1, hint: "Size of the mouse & gamepad widgets" }, + ], + }, + { + title: "Gamepad / controller", + fields: [ + { key: "showGamepad", type: "bool", label: "Show gamepad input", hint: "Buttons as popups; sticks & triggers in a widget. Works with any XInput / DualShock-style controller." }, + ], + }, { title: "Text-to-speech", fields: [ @@ -172,6 +188,11 @@ function collectForm() { .map((s) => s.trim()) .filter((s) => s.length > 0); break; + case "link": + // Not an input — it links to another page. Leave the value from + // config untouched (spread above); collecting the button's empty + // string here would clobber keyLabelOverrides (a map). + break; default: updated[key] = input.value; } diff --git a/src/style.css b/src/style.css index 5df5143..f88e9d6 100644 --- a/src/style.css +++ b/src/style.css @@ -29,6 +29,108 @@ body { /* font, colors, radius, fade duration and max-width come from config via overlay.js */ } +/* Analog device widgets (mouse movement + gamepad sticks/triggers), rendered + by devices.js from `device-state` events. Centered near the bottom edge. */ +#deviceLayer { + position: fixed; + left: 50%; + bottom: 40px; + transform: translateX(-50%) scale(var(--dev-scale, 1)); + transform-origin: bottom center; + display: flex; + align-items: flex-end; + gap: 28px; + pointer-events: none; +} + +[hidden] { + display: none !important; +} + +#mouseWidget, +#gamepadWidget { + display: flex; + align-items: center; + gap: 16px; +} + +#mouseRing, +.stick { + position: relative; + border-radius: 50%; + border: 3px solid var(--dev-color, #ffffff); + background: rgba(0, 0, 0, 0.4); + opacity: 0.85; +} + +#mouseRing { + width: 90px; + height: 90px; +} + +.stick { + width: 76px; + height: 76px; +} + +#mouseDot, +.stick-dot { + position: absolute; + left: 50%; + top: 50%; + border-radius: 50%; + background: var(--dev-color, #ffffff); + box-shadow: 0 0 8px var(--dev-color, #ffffff); +} + +#mouseDot { + width: 16px; + height: 16px; + margin: -8px 0 0 -8px; +} + +.stick-dot { + width: 14px; + height: 14px; + margin: -7px 0 0 -7px; +} + +.triggers { + display: flex; + gap: 10px; +} + +.trigger { + position: relative; + width: 28px; + height: 64px; + border-radius: 6px; + border: 2px solid var(--dev-color, #ffffff); + background: rgba(0, 0, 0, 0.4); + overflow: hidden; + display: flex; + align-items: flex-end; + justify-content: center; + opacity: 0.85; +} + +.trigger-fill { + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: 0%; + background: var(--dev-color, #ffffff); + opacity: 0.55; +} + +.trigger span { + position: relative; + font: 11px sans-serif; + color: var(--dev-color, #ffffff); + padding-bottom: 4px; +} + /* Error/permission notice (e.g. missing input-device permission on Linux) */ #notice { position: fixed; From 68edc9ad2b4c98a5555368864959f0acf844f66b Mon Sep 17 00:00:00 2001 From: Aslan Devecioglu Date: Thu, 30 Jul 2026 00:53:47 +0200 Subject: [PATCH 02/11] feat(obs): browser-source output + OBS-only overlay toggle Serves the overlay over a tiny std::net HTTP server (opt-in, localhost) so OBS can use it as a transparent Browser source at http://localhost:/overlay. The page reuses the exact overlay.js/devices.js via a window.__TAURI__ shim: config over fetch(/config), live input over a flushed Server-Sent-Events stream (/events). Enabling starts the server immediately; a broadcaster mirrors click-event/device-state/config-updated/yakc-error to every browser client, skipping serialization when none are connected. Adds 'Show overlay on this screen' (default on): turn off to display only in OBS, so the overlay isn't captured twice or seen locally. New config: obsServerEnabled, obsServerPort (7238), showOverlayOnScreen. --- src-tauri/src/config.rs | 16 +++ src-tauri/src/input/mod.rs | 13 +++ src-tauri/src/main.rs | 10 ++ src-tauri/src/obs_server.rs | 223 ++++++++++++++++++++++++++++++++++++ src-tauri/src/overlay.rs | 13 +++ src/obs.html | 30 +++++ src/settings.js | 8 ++ src/tauri-shim.js | 50 ++++++++ 8 files changed, 363 insertions(+) create mode 100644 src-tauri/src/obs_server.rs create mode 100644 src/obs.html create mode 100644 src/tauri-shim.js diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index c9f8c20..f91e267 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -53,6 +53,9 @@ pub struct Config { /// Overall scale of the mouse/gamepad widgets (1.0 = default size). #[serde(deserialize_with = "lenient_f64")] pub device_widget_scale: f64, + /// Show the overlay on this screen. Turn off to display only in the OBS + /// browser source (so it isn't captured twice or seen by you locally). + pub show_overlay_on_screen: bool, pub only_keys_with_modifiers: bool, pub show_space_as_unicode: bool, pub text_to_symbols: bool, @@ -80,6 +83,12 @@ pub struct Config { /// ("backspace", "f1", "meta", "ctrl", …), value = custom display text. /// E.g. {"meta": "MOD"} shows "MOD" instead of "META" in combos. pub key_label_overrides: HashMap, + /// Serve the overlay over HTTP for use as an OBS Browser source. Opt-in; + /// changing this needs an app restart to start/stop the server. + pub obs_server_enabled: bool, + /// Port the OBS browser-source server listens on (localhost). + #[serde(deserialize_with = "lenient_u16")] + pub obs_server_port: u16, } impl Config { @@ -113,6 +122,7 @@ impl Default for Config { mouse_movement_sensitivity: 1.0, mouse_movement_decay_seconds: 0.4, device_widget_scale: 1.0, + show_overlay_on_screen: true, only_keys_with_modifiers: false, show_space_as_unicode: false, text_to_symbols: true, @@ -129,6 +139,8 @@ impl Default for Config { toggle_capture_hotkey: "Ctrl+Alt+Y".into(), display_mode: "text".into(), key_label_overrides: HashMap::new(), + obs_server_enabled: false, + obs_server_port: 7238, } } } @@ -152,6 +164,10 @@ fn lenient_usize<'de, D: Deserializer<'de>>(deserializer: D) -> Result>(deserializer: D) -> Result { + Ok(lenient_f64(deserializer)?.clamp(0.0, 65535.0) as u16) +} + /// Path of the active config file: a config.json next to the executable wins /// (portable installs and the old Electron layout), otherwise the platform /// config directory. diff --git a/src-tauri/src/input/mod.rs b/src-tauri/src/input/mod.rs index 46e7b26..6abb1c6 100644 --- a/src-tauri/src/input/mod.rs +++ b/src-tauri/src/input/mod.rs @@ -262,6 +262,11 @@ fn spawn_device_emitter(app: AppHandle, config: SharedConfig, state: Arc) { let Some(op) = op else { continue }; let _ = app.emit_to("overlay", "click-event", &op); + if crate::obs_server::has_clients(&app) { + if let Ok(json) = serde_json::to_string(&op) { + crate::obs_server::broadcast(&app, "click-event", &json); + } + } if cfg.text_to_speech { if let keymap::PopupOp::Append { text } = &op { @@ -495,6 +505,9 @@ pub fn report_error(app: &AppHandle, message: String) { if let Some(overlay) = app.get_webview_window("overlay") { let _ = overlay.emit("yakc-error", &message); } + if let Ok(json) = serde_json::to_string(&message) { + crate::obs_server::broadcast(app, "yakc-error", &json); + } } #[cfg(test)] diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 3de2581..5483909 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -4,6 +4,7 @@ mod config; mod filter; mod input; mod keymap; +mod obs_server; mod overlay; mod setup; mod tts; @@ -58,6 +59,11 @@ fn save_config( } config::save(&app, &config)?; overlay::apply_placement(&app, &config); + // Start the OBS server if this save just enabled it (no restart needed). + obs_server::ensure_started(&app, &config); + if let Ok(json) = serde_json::to_string(&config) { + obs_server::broadcast(&app, "config-updated", &json); + } app.emit("config-updated", &config).map_err(|e| e.to_string()) } @@ -101,6 +107,10 @@ fn main() { app.manage(capturing.clone()); app.manage(input::PendingErrors::default()); + // OBS browser-source server (opt-in; starts on demand from settings). + app.manage(Arc::new(obs_server::ObsHub::new())); + obs_server::ensure_started(&handle, &cfg); + overlay::create(&handle, &cfg)?; overlay::create_settings(&handle)?; if std::env::var("YAKC_SHOW_SETTINGS").is_ok() { diff --git a/src-tauri/src/obs_server.rs b/src-tauri/src/obs_server.rs new file mode 100644 index 0000000..4815fe2 --- /dev/null +++ b/src-tauri/src/obs_server.rs @@ -0,0 +1,223 @@ +//! OBS browser-source output. +//! +//! A tiny hand-rolled HTTP/1.1 server (std::net only) that serves the *same* +//! overlay frontend (`overlay.js` / `devices.js` / `style.css`) as the native +//! window, plus a Server-Sent-Events stream of the input events. A small +//! `window.__TAURI__` shim (`tauri-shim.js`) lets that unmodified frontend run +//! in a plain browser, so OBS can point a Browser source at +//! `http://localhost:/overlay` and get exactly what the desktop overlay +//! shows — transparent and live. +//! +//! We write the socket directly (rather than via a crate) so SSE frames are +//! flushed the instant they are produced; buffered HTTP servers break SSE. +//! +//! Localhost-only; only the overlay assets and the current config are served. +//! Opt-in via `obsServerEnabled`. + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use tauri::{AppHandle, Manager}; + +use crate::config::{Config, SharedConfig}; + +// Frontend assets, embedded so the server needs no filesystem access and stays +// consistent with the binary. Paths are relative to this source file. +const OBS_HTML: &str = include_str!("../../src/obs.html"); +const SHIM_JS: &str = include_str!("../../src/tauri-shim.js"); +const OVERLAY_JS: &str = include_str!("../../src/overlay.js"); +const DEVICES_JS: &str = include_str!("../../src/devices.js"); +const STYLE_CSS: &str = include_str!("../../src/style.css"); + +/// How often an idle SSE connection gets a heartbeat comment — keeps the +/// connection alive and lets us notice a client that has gone away. +const HEARTBEAT: Duration = Duration::from_secs(15); + +/// Fan-out of overlay events to every connected browser (OBS) client. Each +/// client is an SSE connection draining an mpsc channel of pre-framed bytes. +#[derive(Default)] +pub struct ObsHub { + clients: Mutex>>>, + started: AtomicBool, +} + +impl ObsHub { + pub fn new() -> Self { + Self::default() + } + + pub fn has_clients(&self) -> bool { + self.clients + .lock() + .map(|clients| !clients.is_empty()) + .unwrap_or(false) + } + + /// Sends `{event, payload}` to every connected client as one SSE frame. + /// `payload_json` must already be valid JSON. Dead clients are dropped. + pub fn broadcast(&self, event: &str, payload_json: &str) { + let frame = + format!("data: {{\"event\":\"{event}\",\"payload\":{payload_json}}}\n\n").into_bytes(); + if let Ok(mut clients) = self.clients.lock() { + clients.retain(|tx| tx.send(frame.clone()).is_ok()); + } + } + + fn subscribe(&self) -> mpsc::Receiver> { + let (tx, rx) = mpsc::channel(); + if let Ok(mut clients) = self.clients.lock() { + clients.push(tx); + } + rx + } +} + +/// True when at least one browser client is connected — lets hot paths skip +/// serialization when nobody is listening. +pub fn has_clients(app: &AppHandle) -> bool { + app.try_state::>() + .map(|hub| hub.has_clients()) + .unwrap_or(false) +} + +/// Broadcasts an overlay event to browser clients, mirroring an `app.emit`. +pub fn broadcast(app: &AppHandle, event: &str, payload_json: &str) { + if let Some(hub) = app.try_state::>() { + hub.broadcast(event, payload_json); + } +} + +/// Starts the server if the config enables it and it isn't already running. +/// Called at boot and after every settings save, so enabling the OBS source +/// takes effect immediately (no restart). Port changes still need a restart. +pub fn ensure_started(app: &AppHandle, config: &Config) { + if !config.obs_server_enabled { + return; + } + let (Some(hub), Some(shared)) = ( + app.try_state::>(), + app.try_state::(), + ) else { + return; + }; + // Start at most once. + if hub.started.swap(true, Ordering::SeqCst) { + return; + } + spawn((*shared).clone(), (*hub).clone(), config.obs_server_port); +} + +/// Binds `127.0.0.1:` and serves connections, one thread per connection. +pub fn spawn(config: SharedConfig, hub: Arc, port: u16) { + std::thread::spawn(move || { + let listener = match TcpListener::bind(("127.0.0.1", port)) { + Ok(listener) => listener, + Err(err) => { + eprintln!("YAKC: OBS server could not bind port {port}: {err}"); + return; + } + }; + eprintln!("YAKC: OBS browser source ready at http://localhost:{port}/overlay"); + + for stream in listener.incoming() { + let Ok(stream) = stream else { continue }; + let config = config.clone(); + let hub = hub.clone(); + // One thread per connection: SSE streams block for their lifetime. + std::thread::spawn(move || handle(stream, &config, &hub)); + } + }); +} + +fn handle(stream: TcpStream, config: &SharedConfig, hub: &Arc) { + // Read just the request line ("GET /path HTTP/1.1"); we don't need headers. + let Ok(peek) = stream.try_clone() else { return }; + let mut request_line = String::new(); + if BufReader::new(peek).read_line(&mut request_line).is_err() { + return; + } + let path = request_line + .split_whitespace() + .nth(1) + .unwrap_or("/") + .split('?') + .next() + .unwrap_or("/") + .to_string(); + + match path.as_str() { + "/" | "/overlay" => respond(stream, "text/html; charset=utf-8", OBS_HTML), + "/tauri-shim.js" => respond(stream, "text/javascript; charset=utf-8", SHIM_JS), + "/overlay.js" => respond(stream, "text/javascript; charset=utf-8", OVERLAY_JS), + "/devices.js" => respond(stream, "text/javascript; charset=utf-8", DEVICES_JS), + "/style.css" => respond(stream, "text/css; charset=utf-8", STYLE_CSS), + "/config" => { + let json = config + .read() + .ok() + .and_then(|cfg| serde_json::to_string(&*cfg).ok()) + .unwrap_or_else(|| "{}".to_string()); + respond(stream, "application/json; charset=utf-8", &json); + } + "/events" => stream_events(stream, hub), + _ => { + let _ = write!( + &stream, + "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + } + } +} + +/// Writes a complete (finite) response with a Content-Length, then closes. +fn respond(mut stream: TcpStream, content_type: &str, body: &str) { + let head = format!( + "HTTP/1.1 200 OK\r\n\ + Content-Type: {content_type}\r\n\ + Content-Length: {len}\r\n\ + Access-Control-Allow-Origin: *\r\n\ + Connection: close\r\n\r\n", + len = body.len() + ); + let _ = stream.write_all(head.as_bytes()); + let _ = stream.write_all(body.as_bytes()); + let _ = stream.flush(); +} + +/// Holds the connection open and forwards broadcast frames as they arrive, +/// flushing each one immediately so events reach the browser with no delay. +fn stream_events(mut stream: TcpStream, hub: &Arc) { + let head = "HTTP/1.1 200 OK\r\n\ + Content-Type: text/event-stream\r\n\ + Cache-Control: no-cache\r\n\ + Access-Control-Allow-Origin: *\r\n\ + Connection: keep-alive\r\n\r\n"; + if stream.write_all(head.as_bytes()).is_err() || stream.flush().is_err() { + return; + } + // Open the stream immediately so EventSource fires `onopen`. + if stream.write_all(b": connected\n\n").is_err() || stream.flush().is_err() { + return; + } + + let rx = hub.subscribe(); + loop { + match rx.recv_timeout(HEARTBEAT) { + Ok(frame) => { + if stream.write_all(&frame).is_err() || stream.flush().is_err() { + break; // client gone; dropping rx prunes it from the hub + } + } + Err(RecvTimeoutError::Timeout) => { + if stream.write_all(b": ping\n\n").is_err() || stream.flush().is_err() { + break; + } + } + Err(RecvTimeoutError::Disconnected) => break, + } + } +} diff --git a/src-tauri/src/overlay.rs b/src-tauri/src/overlay.rs index fcac506..9f87c96 100644 --- a/src-tauri/src/overlay.rs +++ b/src-tauri/src/overlay.rs @@ -25,6 +25,7 @@ pub fn create(app: &AppHandle, config: &Config) -> tauri::Result window.set_ignore_cursor_events(true)?; place_on_monitor(app, &window, config); + apply_visibility(&window, config); #[cfg(target_os = "macos")] raise_above_fullscreen(&window); @@ -32,6 +33,17 @@ pub fn create(app: &AppHandle, config: &Config) -> tauri::Result Ok(window) } +/// Shows or hides the on-screen overlay per `showOverlayOnScreen`. When hidden, +/// the OBS browser source still streams — letting users go OBS-only. +fn apply_visibility(window: &WebviewWindow, config: &Config) { + if config.show_overlay_on_screen { + let _ = window.show(); + let _ = window.set_always_on_top(true); + } else { + let _ = window.hide(); + } +} + /// Moves/sizes the overlay to fill the monitor selected by `showOnMonitor`. pub fn place_on_monitor(app: &AppHandle, window: &WebviewWindow, config: &Config) { let monitor = app @@ -53,6 +65,7 @@ pub fn place_on_monitor(app: &AppHandle, window: &WebviewWindow, config: &Config pub fn apply_placement(app: &AppHandle, config: &Config) { if let Some(window) = app.get_webview_window("overlay") { place_on_monitor(app, &window, config); + apply_visibility(&window, config); } } diff --git a/src/obs.html b/src/obs.html new file mode 100644 index 0000000..ac5427c --- /dev/null +++ b/src/obs.html @@ -0,0 +1,30 @@ + + + + + + YAKC — OBS overlay + + + + + + + +
+ + + + diff --git a/src/settings.js b/src/settings.js index 181ddd1..2f80b65 100644 --- a/src/settings.js +++ b/src/settings.js @@ -70,6 +70,14 @@ const SECTIONS = [ { key: "showGamepad", type: "bool", label: "Show gamepad input", hint: "Buttons as popups; sticks & triggers in a widget. Works with any XInput / DualShock-style controller." }, ], }, + { + title: "OBS / browser source", + fields: [ + { key: "obsServerEnabled", type: "bool", label: "Enable OBS browser source", hint: "Serves the overlay at http://localhost:/overlay for an OBS Browser source (transparent, live). Enabling starts it immediately; changing the port needs a restart." }, + { key: "obsServerPort", type: "number", label: "Server port", min: 1, max: 65535, step: 1, hint: "Default 7238" }, + { key: "showOverlayOnScreen", type: "bool", label: "Show overlay on this screen", hint: "Turn off to display only in the OBS browser source (avoids showing twice or seeing it locally)." }, + ], + }, { title: "Text-to-speech", fields: [ diff --git a/src/tauri-shim.js b/src/tauri-shim.js new file mode 100644 index 0000000..0f8a7ae --- /dev/null +++ b/src/tauri-shim.js @@ -0,0 +1,50 @@ +/** + * Tauri API shim for the OBS browser source. + * + * overlay.js and devices.js talk to the Rust side through `window.__TAURI__` + * (config via core.invoke, live input via event.listen). In a plain browser + * that object doesn't exist, so this shim provides it, backed by the YAKC OBS + * server: config over fetch(/config), live events over an SSE stream (/events). + * + * Loaded as a NON-deferred script before the deferred overlay.js / devices.js, + * so `window.__TAURI__` is ready before they run. + */ + +(() => { + const bus = new EventTarget(); + + // One shared SSE connection; the Rust hub sends {event, payload} frames. + const source = new EventSource("/events"); + source.onmessage = (e) => { + try { + const msg = JSON.parse(e.data); + bus.dispatchEvent(new CustomEvent(msg.event, { detail: msg.payload })); + } catch { + // Ignore malformed frames. + } + }; + + window.__TAURI__ = { + core: { + invoke: async (cmd) => { + switch (cmd) { + case "get_config": + return (await fetch("/config")).json(); + case "get_pending_errors": + return []; + case "get_config_path": + return "config.json"; + default: + return null; + } + }, + }, + event: { + listen: async (name, cb) => { + const handler = (e) => cb({ payload: e.detail }); + bus.addEventListener(name, handler); + return () => bus.removeEventListener(name, handler); + }, + }, + }; +})(); From c1c853ddc0eb7ed81e1b7a9705977039b8a2a8b7 Mon Sep 17 00:00:00 2001 From: Aslan Devecioglu Date: Thu, 30 Jul 2026 01:12:01 +0200 Subject: [PATCH 03/11] feat(overlay): drag-to-position (temporary non-click-through move mode) Completes the last README TODO. A tray item ('Move overlay position') and a settings button drop the overlay's click-through so the user can drag a handle to where popups should anchor, then Save (persists position=top-left + left/top offsets, live-applied and propagated to the OBS source) or Cancel. Rust: begin_move/end_move in overlay.rs + begin_overlay_move/end_overlay_move commands. Frontend: move-mode handle/toolbar in overlay.js, styles, and a reusable 'action' settings field type. Works in OBS-only mode by temporarily showing the overlay and restoring visibility on exit. --- src-tauri/src/main.rs | 23 ++++++++++- src-tauri/src/overlay.rs | 23 ++++++++++- src/overlay.js | 89 ++++++++++++++++++++++++++++++++++++++++ src/settings.js | 14 +++++-- src/style.css | 50 ++++++++++++++++++++++ 5 files changed, 193 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 5483909..f314c5c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -48,6 +48,19 @@ fn get_known_keys() -> Vec { keymap::known_keys() } +/// Enters drag-to-position mode on the overlay (temporary non-click-through). +#[tauri::command] +fn begin_overlay_move(app: AppHandle) { + overlay::begin_move(&app); +} + +/// Leaves drag-to-position mode, restoring click-through. +#[tauri::command] +fn end_overlay_move(app: AppHandle, state: State) { + let config = state.read().map(|cfg| cfg.clone()).unwrap_or_default(); + overlay::end_move(&app, &config); +} + #[tauri::command] fn save_config( app: AppHandle, @@ -95,7 +108,9 @@ fn main() { get_config_path, get_known_keys, get_pending_errors, - save_config + save_config, + begin_overlay_move, + end_overlay_move ]) .setup(|app| { let handle = app.handle().clone(); @@ -120,10 +135,13 @@ fn main() { // Tray let toggle_item = MenuItem::with_id(app, "toggle", "Toggle Capturing", true, None::<&str>)?; + let move_item = + MenuItem::with_id(app, "move", "Move overlay position", true, None::<&str>)?; let settings_item = MenuItem::with_id(app, "settings", "Settings…", true, None::<&str>)?; let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; - let menu = Menu::with_items(app, &[&toggle_item, &settings_item, &quit_item])?; + let menu = + Menu::with_items(app, &[&toggle_item, &move_item, &settings_item, &quit_item])?; let tray_capturing = capturing.clone(); TrayIconBuilder::with_id("main") @@ -133,6 +151,7 @@ fn main() { .show_menu_on_left_click(true) .on_menu_event(move |app, event| match event.id.as_ref() { "toggle" => toggle_capturing(&tray_capturing), + "move" => overlay::begin_move(app), "settings" => overlay::show_settings(app), "quit" => app.exit(0), _ => {} diff --git a/src-tauri/src/overlay.rs b/src-tauri/src/overlay.rs index 9f87c96..754871d 100644 --- a/src-tauri/src/overlay.rs +++ b/src-tauri/src/overlay.rs @@ -1,5 +1,5 @@ use tauri::{ - AppHandle, Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, + AppHandle, Emitter, Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, WebviewWindowBuilder, }; @@ -79,6 +79,27 @@ pub fn create_settings(app: &AppHandle) -> tauri::Result { .build() } +/// Enters "move" mode: makes the overlay interactive (not click-through) so the +/// user can drag the popup anchor to a new spot. Paired with `end_move`. +pub fn begin_move(app: &AppHandle) { + if let Some(window) = app.get_webview_window("overlay") { + let _ = window.show(); + let _ = window.set_ignore_cursor_events(false); + let _ = window.set_always_on_top(true); + let _ = window.set_focus(); + let _ = window.emit("overlay-move", true); + } +} + +/// Leaves "move" mode: restores click-through and the configured visibility. +pub fn end_move(app: &AppHandle, config: &Config) { + if let Some(window) = app.get_webview_window("overlay") { + let _ = window.set_ignore_cursor_events(true); + apply_visibility(&window, config); + let _ = window.emit("overlay-move", false); + } +} + pub fn show_settings(app: &AppHandle) { if let Some(window) = app.get_webview_window("settings") { let _ = window.show(); diff --git a/src/overlay.js b/src/overlay.js index c41ac2b..0be0366 100644 --- a/src/overlay.js +++ b/src/overlay.js @@ -181,6 +181,92 @@ function showNotice(message) { notice._timer = setTimeout(() => (notice.hidden = true), 30000); } +// Drag-to-position: the overlay becomes interactive (Rust drops click-through), +// the user drags a handle to where popups should anchor, then Saves (persists +// position=top-left + offsets) or Cancels. Only ever runs in the native +// overlay — the OBS browser page never receives the "overlay-move" event. +let moveState = null; + +function enterMoveMode() { + if (moveState) return; + + const backdrop = document.createElement("div"); + backdrop.id = "moveBackdrop"; + + const handle = document.createElement("div"); + handle.id = "moveHandle"; + handle.textContent = "Drag me — this is where your keys will appear"; + + // Start where the popups currently anchor. + const rect = popupArea.getBoundingClientRect(); + const startX = Math.min(Math.max(rect.left || 40, 0), window.innerWidth - 260); + const startY = Math.min(Math.max(rect.top || 40, 0), window.innerHeight - 80); + handle.style.left = `${startX}px`; + handle.style.top = `${startY}px`; + + const toolbar = document.createElement("div"); + toolbar.id = "moveToolbar"; + const saveBtn = document.createElement("button"); + saveBtn.textContent = "Save position"; + saveBtn.className = "move-save"; + const cancelBtn = document.createElement("button"); + cancelBtn.textContent = "Cancel"; + cancelBtn.className = "move-cancel"; + toolbar.append(saveBtn, cancelBtn); + + let dragging = false; + let offX = 0; + let offY = 0; + const onDown = (e) => { + dragging = true; + offX = e.clientX - handle.offsetLeft; + offY = e.clientY - handle.offsetTop; + e.preventDefault(); + }; + const onMove = (e) => { + if (!dragging) return; + const x = Math.min(Math.max(e.clientX - offX, 0), window.innerWidth - handle.offsetWidth); + const y = Math.min(Math.max(e.clientY - offY, 0), window.innerHeight - handle.offsetHeight); + handle.style.left = `${x}px`; + handle.style.top = `${y}px`; + }; + const onUp = () => { + dragging = false; + }; + + handle.addEventListener("mousedown", onDown); + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + + saveBtn.addEventListener("click", async () => { + config.position = "top-left"; + config.leftOffset = Math.round(handle.offsetLeft); + config.topOffset = Math.round(handle.offsetTop); + config.rightOffset = 0; + config.bottomOffset = 0; + try { + await core.invoke("save_config", { config }); + } catch { + // config-updated will not fire; leaving move mode still restores state. + } + await core.invoke("end_overlay_move"); + }); + cancelBtn.addEventListener("click", () => core.invoke("end_overlay_move")); + + document.body.append(backdrop, handle, toolbar); + moveState = { backdrop, handle, toolbar, onMove, onUp }; +} + +function exitMoveMode() { + if (!moveState) return; + window.removeEventListener("mousemove", moveState.onMove); + window.removeEventListener("mouseup", moveState.onUp); + moveState.backdrop.remove(); + moveState.handle.remove(); + moveState.toolbar.remove(); + moveState = null; +} + async function init() { popupArea = document.getElementById("popupArea"); config = await core.invoke("get_config"); @@ -192,6 +278,9 @@ async function init() { applyConfigStyles(); }); await tauriEvent.listen("yakc-error", (e) => showNotice(e.payload)); + await tauriEvent.listen("overlay-move", (e) => + e.payload ? enterMoveMode() : exitMoveMode() + ); // Errors raised before this page was listening (e.g. missing input-device // permission detected during the first device scan). diff --git a/src/settings.js b/src/settings.js index 2f80b65..fec21e1 100644 --- a/src/settings.js +++ b/src/settings.js @@ -25,6 +25,7 @@ const SECTIONS = [ fields: [ { key: "showOnMonitor", type: "number", label: "Monitor index", hint: "0 = first monitor", min: 0, step: 1 }, { key: "position", type: "select", label: "Screen position", options: ["top-left", "top-center", "top-right", "center", "bottom-left", "bottom-center", "bottom-right"] }, + { key: "moveOverlay", type: "action", label: "Drag to position", buttonLabel: "Drag on screen…", command: "begin_overlay_move", hint: "Opens a draggable handle on the overlay; drop it where you want, then Save." }, { key: "topOffset", type: "number", label: "Top offset (px)" }, { key: "bottomOffset", type: "number", label: "Bottom offset (px)" }, { key: "leftOffset", type: "number", label: "Left offset (px)" }, @@ -165,6 +166,12 @@ function buildForm() { window.location.href = "key_labels.html"; }); break; + case "action": + input = document.createElement("button"); + input.className = "link-btn"; + input.textContent = field.buttonLabel || "Run"; + input.addEventListener("click", () => core.invoke(field.command)); + break; default: input.type = "text"; input.value = config[field.key]; @@ -197,9 +204,10 @@ function collectForm() { .filter((s) => s.length > 0); break; case "link": - // Not an input — it links to another page. Leave the value from - // config untouched (spread above); collecting the button's empty - // string here would clobber keyLabelOverrides (a map). + case "action": + // Not inputs — a page link / a command button. Leave the config value + // untouched (spread above); collecting the button's empty string here + // would clobber real fields (e.g. keyLabelOverrides, a map). break; default: updated[key] = input.value; diff --git a/src/style.css b/src/style.css index f88e9d6..eacd20d 100644 --- a/src/style.css +++ b/src/style.css @@ -131,6 +131,56 @@ body { padding-bottom: 4px; } +/* Drag-to-position mode (overlay.js): a dimmed, interactive layer with a + draggable handle and a Save/Cancel toolbar. */ +#moveBackdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.25); + z-index: 10; +} + +#moveHandle { + position: fixed; + z-index: 12; + min-width: 220px; + padding: 14px 18px; + border-radius: 10px; + background: rgba(59, 130, 246, 0.92); + color: #ffffff; + font: 14px/1.3 system-ui, sans-serif; + cursor: move; + user-select: none; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45); +} + +#moveToolbar { + position: fixed; + z-index: 12; + bottom: 28px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 12px; +} + +#moveToolbar button { + padding: 9px 18px; + border-radius: 8px; + border: none; + font: 14px system-ui, sans-serif; + cursor: pointer; + color: #ffffff; +} + +#moveToolbar .move-save { + background: #22c55e; +} + +#moveToolbar .move-cancel { + background: #6b7280; +} + /* Error/permission notice (e.g. missing input-device permission on Linux) */ #notice { position: fixed; From 5147576c43cf10daf1eac1e69ad3d83ed23882be Mon Sep 17 00:00:00 2001 From: Aslan Devecioglu Date: Thu, 30 Jul 2026 02:08:58 +0200 Subject: [PATCH 04/11] feat(keyboard): on-screen keyboard display style (universal layout) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New displayStyle=keyboard renders an on-screen keyboard whose caps light up as you type, alongside the existing popups. Driven by physical key position (W3C codes) not characters, so it lights the correct cap on any layout (QWERTY/QWERTZ/AZERTY/…) and distinguishes left/right modifiers (hold-highlight). Works in the native overlay and the OBS browser source. Cap labels are read from the OS's active layout up front (get_key_labels), per-platform and native: Linux xkbcommon (verified), Windows ToUnicodeEx, macOS UCKeyTranslate — with live relabel from typed characters as a universal fallback. Backends now emit the physical code + forward modifier press/release. The keyboard is movable via drag-to-position (drag the real keyboard; snaps to center). NOTE: the Windows/macOS label enumerators compile only on those targets and are unverified from the Linux dev box — they need a CI/Win/Mac build to confirm. --- src-tauri/Cargo.lock | 2 + src-tauri/Cargo.toml | 13 +- src-tauri/src/config.rs | 4 + src-tauri/src/input/evdev_backend.rs | 89 ++++++++++++++ src-tauri/src/input/labels_macos.rs | 95 +++++++++++++++ src-tauri/src/input/labels_windows.rs | 47 ++++++++ src-tauri/src/input/mod.rs | 69 +++++++++++ src-tauri/src/input/rdev_backend.rs | 64 +++++++++- src-tauri/src/main.rs | 13 ++ src-tauri/src/obs_server.rs | 12 ++ src/index.html | 2 + src/keyboard.js | 166 ++++++++++++++++++++++++++ src/obs.html | 2 + src/overlay.js | 91 ++++++++++---- src/settings.js | 3 +- src/style.css | 52 ++++++++ src/tauri-shim.js | 2 + 17 files changed, 697 insertions(+), 29 deletions(-) create mode 100644 src-tauri/src/input/labels_macos.rs create mode 100644 src-tauri/src/input/labels_windows.rs create mode 100644 src/keyboard.js diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c76d1f5..800379d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5692,6 +5692,7 @@ name = "yakc" version = "2.0.0" dependencies = [ "active-win-pos-rs", + "core-foundation 0.10.1", "evdev", "gilrs", "libc", @@ -5705,6 +5706,7 @@ dependencies = [ "tauri-plugin-single-instance", "tts", "wayland-client", + "windows 0.58.0", "xkbcommon", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c8ce4c1..77d8546 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,6 +27,14 @@ gilrs = "0.11" [target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies] rdev = "0.5" +# Windows: read the active keyboard layout (ToUnicodeEx) to label the on-screen +# keyboard for the user's real layout. +[target.'cfg(target_os = "windows")'.dependencies] +windows = { version = "0.58", features = [ + "Win32_Foundation", + "Win32_UI_Input_KeyboardAndMouse", +] } + # Linux: read /dev/input directly so capture works identically on X11 and # Wayland; xkbcommon translates keycodes per the active layout. [target.'cfg(target_os = "linux")'.dependencies] @@ -37,9 +45,12 @@ xkbcommon = "0.8" wayland-client = "0.31" libc = "0.2" -# macOS: raise the overlay to screen-saver window level (above fullscreen apps). +# macOS: raise the overlay to screen-saver window level (above fullscreen apps); +# read the active keyboard layout (UCKeyTranslate/TIS) to label the on-screen +# keyboard for the user's real layout. [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" +core-foundation = "0.10" [profile.release] strip = true diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index f91e267..035bd07 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -79,6 +79,9 @@ pub struct Config { /// "text": popups behave like a text editor — only typed characters show, /// Backspace deletes. "raw": every key shows (modifiers, ⌫, arrows, …). pub display_mode: String, + /// Overlay rendering style. "popups": fading key popups (default). + /// "keyboard": an on-screen keyboard whose caps light up as you type. + pub display_style: String, /// Override display text for any key. Key = internal key id /// ("backspace", "f1", "meta", "ctrl", …), value = custom display text. /// E.g. {"meta": "MOD"} shows "MOD" instead of "META" in combos. @@ -138,6 +141,7 @@ impl Default for Config { filter_check_every_second: 0.5, toggle_capture_hotkey: "Ctrl+Alt+Y".into(), display_mode: "text".into(), + display_style: "popups".into(), key_label_overrides: HashMap::new(), obs_server_enabled: false, obs_server_port: 7238, diff --git a/src-tauri/src/input/evdev_backend.rs b/src-tauri/src/input/evdev_backend.rs index f361685..e745841 100644 --- a/src-tauri/src/input/evdev_backend.rs +++ b/src-tauri/src/input/evdev_backend.rs @@ -164,6 +164,12 @@ fn handle_key_event( // press (1) or autorepeat (2); repeats show popups like the original if is_modifier(code) { if value == 1 { + if let Some(mcode) = modifier_code(code) { + let _ = tx.send(RawInput::Modifier { + code: mcode, + pressed: true, + }); + } state.update_key(keycode, xkb::KeyDirection::Down); } return; @@ -200,18 +206,101 @@ fn handle_key_event( let _ = tx.send(RawInput::Key { text, named, + code: code_for(code), mods, repeat: value == 2, }); } } 0 => { + if is_modifier(code) { + if let Some(mcode) = modifier_code(code) { + let _ = tx.send(RawInput::Modifier { + code: mcode, + pressed: false, + }); + } + } state.update_key(keycode, xkb::KeyDirection::Up); } _ => {} } } +/// Physical modifier position for the on-screen keyboard. +fn modifier_code(code: evdev::KeyCode) -> Option<&'static str> { + use evdev::KeyCode as K; + Some(match code { + K::KEY_LEFTCTRL => "ControlLeft", + K::KEY_RIGHTCTRL => "ControlRight", + K::KEY_LEFTALT => "AltLeft", + K::KEY_RIGHTALT => "AltRight", + K::KEY_LEFTSHIFT => "ShiftLeft", + K::KEY_RIGHTSHIFT => "ShiftRight", + K::KEY_LEFTMETA => "MetaLeft", + K::KEY_RIGHTMETA => "MetaRight", + _ => return None, + }) +} + +/// Physical key → W3C KeyboardEvent.code, for the on-screen keyboard's caps +/// (which are addressed by physical position, so any layout lights up right). +/// One table drives both `code_for` (live events) and `key_labels` (startup +/// layout detection). +const CODE_TABLE: &[(evdev::KeyCode, &str)] = { + use evdev::KeyCode as K; + &[ + (K::KEY_A, "KeyA"), (K::KEY_B, "KeyB"), (K::KEY_C, "KeyC"), (K::KEY_D, "KeyD"), + (K::KEY_E, "KeyE"), (K::KEY_F, "KeyF"), (K::KEY_G, "KeyG"), (K::KEY_H, "KeyH"), + (K::KEY_I, "KeyI"), (K::KEY_J, "KeyJ"), (K::KEY_K, "KeyK"), (K::KEY_L, "KeyL"), + (K::KEY_M, "KeyM"), (K::KEY_N, "KeyN"), (K::KEY_O, "KeyO"), (K::KEY_P, "KeyP"), + (K::KEY_Q, "KeyQ"), (K::KEY_R, "KeyR"), (K::KEY_S, "KeyS"), (K::KEY_T, "KeyT"), + (K::KEY_U, "KeyU"), (K::KEY_V, "KeyV"), (K::KEY_W, "KeyW"), (K::KEY_X, "KeyX"), + (K::KEY_Y, "KeyY"), (K::KEY_Z, "KeyZ"), + (K::KEY_1, "Digit1"), (K::KEY_2, "Digit2"), (K::KEY_3, "Digit3"), (K::KEY_4, "Digit4"), + (K::KEY_5, "Digit5"), (K::KEY_6, "Digit6"), (K::KEY_7, "Digit7"), (K::KEY_8, "Digit8"), + (K::KEY_9, "Digit9"), (K::KEY_0, "Digit0"), + (K::KEY_MINUS, "Minus"), (K::KEY_EQUAL, "Equal"), + (K::KEY_LEFTBRACE, "BracketLeft"), (K::KEY_RIGHTBRACE, "BracketRight"), + (K::KEY_BACKSLASH, "Backslash"), (K::KEY_SEMICOLON, "Semicolon"), + (K::KEY_APOSTROPHE, "Quote"), (K::KEY_GRAVE, "Backquote"), + (K::KEY_COMMA, "Comma"), (K::KEY_DOT, "Period"), (K::KEY_SLASH, "Slash"), + (K::KEY_SPACE, "Space"), (K::KEY_ENTER, "Enter"), (K::KEY_TAB, "Tab"), + (K::KEY_BACKSPACE, "Backspace"), (K::KEY_CAPSLOCK, "CapsLock"), (K::KEY_ESC, "Escape"), + (K::KEY_UP, "ArrowUp"), (K::KEY_DOWN, "ArrowDown"), + (K::KEY_LEFT, "ArrowLeft"), (K::KEY_RIGHT, "ArrowRight"), + (K::KEY_F1, "F1"), (K::KEY_F2, "F2"), (K::KEY_F3, "F3"), (K::KEY_F4, "F4"), + (K::KEY_F5, "F5"), (K::KEY_F6, "F6"), (K::KEY_F7, "F7"), (K::KEY_F8, "F8"), + (K::KEY_F9, "F9"), (K::KEY_F10, "F10"), (K::KEY_F11, "F11"), (K::KEY_F12, "F12"), + ] +}; + +fn code_for(code: evdev::KeyCode) -> Option<&'static str> { + CODE_TABLE + .iter() + .find(|(key, _)| *key == code) + .map(|(_, w3c)| *w3c) +} + +/// Base (unshifted) character for every keyboard cap in the active layout, keyed +/// by W3C code — so the on-screen keyboard shows the user's real layout (QWERTZ, +/// AZERTY, …) immediately, without waiting for keys to be pressed. Uses the same +/// OS layout (xkbcommon) as live translation. +pub fn key_labels(layout_override: Option<&str>) -> std::collections::HashMap { + let mut labels = std::collections::HashMap::new(); + let Ok(state) = build_xkb_state(layout_override) else { + return labels; + }; + for (code, w3c) in CODE_TABLE { + let keycode = xkb::Keycode::new(code.0 as u32 + 8); + let text = state.key_get_utf8(keycode); + if !text.is_empty() && !text.chars().all(char::is_control) { + labels.insert(w3c.to_string(), text); + } + } + labels +} + /// Compiles an xkb keymap for the active layout. /// Priority: config override → the compositor's own keymap via the Wayland /// protocol (authoritative: exactly what the user configured in their desktop diff --git a/src-tauri/src/input/labels_macos.rs b/src-tauri/src/input/labels_macos.rs new file mode 100644 index 0000000..0bef8c8 --- /dev/null +++ b/src-tauri/src/input/labels_macos.rs @@ -0,0 +1,95 @@ +//! macOS: base (unshifted) label for each physical keyboard cap in the active +//! layout, via `UCKeyTranslate` on the current `TISInputSource` — the on-screen +//! keyboard then shows the user's real layout immediately. Parallels the Linux +//! `xkbcommon` path. + +use std::collections::HashMap; +use std::os::raw::c_void; + +use core_foundation::base::{CFRelease, TCFType}; +use core_foundation::data::{CFData, CFDataRef}; +use core_foundation::string::CFStringRef; + +// Carbon / HIToolbox APIs for reading the active Unicode keyboard layout. +#[link(name = "Carbon", kind = "framework")] +extern "C" { + fn TISCopyCurrentKeyboardLayoutInputSource() -> *mut c_void; + fn TISGetInputSourceProperty(input_source: *mut c_void, key: CFStringRef) -> *mut c_void; + static kTISPropertyUnicodeKeyLayoutData: CFStringRef; + fn LMGetKbdType() -> u8; + #[allow(clippy::too_many_arguments)] + fn UCKeyTranslate( + key_layout_ptr: *const u8, + virtual_key_code: u16, + key_action: u16, + modifier_key_state: u32, + keyboard_type: u32, + key_translate_options: u32, + dead_key_state: *mut u32, + max_string_length: u32, + actual_string_length: *mut u32, + unicode_string: *mut u16, + ) -> i32; +} + +const K_UC_KEY_ACTION_DISPLAY: u16 = 3; + +/// W3C KeyboardEvent.code → macOS ANSI virtual keycode (kVK_ANSI_*), printable only. +const KEYCODES: &[(&str, u16)] = &[ + ("KeyA", 0x00), ("KeyS", 0x01), ("KeyD", 0x02), ("KeyF", 0x03), ("KeyH", 0x04), + ("KeyG", 0x05), ("KeyZ", 0x06), ("KeyX", 0x07), ("KeyC", 0x08), ("KeyV", 0x09), + ("KeyB", 0x0B), ("KeyQ", 0x0C), ("KeyW", 0x0D), ("KeyE", 0x0E), ("KeyR", 0x0F), + ("KeyY", 0x10), ("KeyT", 0x11), + ("Digit1", 0x12), ("Digit2", 0x13), ("Digit3", 0x14), ("Digit4", 0x15), + ("Digit6", 0x16), ("Digit5", 0x17), ("Equal", 0x18), ("Digit9", 0x19), + ("Digit7", 0x1A), ("Minus", 0x1B), ("Digit8", 0x1C), ("Digit0", 0x1D), + ("BracketRight", 0x1E), ("KeyO", 0x1F), ("KeyU", 0x20), ("BracketLeft", 0x21), + ("KeyI", 0x22), ("KeyP", 0x23), ("KeyL", 0x25), ("KeyJ", 0x26), ("Quote", 0x27), + ("KeyK", 0x28), ("Semicolon", 0x29), ("Backslash", 0x2A), ("Comma", 0x2B), + ("Slash", 0x2C), ("KeyN", 0x2D), ("KeyM", 0x2E), ("Period", 0x2F), ("Backquote", 0x32), +]; + +pub fn key_labels(_layout_override: Option<&str>) -> HashMap { + let mut labels = HashMap::new(); + unsafe { + let source = TISCopyCurrentKeyboardLayoutInputSource(); + if source.is_null() { + return labels; + } + let data_ref = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData); + if data_ref.is_null() { + CFRelease(source); + return labels; + } + // +0 (get rule) → wrap so it's retained for the duration and released on drop. + let layout_data: CFData = CFData::wrap_under_get_rule(data_ref as CFDataRef); + let layout_ptr = layout_data.bytes().as_ptr(); // UCKeyboardLayout* + let kbd_type = LMGetKbdType() as u32; + + for (w3c, vk) in KEYCODES { + let mut dead_key_state: u32 = 0; + let mut buf = [0u16; 8]; + let mut len: u32 = 0; + let status = UCKeyTranslate( + layout_ptr, + *vk, + K_UC_KEY_ACTION_DISPLAY, + 0, // no modifiers → base character + kbd_type, + 0, + &mut dead_key_state, + buf.len() as u32, + &mut len, + buf.as_mut_ptr(), + ); + if status == 0 && len > 0 { + let text = String::from_utf16_lossy(&buf[..len as usize]); + if !text.is_empty() && !text.chars().all(char::is_control) { + labels.insert(w3c.to_string(), text); + } + } + } + CFRelease(source); + } + labels +} diff --git a/src-tauri/src/input/labels_windows.rs b/src-tauri/src/input/labels_windows.rs new file mode 100644 index 0000000..b6105bf --- /dev/null +++ b/src-tauri/src/input/labels_windows.rs @@ -0,0 +1,47 @@ +//! Windows: base (unshifted) label for each physical keyboard cap in the active +//! layout, via `ToUnicodeEx` — the on-screen keyboard then shows the user's real +//! layout immediately. Parallels the Linux `xkbcommon` path. + +use std::collections::HashMap; + +use windows::Win32::UI::Input::KeyboardAndMouse::{ + GetKeyboardLayout, MapVirtualKeyExW, ToUnicodeEx, MAPVK_VSC_TO_VK_EX, +}; + +/// W3C KeyboardEvent.code → PC/AT set-1 scan code (physical), printable keys only. +const SCANCODES: &[(&str, u16)] = &[ + ("Digit1", 0x02), ("Digit2", 0x03), ("Digit3", 0x04), ("Digit4", 0x05), + ("Digit5", 0x06), ("Digit6", 0x07), ("Digit7", 0x08), ("Digit8", 0x09), + ("Digit9", 0x0A), ("Digit0", 0x0B), ("Minus", 0x0C), ("Equal", 0x0D), + ("KeyQ", 0x10), ("KeyW", 0x11), ("KeyE", 0x12), ("KeyR", 0x13), ("KeyT", 0x14), + ("KeyY", 0x15), ("KeyU", 0x16), ("KeyI", 0x17), ("KeyO", 0x18), ("KeyP", 0x19), + ("BracketLeft", 0x1A), ("BracketRight", 0x1B), + ("KeyA", 0x1E), ("KeyS", 0x1F), ("KeyD", 0x20), ("KeyF", 0x21), ("KeyG", 0x22), + ("KeyH", 0x23), ("KeyJ", 0x24), ("KeyK", 0x25), ("KeyL", 0x26), + ("Semicolon", 0x27), ("Quote", 0x28), ("Backquote", 0x29), ("Backslash", 0x2B), + ("KeyZ", 0x2C), ("KeyX", 0x2D), ("KeyC", 0x2E), ("KeyV", 0x2F), ("KeyB", 0x30), + ("KeyN", 0x31), ("KeyM", 0x32), ("Comma", 0x33), ("Period", 0x34), ("Slash", 0x35), +]; + +pub fn key_labels(_layout_override: Option<&str>) -> HashMap { + let mut labels = HashMap::new(); + unsafe { + let hkl = GetKeyboardLayout(0); + let key_state = [0u8; 256]; // no modifiers held → base character + for (w3c, scancode) in SCANCODES { + let vk = MapVirtualKeyExW(*scancode as u32, MAPVK_VSC_TO_VK_EX, hkl); + if vk == 0 { + continue; + } + let mut buf = [0u16; 8]; + let n = ToUnicodeEx(vk, *scancode as u32, &key_state, &mut buf, 0, hkl); + if n > 0 { + let text = String::from_utf16_lossy(&buf[..n as usize]); + if !text.is_empty() && !text.chars().all(char::is_control) { + labels.insert(w3c.to_string(), text); + } + } + } + } + labels +} diff --git a/src-tauri/src/input/mod.rs b/src-tauri/src/input/mod.rs index 6abb1c6..2c7ab72 100644 --- a/src-tauri/src/input/mod.rs +++ b/src-tauri/src/input/mod.rs @@ -22,6 +22,27 @@ mod wayland_keymap; #[cfg(target_os = "linux")] use evdev_backend as platform; +// Per-OS enumeration of each physical key's base label in the active layout, so +// the on-screen keyboard shows the user's real layout immediately. Each platform +// uses its native API (Linux xkbcommon, Windows ToUnicodeEx, macOS UCKeyTranslate). +#[cfg(target_os = "linux")] +pub use evdev_backend::key_labels; + +#[cfg(target_os = "windows")] +mod labels_windows; +#[cfg(target_os = "windows")] +pub use labels_windows::key_labels; + +#[cfg(target_os = "macos")] +mod labels_macos; +#[cfg(target_os = "macos")] +pub use labels_macos::key_labels; + +#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] +pub fn key_labels(_layout_override: Option<&str>) -> std::collections::HashMap { + std::collections::HashMap::new() +} + /// Modifier state at the time of a key event. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct Mods { @@ -40,10 +61,21 @@ pub enum RawInput { text: Option, /// Backend-normalized id for non-printable keys ("backspace", "f1", …). named: Option<&'static str>, + /// Physical key position, W3C-KeyboardEvent-style ("KeyQ", "Digit1", + /// "Enter", …). Layout-independent, so the on-screen keyboard lights the + /// right cap on QWERTZ/AZERTY/etc. `None` for keys not on the keyboard. + code: Option<&'static str>, mods: Mods, /// True when this press is an auto-repeat of a held key. repeat: bool, }, + /// A modifier key changed state, with its physical side ("ShiftLeft", …). + /// Modifiers don't produce popups; this drives the on-screen keyboard so a + /// held modifier lights up (and the correct left/right cap). + Modifier { + code: &'static str, + pressed: bool, + }, MouseButton { button: u8, }, @@ -315,6 +347,7 @@ pub fn start(app: AppHandle, config: SharedConfig, capturing: Arc) { named, mods, repeat, + .. } = &event { if !repeat { @@ -364,15 +397,43 @@ pub fn start(app: AppHandle, config: SharedConfig, capturing: Arc) { } continue; } + // A held modifier lights (and un-lights) its exact cap. + RawInput::Modifier { code, pressed } => { + if cfg.display_style == "keyboard" && cfg.show_keyboard_click { + let payload = serde_json::json!({ "code": code, "pressed": pressed }); + emit_key_flash(&app, &payload); + } + continue; + } _ => {} } + // Keyboard-skin mode: flash the pressed cap by physical position, and + // relabel it from the character the OS produced (so the displayed + // keyboard matches the user's actual layout). + if cfg.display_style == "keyboard" && cfg.show_keyboard_click { + if let RawInput::Key { + text, + code: Some(code), + mods, + repeat: false, + .. + } = &event + { + // Only relabel from an unshifted press, to capture base chars. + let label = if mods.shift { None } else { text.as_deref() }; + let payload = serde_json::json!({ "code": code, "label": label }); + emit_key_flash(&app, &payload); + } + } + let op = match &event { RawInput::Key { text, named, mods, repeat, + .. } => { if !cfg.show_keyboard_click { continue; @@ -447,6 +508,14 @@ pub fn start(app: AppHandle, config: SharedConfig, capturing: Arc) { }); } +/// Emits a `key-flash` event to the native overlay and any OBS browser clients. +fn emit_key_flash(app: &AppHandle, payload: &serde_json::Value) { + let _ = app.emit_to("overlay", "key-flash", payload); + if crate::obs_server::has_clients(app) { + crate::obs_server::broadcast(app, "key-flash", &payload.to_string()); + } +} + /// Global cursor position. Works natively on Windows/macOS/X11. On Wayland the /// compositor hides the global cursor from applications, and the XWayland /// fallback returns stale garbage — better to show nothing than wrong numbers. diff --git a/src-tauri/src/input/rdev_backend.rs b/src-tauri/src/input/rdev_backend.rs index 166e316..3bf3cbf 100644 --- a/src-tauri/src/input/rdev_backend.rs +++ b/src-tauri/src/input/rdev_backend.rs @@ -25,6 +25,15 @@ pub fn spawn_listener( match event.event_type { EventType::KeyPress(key) => { if update_modifier(&mut mods, key, true) { + // Forward once (the OS delivers held modifiers as repeats). + if held.insert(key) { + if let Some(mcode) = modifier_code(key) { + let _ = tx.send(RawInput::Modifier { + code: mcode, + pressed: true, + }); + } + } return; } let repeat = !held.insert(key); @@ -43,13 +52,21 @@ pub fn spawn_listener( let _ = tx.send(RawInput::Key { text, named, + code: code_for(key), mods, repeat, }); } EventType::KeyRelease(key) => { held.remove(&key); - update_modifier(&mut mods, key, false); + if update_modifier(&mut mods, key, false) { + if let Some(mcode) = modifier_code(key) { + let _ = tx.send(RawInput::Modifier { + code: mcode, + pressed: false, + }); + } + } } EventType::ButtonPress(button) => { let button = match button { @@ -106,6 +123,51 @@ fn update_modifier(mods: &mut Mods, key: Key, pressed: bool) -> bool { true } +/// Physical modifier position for the on-screen keyboard. +fn modifier_code(key: Key) -> Option<&'static str> { + Some(match key { + Key::ControlLeft => "ControlLeft", + Key::ControlRight => "ControlRight", + Key::Alt => "AltLeft", + Key::AltGr => "AltRight", + Key::ShiftLeft => "ShiftLeft", + Key::ShiftRight => "ShiftRight", + Key::MetaLeft => "MetaLeft", + Key::MetaRight => "MetaRight", + _ => return None, + }) +} + +/// Physical key position (W3C KeyboardEvent.code style) for the on-screen +/// keyboard. Layout-independent, so the correct cap lights up on any layout. +fn code_for(key: Key) -> Option<&'static str> { + Some(match key { + Key::KeyA => "KeyA", Key::KeyB => "KeyB", Key::KeyC => "KeyC", Key::KeyD => "KeyD", + Key::KeyE => "KeyE", Key::KeyF => "KeyF", Key::KeyG => "KeyG", Key::KeyH => "KeyH", + Key::KeyI => "KeyI", Key::KeyJ => "KeyJ", Key::KeyK => "KeyK", Key::KeyL => "KeyL", + Key::KeyM => "KeyM", Key::KeyN => "KeyN", Key::KeyO => "KeyO", Key::KeyP => "KeyP", + Key::KeyQ => "KeyQ", Key::KeyR => "KeyR", Key::KeyS => "KeyS", Key::KeyT => "KeyT", + Key::KeyU => "KeyU", Key::KeyV => "KeyV", Key::KeyW => "KeyW", Key::KeyX => "KeyX", + Key::KeyY => "KeyY", Key::KeyZ => "KeyZ", + Key::Num1 => "Digit1", Key::Num2 => "Digit2", Key::Num3 => "Digit3", Key::Num4 => "Digit4", + Key::Num5 => "Digit5", Key::Num6 => "Digit6", Key::Num7 => "Digit7", Key::Num8 => "Digit8", + Key::Num9 => "Digit9", Key::Num0 => "Digit0", + Key::Minus => "Minus", Key::Equal => "Equal", + Key::LeftBracket => "BracketLeft", Key::RightBracket => "BracketRight", + Key::BackSlash => "Backslash", Key::SemiColon => "Semicolon", + Key::Quote => "Quote", Key::BackQuote => "Backquote", + Key::Comma => "Comma", Key::Dot => "Period", Key::Slash => "Slash", + Key::Space => "Space", Key::Return => "Enter", Key::Tab => "Tab", + Key::Backspace => "Backspace", Key::CapsLock => "CapsLock", Key::Escape => "Escape", + Key::UpArrow => "ArrowUp", Key::DownArrow => "ArrowDown", + Key::LeftArrow => "ArrowLeft", Key::RightArrow => "ArrowRight", + Key::F1 => "F1", Key::F2 => "F2", Key::F3 => "F3", Key::F4 => "F4", + Key::F5 => "F5", Key::F6 => "F6", Key::F7 => "F7", Key::F8 => "F8", + Key::F9 => "F9", Key::F10 => "F10", Key::F11 => "F11", Key::F12 => "F12", + _ => return None, + }) +} + /// Maps rdev non-printable keys to the shared named-key ids in keymap.rs. fn named_for(key: Key) -> Option<&'static str> { Some(match key { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index f314c5c..4c73dfe 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -48,6 +48,18 @@ fn get_known_keys() -> Vec { keymap::known_keys() } +/// Base labels per physical key from the OS layout, so the on-screen keyboard +/// shows the user's real layout immediately (QWERTZ/AZERTY/…), not QWERTY. +#[tauri::command] +fn get_key_labels(state: State) -> std::collections::HashMap { + let layout = state + .read() + .ok() + .map(|cfg| cfg.keyboard_layout.clone()) + .filter(|layout| !layout.trim().is_empty()); + input::key_labels(layout.as_deref()) +} + /// Enters drag-to-position mode on the overlay (temporary non-click-through). #[tauri::command] fn begin_overlay_move(app: AppHandle) { @@ -107,6 +119,7 @@ fn main() { get_config, get_config_path, get_known_keys, + get_key_labels, get_pending_errors, save_config, begin_overlay_move, diff --git a/src-tauri/src/obs_server.rs b/src-tauri/src/obs_server.rs index 4815fe2..41defa2 100644 --- a/src-tauri/src/obs_server.rs +++ b/src-tauri/src/obs_server.rs @@ -31,6 +31,7 @@ const OBS_HTML: &str = include_str!("../../src/obs.html"); const SHIM_JS: &str = include_str!("../../src/tauri-shim.js"); const OVERLAY_JS: &str = include_str!("../../src/overlay.js"); const DEVICES_JS: &str = include_str!("../../src/devices.js"); +const KEYBOARD_JS: &str = include_str!("../../src/keyboard.js"); const STYLE_CSS: &str = include_str!("../../src/style.css"); /// How often an idle SSE connection gets a heartbeat comment — keeps the @@ -154,6 +155,7 @@ fn handle(stream: TcpStream, config: &SharedConfig, hub: &Arc) { "/tauri-shim.js" => respond(stream, "text/javascript; charset=utf-8", SHIM_JS), "/overlay.js" => respond(stream, "text/javascript; charset=utf-8", OVERLAY_JS), "/devices.js" => respond(stream, "text/javascript; charset=utf-8", DEVICES_JS), + "/keyboard.js" => respond(stream, "text/javascript; charset=utf-8", KEYBOARD_JS), "/style.css" => respond(stream, "text/css; charset=utf-8", STYLE_CSS), "/config" => { let json = config @@ -163,6 +165,16 @@ fn handle(stream: TcpStream, config: &SharedConfig, hub: &Arc) { .unwrap_or_else(|| "{}".to_string()); respond(stream, "application/json; charset=utf-8", &json); } + "/key_labels" => { + let layout = config + .read() + .ok() + .map(|cfg| cfg.keyboard_layout.clone()) + .filter(|layout| !layout.trim().is_empty()); + let labels = crate::input::key_labels(layout.as_deref()); + let json = serde_json::to_string(&labels).unwrap_or_else(|_| "{}".to_string()); + respond(stream, "application/json; charset=utf-8", &json); + } "/events" => stream_events(stream, hub), _ => { let _ = write!( diff --git a/src/index.html b/src/index.html index a91a7e0..e39ada8 100644 --- a/src/index.html +++ b/src/index.html @@ -7,9 +7,11 @@ +
+