Files
VRIO/src/VRio.App/MainForm.cs
T
CydandClaude Fable 5 97e78b0eea App icon: vwe.ico as the exe and window icon
ApplicationIcon embeds it in the exe (Explorer, taskbar); MainForm pulls
the same embedded icon for the title bar via ExtractAssociatedIcon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:22:30 -05:00

511 lines
21 KiB
C#

using System.Diagnostics;
using System.IO.Ports;
using VRio.Core.Device;
using VRio.Core.Input;
namespace VRio.App;
/// <summary>
/// vRIO main window: the interactive cockpit panel on the left (the same
/// functional map RIOJoy's profile editor shows) and a control strip on the
/// right — COM port, device settings, and a live wire log. Open the COM port,
/// point RIOJoy at the other end of the null-modem pair, and every click here
/// arrives at RIOJoy exactly as if the physical cockpit sent it.
/// </summary>
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 KeyboardLampMirror _lampMirror;
private readonly string _bindingsPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "vRIO", "bindings.txt");
private readonly ComboBox _portBox = new()
{
Location = new Point(80, 12),
Width = 128,
DropDownStyle = ComboBoxStyle.DropDownList,
};
private readonly Button _rescan = new() { Text = "Rescan", Location = new Point(214, 11), Width = 52 };
private readonly Button _openClose = new() { Text = "Open", Location = new Point(272, 11), Width = 46 };
private readonly Label _linkStatus = new()
{
Text = "Port closed.",
Location = new Point(12, 42),
AutoSize = true,
ForeColor = Color.Gray,
};
private readonly NumericUpDown _verMajor = new() { Location = new Point(80, 24), Width = 44, Minimum = 0, Maximum = 127, Value = 4 };
private readonly NumericUpDown _verMinor = new() { Location = new Point(140, 24), Width = 44, Minimum = 0, Maximum = 127, Value = 2 };
private readonly CheckBox _spring = new() { Text = "Stick springs back to center", Location = new Point(10, 56), AutoSize = true, Checked = true };
private readonly Button _centerAxes = new() { Text = "Center all axes", Location = new Point(10, 82), Width = 140, Height = 26 };
private readonly Button _lampsOff = new() { Text = "All lamps off", Location = new Point(156, 82), Width = 140, Height = 26 };
private readonly Button _testEnter = new() { Text = "Enter test mode", Location = new Point(10, 114), Width = 140, Height = 26 };
private readonly Button _testExit = new() { Text = "Exit test mode", Location = new Point(156, 114), 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 CheckBox _invertY = new() { Text = "Invert joystick Y", Location = new Point(10, 46), AutoSize = true, Checked = true };
private readonly Label _padStatus = new()
{
Text = "No controller detected.",
Location = new Point(10, 70),
AutoSize = true,
ForeColor = Color.Gray,
};
private readonly Button _reloadBindings = new() { Text = "Reload bindings", Location = new Point(10, 92), Width = 140, Height = 26 };
private readonly Button _editBindings = new() { Text = "Edit bindings…", Location = new Point(156, 92), Width = 140, Height = 26 };
private readonly CheckBox _kbLights = new()
{
Text = "Mirror lamps on RGB keyboard (Dynamic Lighting)",
Location = new Point(10, 124),
AutoSize = true,
};
private readonly ComboBox _kbLightsTarget = new()
{
Location = new Point(10, 146),
Width = 286,
DropDownStyle = ComboBoxStyle.DropDownList,
Enabled = false, // populated while the mirror is on
};
/// <summary>A keyboard choice in the lamp-mirror picker (null id = all).</summary>
private sealed record KbChoice(string? Id, string Name)
{
public override string ToString() => Name;
}
private readonly Label _counters = new()
{
Location = new Point(12, 414),
AutoSize = true,
Font = new Font("Consolas", 8f),
};
private readonly Label _help = new()
{
Location = new Point(12, 470),
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. " +
"Keyboard and Xbox-pad input follow the bindings file (Edit bindings…).",
};
private readonly TextBox _logBox = new()
{
Location = new Point(12, 554),
Multiline = true,
ReadOnly = true,
ScrollBars = ScrollBars.Both, // long wire lines don't wrap — scroll to read
BackColor = Color.FromArgb(24, 24, 24),
ForeColor = Color.Gainsboro,
Font = new Font("Consolas", 8f),
Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left,
WordWrap = false,
};
private readonly Button _clearLog = new()
{
Text = "Clear log",
Width = 80,
Anchor = AnchorStyles.Bottom | AnchorStyles.Left,
};
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()
{
// ProductVersion carries the git stamp (see StampGitVersion in the csproj).
Text = $"vRIO v{Application.ProductVersion} — Virtual RIO cockpit device";
// Title-bar/taskbar icon from the exe's embedded ApplicationIcon (vwe.ico).
Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath);
// Fit the window to its content: the cockpit canvas plus the 330px
// control strip, with just enough height for the strip's log area.
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);
_lampMirror = new KeyboardLampMirror(_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) };
scroller.Controls.Add(_canvas);
Controls.Add(scroller);
Controls.Add(BuildControlStrip());
// Canvas ↔ device wiring.
_canvas.LampProvider = _device.GetLamp;
_canvas.AxisProvider = _device.GetAxis;
_canvas.AddressPressed += _device.PressAddress;
_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));
_lampMirror.Logged += line => RunOnUi(() => PrependLog(line));
_service.ConnectionChanged += open => RunOnUi(() => OnConnectionChanged(open));
_service.HostHandshake += high => RunOnUi(() =>
PrependLog(high ? "Host raised DTR (board-reset handshake)" : "Host dropped DTR"));
_rescan.Click += (_, _) => RefreshPorts();
_openClose.Click += (_, _) => ToggleOpen();
_verMajor.ValueChanged += (_, _) => _device.VersionMajor = (byte)_verMajor.Value;
_verMinor.ValueChanged += (_, _) => _device.VersionMinor = (byte)_verMinor.Value;
_spring.CheckedChanged += (_, _) => _canvas.StickSpringsBack = _spring.Checked;
_centerAxes.Click += (_, _) =>
{
foreach (RioAxis axis in (RioAxis[])Enum.GetValues(typeof(RioAxis)))
_device.SetAxis(axis, 0);
_router.ResetAxisState();
};
_lampsOff.Click += (_, _) =>
{
_device.ClearLamps();
_canvas.Invalidate();
};
_testEnter.Click += (_, _) => _device.SendTestMode(1);
_testExit.Click += (_, _) => _device.SendTestMode(0);
_clearLog.Click += (_, _) => _logBox.Clear();
_kbInput.CheckedChanged += (_, _) =>
{
if (!_kbInput.Checked)
_router.ReleaseAllKeys();
};
_invertY.CheckedChanged += (_, _) => _device.InvertJoystickY = _invertY.Checked;
_device.InvertJoystickY = _invertY.Checked; // the field initializer raises no event
_kbLights.CheckedChanged += (_, _) =>
{
_lampMirror.Enabled = _kbLights.Checked;
_kbLightsTarget.Enabled = _kbLights.Checked;
};
_kbLightsTarget.SelectedIndexChanged += (_, _) =>
_lampMirror.SetTarget((_kbLightsTarget.SelectedItem as KbChoice)?.Id);
_lampMirror.KeyboardsChanged += list => RunOnUi(() => RebuildKeyboardPicker(list));
_kbLightsTarget.Items.Add(new KbChoice(null, "All keyboards"));
_kbLightsTarget.SelectedIndex = 0;
_reloadBindings.Click += (_, _) => LoadBindings();
_editBindings.Click += (_, _) => OpenBindingsFile();
_uiTimer.Tick += (_, _) => UpdateStatus();
_uiTimer.Start();
_inputTimer.Tick += (_, _) => InputTick();
_inputTimer.Start();
FormClosed += (_, _) =>
{
_uiTimer.Dispose();
_inputTimer.Dispose();
_lampMirror.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()
{
var panel = new Panel
{
Dock = DockStyle.Right,
Width = 330,
// Height must be set before the anchored children are added:
// Anchor offsets are captured against the parent's current size,
// and the 100px default would pin the log box's bottom far below
// the panel, making it grow past the window instead of scrolling.
Height = ClientSize.Height,
Padding = new Padding(8),
BorderStyle = BorderStyle.FixedSingle,
};
panel.Controls.Add(new Label { Text = "COM port:", Location = new Point(12, 15), AutoSize = true });
panel.Controls.Add(_portBox);
panel.Controls.Add(_rescan);
panel.Controls.Add(_openClose);
panel.Controls.Add(_linkStatus);
var device = new GroupBox { Text = "Device", Location = new Point(12, 68), Size = new Size(306, 150) };
device.Controls.Add(new Label { Text = "Firmware:", Location = new Point(10, 27), AutoSize = true });
device.Controls.Add(_verMajor);
device.Controls.Add(new Label { Text = ".", Location = new Point(127, 27), AutoSize = true });
device.Controls.Add(_verMinor);
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 });
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 });
_logBox.Size = new Size(306, ClientSize.Height - _logBox.Top - 44);
panel.Controls.Add(_logBox);
_clearLog.Location = new Point(12, ClientSize.Height - 36);
panel.Controls.Add(_clearLog);
return panel;
}
// ---- Port handling -----------------------------------------------------
private void RefreshPorts()
{
string? current = _portBox.SelectedItem?.ToString();
_portBox.Items.Clear();
foreach (string name in SerialPort.GetPortNames().Distinct().OrderBy(n => n, StringComparer.OrdinalIgnoreCase))
_portBox.Items.Add(name);
if (_portBox.Items.Count == 0)
return;
int idx = current is null ? -1 : _portBox.Items.IndexOf(current);
_portBox.SelectedIndex = idx >= 0 ? idx : 0;
}
private void ToggleOpen()
{
if (_service.IsOpen)
{
_service.Close();
return;
}
if (_portBox.SelectedItem?.ToString() is not { } port)
{
MessageBox.Show(this, "No COM port selected. On a single PC, install a com0com virtual " +
"null-modem pair and open one end here.", "vRIO", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
try
{
_service.Open(port);
}
catch (Exception ex)
{
MessageBox.Show(this, $"Could not open {port}:\n{ex.Message}", "vRIO",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
private void OnConnectionChanged(bool open)
{
_openClose.Text = open ? "Close" : "Open";
_portBox.Enabled = _rescan.Enabled = !open;
_linkStatus.Text = open ? $"Serving {_service.PortName} @ 9600 8N1." : "Port closed.";
_linkStatus.ForeColor = open ? Color.ForestGreen : Color.Gray;
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;
_lampMirror.SetProfile(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);
}
}
/// <summary>Refresh the lamp-mirror keyboard picker, keeping the pick if it survived.</summary>
private void RebuildKeyboardPicker(IReadOnlyList<(string Id, string Name)> keyboards)
{
string? selected = (_kbLightsTarget.SelectedItem as KbChoice)?.Id;
_kbLightsTarget.BeginUpdate();
_kbLightsTarget.Items.Clear();
_kbLightsTarget.Items.Add(new KbChoice(null, "All keyboards"));
int select = 0;
foreach ((string id, string name) in keyboards)
{
int idx = _kbLightsTarget.Items.Add(new KbChoice(id, name));
if (id == selected)
select = idx;
}
// Falls back to "All keyboards" if the picked device vanished (the
// selection-changed handler re-targets the mirror accordingly).
_kbLightsTarget.SelectedIndex = select;
_kbLightsTarget.EndUpdate();
}
// ---- Status / log ------------------------------------------------------
private void UpdateStatus()
{
long polls = _device.AnalogRequests;
long dropped = _device.AnalogDropped;
long bad = _device.BadChecksums;
bool wedged = _device.AnalogWedged;
_counters.Text = $"Analog polls served: {polls}\n" +
$"Analog polls dropped: {dropped}\n" +
$"Bad checksums: {bad}";
_canvas.StatusText = _service.IsOpen
? $"vRIO {_device.VersionMajor}.{_device.VersionMinor} on {_service.PortName}\n" +
(wedged
? "** ANALOG WEDGED (v4.2 bug) — awaiting host reset **"
: $"{polls} analog polls" + (polls > 0 ? " (host is alive)" : " (no host traffic yet)"))
: "PORT CLOSED\nOpen a COM port to go live.";
}
private void PrependLog(string line)
{
// Newest entry on top; older lines flow downward and eventually fall
// off the bounded end, so the visible top is always current.
string text = $"[{_clock.Elapsed.TotalSeconds,8:F2}s] {line}{Environment.NewLine}" + _logBox.Text;
// Bound the log so an all-day session doesn't grow without limit;
// cut at a line boundary so the tail isn't half an entry.
if (text.Length > 120_000)
{
int cut = text.LastIndexOf(Environment.NewLine, 60_000, StringComparison.Ordinal);
text = text.Substring(0, cut < 0 ? 60_000 : cut + Environment.NewLine.Length);
}
_logBox.Text = text;
_logBox.SelectionStart = 0;
_logBox.ScrollToCaret();
}
private void RunOnUi(Action action)
{
if (IsDisposed)
return;
if (InvokeRequired)
{
try { BeginInvoke(action); }
catch (ObjectDisposedException) { }
catch (InvalidOperationException) { } // handle not created / closing
return;
}
action();
}
}