diff --git a/README.md b/README.md index 16b176e..b3aaf7c 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,17 @@ controls: F1–F6 / F7–F12 = the Secondary / Screen columns, numpad = internal keypad (hex keys on the operators), arrows + Space = hat + main, with ABXY / dpad / shoulders on the pad's named buttons. +- **Capture one keyboard in the background** (*Capture keyboard* picker in the + Input panel): by default keyboard input follows the focused window, so vRIO + goes deaf the moment the sim takes focus. Selecting a specific keyboard + instead taps it through the Windows **Raw Input** API (`RIDEV_INPUTSINK`), so + its keys drive the panel *while the game has focus* — the input-side twin of + the lamp mirror writing that keyboard's LEDs under the same condition. Raw + Input observes without intercepting: the keystroke still reaches the focused + app, so this is meant for a keyboard *dedicated* to the panel, not the one you + also type on. Leaving the picker on *All keyboards* keeps the plain + focus-only behavior. (No package identity needed — unlike the lamp side, this + is a plain Win32 tap.) ## Wire behavior diff --git a/src/VRio.App/MainForm.cs b/src/VRio.App/MainForm.cs index a3c52a8..a4b34fd 100644 --- a/src/VRio.App/MainForm.cs +++ b/src/VRio.App/MainForm.cs @@ -20,6 +20,8 @@ internal sealed class MainForm : Form private readonly InputRouter _router; private readonly XInputGamepad _gamepad = new(); private readonly KeyboardLampMirror _lampMirror; + private readonly RawKeyboardSource _rawKeyboard = new(); + private string? _activeInputId; // interface path of the keyboard being captured; null = WinForms focus path private readonly string _bindingsPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "vRIO", "bindings.txt"); @@ -72,6 +74,18 @@ internal sealed class MainForm : Form DropDownStyle = ComboBoxStyle.DropDownList, Enabled = false, // populated while the mirror is on }; + private readonly Label _kbInputLabel = new() + { + Text = "Capture keyboard (no window focus needed):", + Location = new Point(10, 168), + AutoSize = true, + }; + private readonly ComboBox _kbInputTarget = new() + { + Location = new Point(10, 186), + Width = 286, + DropDownStyle = ComboBoxStyle.DropDownList, + }; /// A keyboard choice in the lamp-mirror picker (null id = all). private sealed record KbChoice(string? Id, string Name) @@ -81,14 +95,14 @@ internal sealed class MainForm : Form private readonly Label _counters = new() { - Location = new Point(12, 414), + Location = new Point(12, 458), AutoSize = true, Font = new Font("Consolas", 8f), }; private readonly Label _help = new() { - Location = new Point(12, 470), + Location = new Point(12, 514), MaximumSize = new Size(306, 0), AutoSize = true, ForeColor = Color.Gray, @@ -99,7 +113,7 @@ internal sealed class MainForm : Form private readonly TextBox _logBox = new() { - Location = new Point(12, 554), + Location = new Point(12, 598), Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Both, // long wire lines don't wrap — scroll to read @@ -209,6 +223,18 @@ internal sealed class MainForm : Form _lampMirror.KeyboardsChanged += list => RunOnUi(() => RebuildKeyboardPicker(list)); _kbLightsTarget.Items.Add(new KbChoice(null, "All keyboards")); _kbLightsTarget.SelectedIndex = 0; + + // Raw-input capture: a chosen keyboard drives the panel even while the + // sim has focus. Down-edges only fire when keyboard input is enabled; + // up-edges always land so a release is never stranded by a mid-hold flip. + _rawKeyboard.KeyDown += name => RunOnUi(() => { if (_kbInput.Checked) _router.KeyDown(name); }); + _rawKeyboard.KeyUp += name => RunOnUi(() => _router.KeyUp(name)); + _rawKeyboard.Logged += line => RunOnUi(() => PrependLog(line)); + _rawKeyboard.KeyboardsChanged += list => RunOnUi(() => RebuildInputKeyboardPicker(list)); + _kbInputTarget.SelectedIndexChanged += (_, _) => ApplyInputSource(); + _kbInputTarget.DropDown += (_, _) => _rawKeyboard.RefreshKeyboards(); + _kbInputTarget.Items.Add(new KbChoice(null, "All keyboards (focus only)")); + _kbInputTarget.SelectedIndex = 0; _reloadBindings.Click += (_, _) => LoadBindings(); _editBindings.Click += (_, _) => OpenBindingsFile(); @@ -223,6 +249,7 @@ internal sealed class MainForm : Form _uiTimer.Dispose(); _inputTimer.Dispose(); _lampMirror.Dispose(); + _rawKeyboard.Dispose(); _service.Dispose(); }; @@ -261,13 +288,13 @@ internal sealed class MainForm : Form device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff, _testEnter, _testExit }); panel.Controls.Add(device); - var input = new GroupBox { Text = "Input", Location = new Point(12, 224), Size = new Size(306, 182) }; - input.Controls.AddRange(new Control[] { _kbInput, _padInput, _invertY, _padStatus, _reloadBindings, _editBindings, _kbLights, _kbLightsTarget }); + var input = new GroupBox { Text = "Input", Location = new Point(12, 224), Size = new Size(306, 226) }; + input.Controls.AddRange(new Control[] { _kbInput, _padInput, _invertY, _padStatus, _reloadBindings, _editBindings, _kbLights, _kbLightsTarget, _kbInputLabel, _kbInputTarget }); panel.Controls.Add(input); panel.Controls.Add(_counters); panel.Controls.Add(_help); - panel.Controls.Add(new Label { Text = "Wire log:", Location = new Point(12, 536), AutoSize = true }); + panel.Controls.Add(new Label { Text = "Wire log:", Location = new Point(12, 580), AutoSize = true }); _logBox.Size = new Size(306, ClientSize.Height - _logBox.Top - 44); panel.Controls.Add(_logBox); @@ -338,6 +365,30 @@ internal sealed class MainForm : Form _kbInput.Checked && !_portBox.ContainsFocus && !_verMajor.ContainsFocus && !_verMinor.ContainsFocus && !_logBox.ContainsFocus; + protected override void OnHandleCreated(EventArgs e) + { + base.OnHandleCreated(e); + _rawKeyboard.Attach(Handle); + _rawKeyboard.RefreshKeyboards(); // seed the capture picker + } + + protected override void WndProc(ref Message m) + { + // Raw Input arrives outside the normal key-message path; feed it to the + // capture source, then let DefWindowProc run its WM_INPUT cleanup. + const int WM_INPUT = 0x00FF, WM_INPUT_DEVICE_CHANGE = 0x00FE; + switch (m.Msg) + { + case WM_INPUT: + _rawKeyboard.ProcessInput(m.LParam); + break; + case WM_INPUT_DEVICE_CHANGE: + _rawKeyboard.HandleDeviceChange(); + break; + } + base.WndProc(ref m); + } + protected override bool ProcessCmdKey(ref Message msg, Keys keyData) { // Intercept WM_KEYDOWN before dialog-key processing, or arrows/space @@ -350,7 +401,10 @@ internal sealed class MainForm : Form string name = (keyData & Keys.KeyCode).ToString(); if (_router.HasKeyBinding(name)) { - _router.KeyDown(name); + // While a specific keyboard is captured, that source owns + // routing; still swallow the key so it can't click a button. + if (!_rawKeyboard.IsCapturing) + _router.KeyDown(name); return true; } } @@ -359,18 +413,57 @@ internal sealed class MainForm : Form protected override void OnKeyUp(KeyEventArgs e) { - // Unconditional: the router ignores keys it never saw go down, and a - // release must land even if the checkbox flipped mid-hold. - _router.KeyUp(e.KeyCode.ToString()); + // Unconditional in focus mode: the router ignores keys it never saw go + // down, and a release must land even if the checkbox flipped mid-hold. + // In capture mode the raw source owns edges, so don't double-release. + if (!_rawKeyboard.IsCapturing) + _router.KeyUp(e.KeyCode.ToString()); base.OnKeyUp(e); } protected override void OnDeactivate(EventArgs e) { - _router.ReleaseAllKeys(); // key-up events are lost once unfocused + // Focus mode loses key-up events once unfocused, so release held keys. + // Capture mode keeps receiving them in the background — leave holds be. + if (!_rawKeyboard.IsCapturing) + _router.ReleaseAllKeys(); base.OnDeactivate(e); } + /// Apply the capture-picker selection: swap the keyboard input source. + private void ApplyInputSource() + { + string? id = (_kbInputTarget.SelectedItem as KbChoice)?.Id; + if (id == _activeInputId) + return; + _activeInputId = id; + _router.ReleaseAllKeys(); // don't strand holds across a source swap + _rawKeyboard.SetTarget(id); + PrependLog(id is null + ? "Keyboard input: all keyboards, only while vRIO has focus" + : $"Keyboard input: capturing \"{(_kbInputTarget.SelectedItem as KbChoice)?.Name}\" in the background"); + } + + /// Refresh the capture picker, keeping the pick if the device survived. + private void RebuildInputKeyboardPicker(IReadOnlyList<(string Id, string Name)> keyboards) + { + string? selected = (_kbInputTarget.SelectedItem as KbChoice)?.Id; + _kbInputTarget.BeginUpdate(); + _kbInputTarget.Items.Clear(); + _kbInputTarget.Items.Add(new KbChoice(null, "All keyboards (focus only)")); + int select = 0; + foreach ((string id, string name) in keyboards) + { + int idx = _kbInputTarget.Items.Add(new KbChoice(id, name)); + if (id == selected) + select = idx; + } + // A vanished capture device falls back to "All keyboards"; the + // selection-changed handler (ApplyInputSource) unregisters accordingly. + _kbInputTarget.SelectedIndex = select; + _kbInputTarget.EndUpdate(); + } + private void InputTick() { double now = _clock.Elapsed.TotalSeconds; diff --git a/src/VRio.App/RawKeyboardSource.cs b/src/VRio.App/RawKeyboardSource.cs new file mode 100644 index 0000000..dfc7dfe --- /dev/null +++ b/src/VRio.App/RawKeyboardSource.cs @@ -0,0 +1,376 @@ +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using System.Windows.Forms; +using Microsoft.Win32; + +namespace VRio.App; + +/// +/// A background keyboard input source built on the Win32 Raw Input API. +/// Where the WinForms focus path (ProcessCmdKey/OnKeyUp) only sees +/// keys while vRIO is the foreground window and can't tell one keyboard from +/// another, Raw Input carries a per-device handle with every keystroke and — +/// with the RIDEV_INPUTSINK flag — keeps delivering WM_INPUT even +/// when vRIO is in the background. That lets the user nominate one physical +/// keyboard as a dedicated cockpit panel: its keys drive the RIO controls while +/// the sim (or RIOJoy) holds focus, and other keyboards are ignored. +/// +/// This is the input-side twin of : that +/// class writes a chosen keyboard's LEDs while the game has focus, this +/// one reads a chosen keyboard's keys under the same condition. Raw +/// Input observes without intercepting — the keystroke still reaches whatever +/// app has focus — so the selected keyboard should be one dedicated to the +/// panel, not the one you also type on. +/// +/// Registration is scoped to when a device is actually selected +/// (): picking "all keyboards" unregisters and hands +/// input back to the focus path, so vRIO isn't pumping WM_INPUT for every +/// keystroke system-wide when the feature is idle. Not thread-safe; +/// and the events fire on the UI thread that owns the +/// window handle, which is exactly where wants +/// to be called. +/// +public sealed class RawKeyboardSource : IDisposable +{ + // --- Win32 Raw Input interop --------------------------------------------- + + [StructLayout(LayoutKind.Sequential)] + private struct RAWINPUTDEVICE + { + public ushort usUsagePage; + public ushort usUsage; + public uint dwFlags; + public IntPtr hwndTarget; + } + + [StructLayout(LayoutKind.Sequential)] + private struct RAWINPUTDEVICELIST + { + public IntPtr hDevice; + public uint dwType; + } + + [StructLayout(LayoutKind.Sequential)] + private struct RAWINPUTHEADER + { + public uint dwType; + public uint dwSize; + public IntPtr hDevice; + public IntPtr wParam; + } + + [StructLayout(LayoutKind.Sequential)] + private struct RAWKEYBOARD + { + public ushort MakeCode; + public ushort Flags; + public ushort Reserved; + public ushort VKey; + public uint Message; + public uint ExtraInformation; + } + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool RegisterRawInputDevices( + [In] RAWINPUTDEVICE[] pRawInputDevices, uint uiNumDevices, uint cbSize); + + [DllImport("user32.dll", SetLastError = true)] + private static extern uint GetRawInputData( + IntPtr hRawInput, uint uiCommand, IntPtr pData, ref uint pcbSize, uint cbSizeHeader); + + [DllImport("user32.dll", SetLastError = true)] + private static extern uint GetRawInputDeviceList( + [In, Out] RAWINPUTDEVICELIST[]? pRawInputDeviceList, ref uint puiNumDevices, uint cbSize); + + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern uint GetRawInputDeviceInfoW( + IntPtr hDevice, uint uiCommand, IntPtr pData, ref uint pcbSize); + + private const uint Err = 0xFFFFFFFF; // sentinel: all three APIs return (UINT)-1 on failure + private const uint RIDEV_REMOVE = 0x00000001; + private const uint RIDEV_INPUTSINK = 0x00000100; + private const uint RIDEV_DEVNOTIFY = 0x00002000; // WM_INPUT_DEVICE_CHANGE on hot-plug + private const uint RID_INPUT = 0x10000003; + private const uint RIDI_DEVICENAME = 0x20000007; + private const uint RIM_TYPEKEYBOARD = 1; + private const ushort RI_KEY_BREAK = 0x01; // set = key up (make/break) + private const ushort HidUsagePageGeneric = 0x01; + private const ushort HidUsageKeyboard = 0x06; + + private static readonly int HeaderSize = Marshal.SizeOf(); + + // --- State --------------------------------------------------------------- + + // hDevice → interface path, so the hot per-keystroke path skips the + // two-call GetRawInputDeviceInfo dance once a device is known. + private readonly Dictionary _pathByHandle = new(); + + private IntPtr _hwnd; + private string? _target; // interface path of the keyboard to capture; null = disabled + private bool _registered; + private bool _disposed; + + /// A key on the captured keyboard went down (.NET name). + public event Action? KeyDown; + + /// A key on the captured keyboard came up (.NET name). + public event Action? KeyUp; + + /// The set of attached keyboards changed (interface path, display name) — for a picker. + public event Action>? KeyboardsChanged; + + /// Status/diagnostic lines. + public event Action? Logged; + + /// True while a specific keyboard is being captured (focus path should stand down). + public bool IsCapturing => _target is not null; + + /// Bind to the window whose message loop delivers WM_INPUT. + public void Attach(IntPtr hwnd) => _hwnd = hwnd; + + /// + /// Capture one keyboard by interface path, or none ( + /// null) to release input back to the focus path. Registering/unregistering + /// Raw Input is scoped here so vRIO only taps the global key stream while the + /// feature is actually in use. + /// + public void SetTarget(string? deviceId) + { + if (_target == deviceId) + return; + _target = deviceId; + if (deviceId is null) + Unregister(); + else + Register(); + } + + /// Re-enumerate keyboards and raise . + public void RefreshKeyboards() + { + _pathByHandle.Clear(); // handles are only stable per connection + KeyboardsChanged?.Invoke(EnumerateKeyboards()); + } + + /// Handle a WM_INPUT_DEVICE_CHANGE: a keyboard arrived or left. + public void HandleDeviceChange() => RefreshKeyboards(); + + /// + /// Handle a WM_INPUT message (pass its LParam). Silently ignores + /// input from any keyboard other than the captured one. + /// + public void ProcessInput(IntPtr hRawInput) + { + string? target = _target; + if (target is null) + return; + + uint size = 0; + if (GetRawInputData(hRawInput, RID_INPUT, IntPtr.Zero, ref size, (uint)HeaderSize) == Err || size == 0) + return; + + IntPtr buffer = Marshal.AllocHGlobal((int)size); + try + { + if (GetRawInputData(hRawInput, RID_INPUT, buffer, ref size, (uint)HeaderSize) != size) + return; + + var header = Marshal.PtrToStructure(buffer); + if (header.dwType != RIM_TYPEKEYBOARD) + return; + string? path = PathFor(header.hDevice); + if (!string.Equals(path, target, StringComparison.OrdinalIgnoreCase)) + return; + + var kb = Marshal.PtrToStructure(IntPtr.Add(buffer, HeaderSize)); + ushort vkey = kb.VKey; + // 0 = no VK, 0xFF = fake key emitted as part of an escaped sequence. + if (vkey is 0 or 0xFF) + return; + + string key = ((Keys)vkey).ToString(); + if ((kb.Flags & RI_KEY_BREAK) != 0) + KeyUp?.Invoke(key); + else + KeyDown?.Invoke(key); // repeats while held are dropped by the router + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _target = null; + Unregister(); + KeyDown = null; + KeyUp = null; + KeyboardsChanged = null; + Logged = null; + } + + // --- Registration -------------------------------------------------------- + + private void Register() + { + if (_registered || _hwnd == IntPtr.Zero) + return; + var rid = new[] + { + new RAWINPUTDEVICE + { + usUsagePage = HidUsagePageGeneric, + usUsage = HidUsageKeyboard, + dwFlags = RIDEV_INPUTSINK | RIDEV_DEVNOTIFY, // background delivery + hot-plug notices + hwndTarget = _hwnd, + }, + }; + if (RegisterRawInputDevices(rid, 1, (uint)Marshal.SizeOf())) + _registered = true; + else + Logged?.Invoke($"Raw keyboard capture unavailable (error {Marshal.GetLastWin32Error()})"); + } + + private void Unregister() + { + if (!_registered) + return; + // RIDEV_REMOVE must pass a null target window, or the call fails. + var rid = new[] + { + new RAWINPUTDEVICE + { + usUsagePage = HidUsagePageGeneric, + usUsage = HidUsageKeyboard, + dwFlags = RIDEV_REMOVE, + hwndTarget = IntPtr.Zero, + }, + }; + RegisterRawInputDevices(rid, 1, (uint)Marshal.SizeOf()); + _registered = false; + } + + // --- Enumeration / naming ------------------------------------------------ + + private List<(string Id, string Name)> EnumerateKeyboards() + { + var result = new List<(string, string)>(); + uint listSize = (uint)Marshal.SizeOf(); + uint count = 0; + if (GetRawInputDeviceList(null, ref count, listSize) == Err || count == 0) + return result; + + var devices = new RAWINPUTDEVICELIST[count]; + uint got = GetRawInputDeviceList(devices, ref count, listSize); + if (got == Err) + return result; + + var labelCounts = new Dictionary(); + for (int i = 0; i < got; i++) + { + if (devices[i].dwType != RIM_TYPEKEYBOARD) + continue; + string? path = PathFor(devices[i].hDevice); + if (string.IsNullOrEmpty(path)) + continue; + + string label = FriendlyName(path!); + // Physical keyboards can surface as more than one collection; a bare + // count suffix keeps otherwise-identical labels distinguishable. + if (labelCounts.TryGetValue(label, out int n)) + { + labelCounts[label] = n + 1; + label = $"{label} ({n + 1})"; + } + else + { + labelCounts[label] = 1; + } + result.Add((path!, label)); + } + return result; + } + + private string? PathFor(IntPtr hDevice) + { + if (_pathByHandle.TryGetValue(hDevice, out string? cached)) + return cached; + string? path = DeviceName(hDevice); + if (path is not null) + _pathByHandle[hDevice] = path; + return path; + } + + private static string? DeviceName(IntPtr hDevice) + { + uint chars = 0; + if (GetRawInputDeviceInfoW(hDevice, RIDI_DEVICENAME, IntPtr.Zero, ref chars) == Err || chars == 0) + return null; + + IntPtr buffer = Marshal.AllocHGlobal((int)chars * sizeof(char)); + try + { + if (GetRawInputDeviceInfoW(hDevice, RIDI_DEVICENAME, buffer, ref chars) == Err) + return null; + return Marshal.PtrToStringUni(buffer); + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + /// + /// Turn a raw device interface path into something a human recognizes: the + /// device's registry description if we can resolve it, else its VID:PID. + /// + private static string FriendlyName(string interfacePath) + { + string? desc = RegistryName(interfacePath); + if (!string.IsNullOrWhiteSpace(desc)) + return desc!; + + Match m = Regex.Match(interfacePath, @"VID_([0-9A-Fa-f]{4}).*?PID_([0-9A-Fa-f]{4})"); + return m.Success + ? $"Keyboard {m.Groups[1].Value.ToUpperInvariant()}:{m.Groups[2].Value.ToUpperInvariant()}" + : "Keyboard"; + } + + private static string? RegistryName(string interfacePath) + { + try + { + // \\?\HID#VID_x&PID_y#instance#{class-guid} → Enum key HID\VID_x&PID_y\instance + string s = interfacePath; + if (s.StartsWith(@"\\?\", StringComparison.Ordinal) || s.StartsWith(@"\\.\", StringComparison.Ordinal)) + s = s.Substring(4); + int guid = s.LastIndexOf("#{", StringComparison.Ordinal); + if (guid >= 0) + s = s.Substring(0, guid); + string instance = s.Replace('#', '\\'); + + using RegistryKey? key = Registry.LocalMachine.OpenSubKey( + @"SYSTEM\CurrentControlSet\Enum\" + instance); + if (key is null) + return null; + + string? name = key.GetValue("FriendlyName") as string; + if (string.IsNullOrWhiteSpace(name)) + name = key.GetValue("DeviceDesc") as string; + if (string.IsNullOrWhiteSpace(name)) + return null; + + // DeviceDesc is "@driver.inf,%token%;Actual Name" — keep the tail. + int semi = name!.LastIndexOf(';'); + return semi >= 0 ? name.Substring(semi + 1) : name; + } + catch (Exception ex) when (ex is System.Security.SecurityException or UnauthorizedAccessException or IOException) + { + return null; + } + } +}