Capture a chosen keyboard's keys without window focus (Raw Input)

The WinForms focus path only sees keys while vRIO is foreground and can't
tell one keyboard from another, so vRIO went deaf the moment the sim took
focus. The new "Capture keyboard" picker in the Input panel taps one
physical keyboard through the Win32 Raw Input API (RIDEV_INPUTSINK) and
routes its keys to the panel even in the background — the input-side twin
of the lamp mirror writing that keyboard's LEDs under the same condition.

Raw Input observes without intercepting, so keys still reach the focused
app; the picker defaults to "All keyboards (focus only)", preserving the
prior focus-bound behavior, and only registers Raw Input while a specific
device is selected. While capturing, the focus path stands down but still
swallows bound keys so they can't click panel buttons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-06 21:31:49 -05:00
co-authored by Claude Opus 4.8
parent bec3bb1e4a
commit 391c53f294
3 changed files with 491 additions and 11 deletions
+104 -11
View File
@@ -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,
};
/// <summary>A keyboard choice in the lamp-mirror picker (null id = all).</summary>
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);
}
/// <summary>Apply the capture-picker selection: swap the keyboard input source.</summary>
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");
}
/// <summary>Refresh the capture picker, keeping the pick if the device survived.</summary>
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;