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:
+161
-5
@@ -1,6 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO.Ports;
|
||||
using VRio.Core.Device;
|
||||
using VRio.Core.Input;
|
||||
|
||||
namespace VRio.App;
|
||||
|
||||
@@ -16,6 +17,10 @@ internal sealed class MainForm : Form
|
||||
private readonly VRioDevice _device = new();
|
||||
private readonly VRioSerialService _service;
|
||||
private readonly PanelCanvas _canvas = new();
|
||||
private readonly InputRouter _router;
|
||||
private readonly XInputGamepad _gamepad = new();
|
||||
private readonly string _bindingsPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "vRIO", "bindings.txt");
|
||||
|
||||
private readonly ComboBox _portBox = new()
|
||||
{
|
||||
@@ -48,26 +53,39 @@ internal sealed class MainForm : Form
|
||||
};
|
||||
private readonly Button _wedgeNow = new() { Text = "Wedge analog now", Location = new Point(10, 172), Width = 140, Height = 26 };
|
||||
|
||||
private readonly CheckBox _kbInput = new() { Text = "Keyboard", Location = new Point(10, 22), AutoSize = true, Checked = true };
|
||||
private readonly CheckBox _padInput = new() { Text = "Xbox gamepad", Location = new Point(120, 22), AutoSize = true, Checked = true };
|
||||
private readonly Label _padStatus = new()
|
||||
{
|
||||
Text = "No controller detected.",
|
||||
Location = new Point(10, 46),
|
||||
AutoSize = true,
|
||||
ForeColor = Color.Gray,
|
||||
};
|
||||
private readonly Button _reloadBindings = new() { Text = "Reload bindings", Location = new Point(10, 68), Width = 140, Height = 26 };
|
||||
private readonly Button _editBindings = new() { Text = "Edit bindings…", Location = new Point(156, 68), Width = 140, Height = 26 };
|
||||
|
||||
private readonly Label _counters = new()
|
||||
{
|
||||
Location = new Point(12, 290),
|
||||
Location = new Point(12, 396),
|
||||
AutoSize = true,
|
||||
Font = new Font("Consolas", 8f),
|
||||
};
|
||||
|
||||
private readonly Label _help = new()
|
||||
{
|
||||
Location = new Point(12, 348),
|
||||
Location = new Point(12, 452),
|
||||
MaximumSize = new Size(306, 0),
|
||||
AutoSize = true,
|
||||
ForeColor = Color.Gray,
|
||||
Text = "Left-click a cell: momentary press. Right-click: latch it down. " +
|
||||
"Drag the X/Y box and the Z / L / R gauges to move the axes.",
|
||||
"Drag the X/Y box and the Z / L / R gauges to move the axes. " +
|
||||
"Keyboard and Xbox-pad input follow the bindings file (Edit bindings…).",
|
||||
};
|
||||
|
||||
private readonly TextBox _logBox = new()
|
||||
{
|
||||
Location = new Point(12, 428),
|
||||
Location = new Point(12, 536),
|
||||
Multiline = true,
|
||||
ReadOnly = true,
|
||||
ScrollBars = ScrollBars.Vertical,
|
||||
@@ -85,7 +103,9 @@ internal sealed class MainForm : Form
|
||||
};
|
||||
|
||||
private readonly System.Windows.Forms.Timer _uiTimer = new() { Interval = 500 };
|
||||
private readonly System.Windows.Forms.Timer _inputTimer = new() { Interval = 16 };
|
||||
private readonly Stopwatch _clock = Stopwatch.StartNew();
|
||||
private double _lastInputTick;
|
||||
|
||||
public MainForm()
|
||||
{
|
||||
@@ -95,8 +115,10 @@ internal sealed class MainForm : Form
|
||||
ClientSize = new Size(_canvas.Width + 332, Math.Max(_canvas.Height, 640));
|
||||
MinimumSize = new Size(1000, 620);
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
KeyPreview = true; // form-level key routing for the input bindings
|
||||
|
||||
_service = new VRioSerialService(_device);
|
||||
_router = new InputRouter(_device);
|
||||
|
||||
// Panel canvas, scrolled if the window is smaller than the grid.
|
||||
var scroller = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Color.FromArgb(28, 28, 28) };
|
||||
@@ -112,9 +134,14 @@ internal sealed class MainForm : Form
|
||||
_canvas.AddressReleased += _device.ReleaseAddress;
|
||||
_canvas.AxisMoved += (axis, value) => _device.SetAxis(axis, value);
|
||||
|
||||
// Router-driven presses light up like clicks (router runs on the UI thread).
|
||||
_router.AddressHeldChanged += _canvas.SetExternalHeld;
|
||||
|
||||
// Device / service events arrive on worker threads; marshal to the UI.
|
||||
_device.LampChanged += (_, _) => RunOnUi(_canvas.Invalidate);
|
||||
_device.AxesChanged += () => RunOnUi(_canvas.Invalidate);
|
||||
// A host reset re-zeroes the axes behind the router's back.
|
||||
_device.ResetReceived += _ => RunOnUi(_router.ResetAxisState);
|
||||
_device.Logged += line => RunOnUi(() => PrependLog(line));
|
||||
_service.Logged += line => RunOnUi(() => PrependLog(line));
|
||||
_service.ConnectionChanged += open => RunOnUi(() => OnConnectionChanged(open));
|
||||
@@ -130,6 +157,7 @@ internal sealed class MainForm : Form
|
||||
{
|
||||
foreach (RioAxis axis in (RioAxis[])Enum.GetValues(typeof(RioAxis)))
|
||||
_device.SetAxis(axis, 0);
|
||||
_router.ResetAxisState();
|
||||
};
|
||||
_lampsOff.Click += (_, _) =>
|
||||
{
|
||||
@@ -146,18 +174,31 @@ internal sealed class MainForm : Form
|
||||
};
|
||||
_clearLog.Click += (_, _) => _logBox.Clear();
|
||||
|
||||
_kbInput.CheckedChanged += (_, _) =>
|
||||
{
|
||||
if (!_kbInput.Checked)
|
||||
_router.ReleaseAllKeys();
|
||||
};
|
||||
_reloadBindings.Click += (_, _) => LoadBindings();
|
||||
_editBindings.Click += (_, _) => OpenBindingsFile();
|
||||
|
||||
_uiTimer.Tick += (_, _) => UpdateStatus();
|
||||
_uiTimer.Start();
|
||||
|
||||
_inputTimer.Tick += (_, _) => InputTick();
|
||||
_inputTimer.Start();
|
||||
|
||||
FormClosed += (_, _) =>
|
||||
{
|
||||
_uiTimer.Dispose();
|
||||
_inputTimer.Dispose();
|
||||
_service.Dispose();
|
||||
};
|
||||
|
||||
RefreshPorts();
|
||||
UpdateStatus();
|
||||
PrependLog("vRIO ready. Open a COM port, then point RIOJoy at the other end of the pair.");
|
||||
LoadBindings();
|
||||
}
|
||||
|
||||
private Panel BuildControlStrip()
|
||||
@@ -189,9 +230,13 @@ internal sealed class MainForm : Form
|
||||
device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff, _testEnter, _testExit, _wedgeBug, _wedgeNow });
|
||||
panel.Controls.Add(device);
|
||||
|
||||
var input = new GroupBox { Text = "Input", Location = new Point(12, 284), Size = new Size(306, 104) };
|
||||
input.Controls.AddRange(new Control[] { _kbInput, _padInput, _padStatus, _reloadBindings, _editBindings });
|
||||
panel.Controls.Add(input);
|
||||
|
||||
panel.Controls.Add(_counters);
|
||||
panel.Controls.Add(_help);
|
||||
panel.Controls.Add(new Label { Text = "Wire log:", Location = new Point(12, 410), AutoSize = true });
|
||||
panel.Controls.Add(new Label { Text = "Wire log:", Location = new Point(12, 518), AutoSize = true });
|
||||
|
||||
_logBox.Size = new Size(306, ClientSize.Height - _logBox.Top - 44);
|
||||
panel.Controls.Add(_logBox);
|
||||
@@ -252,6 +297,117 @@ internal sealed class MainForm : Form
|
||||
UpdateStatus();
|
||||
}
|
||||
|
||||
// ---- Keyboard / gamepad input -------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Keys route to the panel unless the user is in a control that needs
|
||||
/// them (port list, firmware spinners, log box scrolling).
|
||||
/// </summary>
|
||||
private bool KeyboardRoutingActive =>
|
||||
_kbInput.Checked &&
|
||||
!_portBox.ContainsFocus && !_verMajor.ContainsFocus && !_verMinor.ContainsFocus && !_logBox.ContainsFocus;
|
||||
|
||||
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
|
||||
{
|
||||
// Intercept WM_KEYDOWN before dialog-key processing, or arrows/space
|
||||
// would move focus and click buttons instead of reaching the panel.
|
||||
const int WM_KEYDOWN = 0x0100, WM_SYSKEYDOWN = 0x0104;
|
||||
if ((msg.Msg is WM_KEYDOWN or WM_SYSKEYDOWN)
|
||||
&& (keyData & (Keys.Control | Keys.Alt)) == 0
|
||||
&& KeyboardRoutingActive)
|
||||
{
|
||||
string name = (keyData & Keys.KeyCode).ToString();
|
||||
if (_router.HasKeyBinding(name))
|
||||
{
|
||||
_router.KeyDown(name);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return base.ProcessCmdKey(ref msg, keyData);
|
||||
}
|
||||
|
||||
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());
|
||||
base.OnKeyUp(e);
|
||||
}
|
||||
|
||||
protected override void OnDeactivate(EventArgs e)
|
||||
{
|
||||
_router.ReleaseAllKeys(); // key-up events are lost once unfocused
|
||||
base.OnDeactivate(e);
|
||||
}
|
||||
|
||||
private void InputTick()
|
||||
{
|
||||
double now = _clock.Elapsed.TotalSeconds;
|
||||
double dt = Math.Min(0.25, now - _lastInputTick); // cap catch-up after a stall
|
||||
_lastInputTick = now;
|
||||
|
||||
if (_padInput.Checked && _gamepad.TryRead(out var pad))
|
||||
_router.SetPadState(pad);
|
||||
else
|
||||
_router.SetPadState(default); // releases whatever the pad held
|
||||
|
||||
_router.Tick(dt);
|
||||
|
||||
string status = _padInput.Checked
|
||||
? _gamepad.Connected ? $"Controller #{_gamepad.UserIndex + 1} connected." : "No controller detected."
|
||||
: "Gamepad input off.";
|
||||
if (_padStatus.Text != status)
|
||||
{
|
||||
_padStatus.Text = status;
|
||||
_padStatus.ForeColor = _gamepad.Connected && _padInput.Checked ? Color.ForestGreen : Color.Gray;
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadBindings()
|
||||
{
|
||||
try
|
||||
{
|
||||
string text;
|
||||
if (File.Exists(_bindingsPath))
|
||||
{
|
||||
text = File.ReadAllText(_bindingsPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
// First run: materialize the commented default file so
|
||||
// "Edit bindings…" has something self-documenting to open.
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_bindingsPath)!);
|
||||
File.WriteAllText(_bindingsPath, BindingProfileFormat.DefaultText);
|
||||
text = BindingProfileFormat.DefaultText;
|
||||
}
|
||||
|
||||
var profile = BindingProfileFormat.Parse(text, out var errors);
|
||||
_router.Profile = profile;
|
||||
foreach (string error in errors)
|
||||
PrependLog($"Bindings: {error}");
|
||||
PrependLog($"Bindings loaded: {profile.Count} ({_bindingsPath})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
PrependLog($"Bindings load failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenBindingsFile()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_bindingsPath))
|
||||
LoadBindings(); // writes the default file
|
||||
Process.Start(new ProcessStartInfo(_bindingsPath) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this, $"Could not open {_bindingsPath}:\n{ex.Message}", "vRIO",
|
||||
MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Status / log ------------------------------------------------------
|
||||
|
||||
private void UpdateStatus()
|
||||
|
||||
Reference in New Issue
Block a user