Keyboard and Xbox-gamepad input drive the panel
New input pipeline: sources feed an InputRouter (VRio.Core.Input) that calls the same VRioDevice press/release/axis entry points the mouse canvas uses, so routed input is indistinguishable on the wire. - Bindings live in %APPDATA%\vRIO\bindings.txt (plain text, commented defaults written on first run; Reload/Edit buttons in the new Input group). Keys and pad buttons press any RIO address, momentary or toggle; axes bind in normalized units of each axis realistic travel window with deflect (spring-back), rate (position holds), deadzone, and invert options - RioAxisRange supplies the wire signs. - Router suppresses key auto-repeat, edge-detects pad buttons, and hold-counts per address so overlapping sources press once and release last; axes only write when the composed value changes, so mouse drags keep working while sources idle. - Xbox pad via a zero-dependency XInput P/Invoke poller (xinput1_4, fallback xinput9_1_0), throttled rescan while disconnected. - MainForm intercepts bound keys in ProcessCmdKey so arrows/space reach the panel instead of moving focus; keys release on focus loss; center-axes and host ResetRequest reset the rate integrators. - Canvas highlights router-held addresses like clicks. - Defaults: arrows=stick, W/S=throttle, Q/E=pedals, numpad=internal keypad, IJKL/Space=hat+main; pad left stick/triggers/right stick = stick/pedals/throttle-rate, ABXY/dpad/shoulders = named buttons. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
using VRio.Core.Device;
|
||||
using VRio.Core.Input;
|
||||
using VRio.Core.Protocol;
|
||||
using Xunit;
|
||||
|
||||
namespace VRio.Core.Tests;
|
||||
|
||||
public class BindingProfileFormatTests
|
||||
{
|
||||
[Fact]
|
||||
public void Default_profile_parses_clean()
|
||||
{
|
||||
BindingProfile profile = BindingProfileFormat.Parse(BindingProfileFormat.DefaultText, out var errors);
|
||||
|
||||
Assert.Empty(errors);
|
||||
Assert.True(profile.KeyButtons.Count >= 19); // named buttons + numpad digits
|
||||
Assert.Equal(8, profile.KeyAxes.Count); // arrows + W/S + Q/E
|
||||
Assert.Equal(10, profile.PadButtons.Count);
|
||||
Assert.Equal(5, profile.PadAxes.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lines_parse_into_typed_bindings()
|
||||
{
|
||||
BindingProfile profile = BindingProfileFormat.Parse(
|
||||
"key SPACE button 0x40 toggle # comment\n" +
|
||||
"key w axis throttle rate -0.5\n" +
|
||||
"pad dpadup button 81\n" +
|
||||
"padaxis lefttrigger axis LeftPedal invert deadzone 0.25 rate 2\n",
|
||||
out var errors);
|
||||
|
||||
Assert.Empty(errors);
|
||||
|
||||
KeyButtonBinding kb = Assert.Single(profile.KeyButtons);
|
||||
Assert.Equal(("SPACE", 0x40, true), (kb.Key, kb.Address, kb.Toggle));
|
||||
|
||||
KeyAxisBinding ka = Assert.Single(profile.KeyAxes);
|
||||
Assert.Equal((RioAxis.Throttle, KeyAxisMode.Rate, -0.5f), (ka.Axis, ka.Mode, ka.Value));
|
||||
|
||||
PadButtonBinding pb = Assert.Single(profile.PadButtons);
|
||||
Assert.Equal((PadButtons.DPadUp, 81), (pb.Button, pb.Address)); // decimal keypad address
|
||||
|
||||
PadAxisBinding pa = Assert.Single(profile.PadAxes);
|
||||
Assert.Equal((PadAxis.LeftTrigger, RioAxis.LeftPedal, true, 0.25f, 2f),
|
||||
(pa.Source, pa.Axis, pa.Invert, pa.Deadzone, pa.Rate));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("key W button 0x48")] // hole between buttons and keypads
|
||||
[InlineData("key W button zz")]
|
||||
[InlineData("key W axis Throttle deflect")] // missing value
|
||||
[InlineData("key W axis Throttle wiggle 1")] // unknown mode
|
||||
[InlineData("pad Guide button 0x00")] // unknown pad button
|
||||
[InlineData("padaxis LeftStickX axis JoystickX sideways")]
|
||||
[InlineData("mouse X button 0x00")] // unknown source
|
||||
public void Bad_lines_are_reported_and_skipped(string line)
|
||||
{
|
||||
BindingProfile profile = BindingProfileFormat.Parse("key A button 0x00\n" + line, out var errors);
|
||||
|
||||
string error = Assert.Single(errors);
|
||||
Assert.StartsWith("line 2:", error);
|
||||
Assert.Equal(1, profile.Count); // the good line survived
|
||||
}
|
||||
}
|
||||
|
||||
public class InputRouterTests
|
||||
{
|
||||
private readonly VRioDevice _device = new();
|
||||
private readonly InputRouter _router;
|
||||
private readonly List<byte[]> _tx = new();
|
||||
|
||||
public InputRouterTests()
|
||||
{
|
||||
_router = new InputRouter(_device);
|
||||
_device.Transmit += _tx.Add;
|
||||
}
|
||||
|
||||
private void UseProfile(string text)
|
||||
{
|
||||
_router.Profile = BindingProfileFormat.Parse(text, out var errors);
|
||||
Assert.Empty(errors);
|
||||
_tx.Clear();
|
||||
}
|
||||
|
||||
// ---- Buttons ------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Key_press_and_release_hit_the_wire_once_despite_repeats()
|
||||
{
|
||||
UseProfile("key Space button 0x40");
|
||||
|
||||
_router.KeyDown("Space");
|
||||
_router.KeyDown("Space"); // auto-repeat
|
||||
_router.KeyDown("Space");
|
||||
_router.KeyUp("Space");
|
||||
|
||||
Assert.Equal(new[] { PacketBuilder.ButtonPressed(0x40), PacketBuilder.ButtonReleased(0x40) }, _tx);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Keypad_addresses_go_out_as_key_events()
|
||||
{
|
||||
UseProfile("key NumPad7 button 0x67"); // external keypad, key 7
|
||||
|
||||
_router.KeyDown("NumPad7");
|
||||
_router.KeyUp("NumPad7");
|
||||
|
||||
Assert.Equal(new[] { PacketBuilder.KeyPressed(1, 7), PacketBuilder.KeyReleased(1, 7) }, _tx);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Toggle_latches_across_key_up()
|
||||
{
|
||||
UseProfile("key T button 0x12 toggle");
|
||||
|
||||
_router.KeyDown("T");
|
||||
_router.KeyUp("T");
|
||||
Assert.Equal(new[] { PacketBuilder.ButtonPressed(0x12) }, _tx);
|
||||
|
||||
_router.KeyDown("T"); // second press releases the latch
|
||||
_router.KeyUp("T");
|
||||
Assert.Equal(PacketBuilder.ButtonReleased(0x12), _tx[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Overlapping_sources_on_one_address_press_once_release_last()
|
||||
{
|
||||
UseProfile("key Space button 0x40\npad A button 0x40");
|
||||
var held = new List<(int Address, bool Held)>();
|
||||
_router.AddressHeldChanged += (a, h) => held.Add((a, h));
|
||||
|
||||
_router.KeyDown("Space");
|
||||
_router.SetPadState(new PadState(PadButtons.A, 0, 0, 0, 0, 0, 0));
|
||||
_router.KeyUp("Space");
|
||||
Assert.Equal(new[] { PacketBuilder.ButtonPressed(0x40) }, _tx); // still held by the pad
|
||||
|
||||
_router.SetPadState(default);
|
||||
Assert.Equal(PacketBuilder.ButtonReleased(0x40), _tx[1]);
|
||||
Assert.Equal(new[] { (0x40, true), (0x40, false) }, held);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pad_button_edges_only_fire_on_change()
|
||||
{
|
||||
UseProfile("pad B button 0x3D");
|
||||
var down = new PadState(PadButtons.B, 0, 0, 0, 0, 0, 0);
|
||||
|
||||
_router.SetPadState(down);
|
||||
_router.SetPadState(down); // unchanged snapshot
|
||||
_router.SetPadState(default);
|
||||
|
||||
Assert.Equal(new[] { PacketBuilder.ButtonPressed(0x3D), PacketBuilder.ButtonReleased(0x3D) }, _tx);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReleaseAllKeys_releases_momentaries_but_keeps_toggles()
|
||||
{
|
||||
UseProfile("key A button 0x01\nkey B button 0x02 toggle");
|
||||
|
||||
_router.KeyDown("A");
|
||||
_router.KeyDown("B");
|
||||
_router.ReleaseAllKeys();
|
||||
|
||||
Assert.Equal(0, _device.GetLamp(0)); // sanity: device untouched otherwise
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
PacketBuilder.ButtonPressed(0x01),
|
||||
PacketBuilder.ButtonPressed(0x02),
|
||||
PacketBuilder.ButtonReleased(0x01), // toggle at 0x02 stays latched
|
||||
}, _tx);
|
||||
}
|
||||
|
||||
// ---- Axes ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Deflect_key_holds_full_travel_and_springs_back()
|
||||
{
|
||||
UseProfile("key Up axis JoystickY deflect 1\nkey Down axis JoystickY deflect -1");
|
||||
|
||||
_router.KeyDown("Up");
|
||||
_router.Tick(0.016);
|
||||
Assert.Equal(RioAxisRange.JoystickExtent, _device.GetAxis(RioAxis.JoystickY));
|
||||
|
||||
_router.KeyDown("Down"); // opposite deflects cancel
|
||||
_router.Tick(0.016);
|
||||
Assert.Equal(0, _device.GetAxis(RioAxis.JoystickY));
|
||||
|
||||
_router.KeyUp("Up");
|
||||
_router.KeyUp("Down");
|
||||
_router.Tick(0.016);
|
||||
Assert.Equal(0, _device.GetAxis(RioAxis.JoystickY));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rate_key_walks_the_throttle_and_position_sticks()
|
||||
{
|
||||
UseProfile("key W axis Throttle rate 0.5");
|
||||
|
||||
_router.KeyDown("W");
|
||||
_router.Tick(1.0); // half travel
|
||||
Assert.Equal(RioAxisRange.ThrottleFull / 2, _device.GetAxis(RioAxis.Throttle));
|
||||
|
||||
_router.KeyUp("W");
|
||||
_router.Tick(1.0); // released: stays put
|
||||
Assert.Equal(RioAxisRange.ThrottleFull / 2, _device.GetAxis(RioAxis.Throttle));
|
||||
|
||||
_router.KeyDown("W");
|
||||
_router.Tick(10.0); // clamps at full realistic travel
|
||||
Assert.Equal(RioAxisRange.ThrottleFull, _device.GetAxis(RioAxis.Throttle));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pad_stick_maps_through_deadzone_and_invert()
|
||||
{
|
||||
UseProfile("padaxis LeftStickX axis JoystickX invert deadzone 0.2");
|
||||
|
||||
// Inside the deadzone: centered.
|
||||
_router.SetPadState(new PadState(PadButtons.None, 0.1f, 0, 0, 0, 0, 0));
|
||||
_router.Tick(0.016);
|
||||
Assert.Equal(0, _device.GetAxis(RioAxis.JoystickX));
|
||||
|
||||
// Full right, inverted → raw negative full extent (the wire direction).
|
||||
_router.SetPadState(new PadState(PadButtons.None, 1f, 0, 0, 0, 0, 0));
|
||||
_router.Tick(0.016);
|
||||
Assert.Equal(-RioAxisRange.JoystickExtent, _device.GetAxis(RioAxis.JoystickX));
|
||||
|
||||
// Pad gone → springs back.
|
||||
_router.SetPadState(default);
|
||||
_router.Tick(0.016);
|
||||
Assert.Equal(0, _device.GetAxis(RioAxis.JoystickX));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pad_trigger_drives_pedal_to_full_press()
|
||||
{
|
||||
UseProfile("padaxis RightTrigger axis RightPedal");
|
||||
|
||||
_router.SetPadState(new PadState(PadButtons.None, 0, 0, 0, 0, 0, 1f));
|
||||
_router.Tick(0.016);
|
||||
Assert.Equal(RioAxisRange.PedalFull, _device.GetAxis(RioAxis.RightPedal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rate_mode_pad_axis_integrates_and_holds()
|
||||
{
|
||||
UseProfile("padaxis RightStickY axis Throttle rate 0.5");
|
||||
|
||||
_router.SetPadState(new PadState(PadButtons.None, 0, 0, 0, 1f, 0, 0));
|
||||
_router.Tick(1.0);
|
||||
Assert.Equal(RioAxisRange.ThrottleFull / 2, _device.GetAxis(RioAxis.Throttle));
|
||||
|
||||
_router.SetPadState(default); // stick released: throttle stays
|
||||
_router.Tick(1.0);
|
||||
Assert.Equal(RioAxisRange.ThrottleFull / 2, _device.GetAxis(RioAxis.Throttle));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetAxisState_forgets_integrated_position()
|
||||
{
|
||||
UseProfile("key W axis Throttle rate 1");
|
||||
|
||||
_router.KeyDown("W");
|
||||
_router.Tick(0.5);
|
||||
_router.KeyUp("W");
|
||||
Assert.NotEqual(0, _device.GetAxis(RioAxis.Throttle));
|
||||
|
||||
// Emulate the center button / host reset combination.
|
||||
_device.SetAxis(RioAxis.Throttle, 0);
|
||||
_router.ResetAxisState();
|
||||
_router.Tick(0.016);
|
||||
Assert.Equal(0, _device.GetAxis(RioAxis.Throttle));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Idle_router_does_not_fight_other_axis_writers()
|
||||
{
|
||||
UseProfile("padaxis LeftStickX axis JoystickX");
|
||||
_router.Tick(0.016);
|
||||
|
||||
_device.SetAxis(RioAxis.JoystickX, 42); // e.g. a mouse drag on the panel
|
||||
_router.Tick(0.016); // router value unchanged → no clobber
|
||||
|
||||
Assert.Equal(42, _device.GetAxis(RioAxis.JoystickX));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Profile_swap_releases_held_inputs()
|
||||
{
|
||||
UseProfile("key A button 0x05");
|
||||
_router.KeyDown("A");
|
||||
|
||||
_router.Profile = BindingProfile.Empty;
|
||||
|
||||
Assert.Equal(new[] { PacketBuilder.ButtonPressed(0x05), PacketBuilder.ButtonReleased(0x05) }, _tx);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user