Files
VRIO/src/VRio.App/MainForm.cs
T
CydandClaude Fable 5 697cf3129b Pipe endpoints join the connection pickers; no more COM11/COM12 auto-bind
Each device row now serves one endpoint at a time, picked from a list of
pipe:vrio / pipe:vplasma (first, default) plus the machine's COM ports —
nothing opens automatically at startup. The pipe services gained an
IsListening property and are started/stopped by the row's Open/Close
button instead of running for the whole app lifetime, which also retires
the dual-transport warning. Status line shows the active endpoint;
OFFLINE when none. Standalone vPLASMA is unchanged (hardwired COM12 +
permanent pipe). Also fixes the xUnit1031 warnings in the plasma pipe
tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 23:17:34 -05:00

755 lines
30 KiB
C#

using System.Diagnostics;
using System.IO.Ports;
using VPlasma.App;
using VPlasma.Core.Device;
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 — connection rows, device settings, and a live wire log. The
/// panel's encoder strip also hosts the built-in plasma glass (the vPLASMA
/// display emulator) on its own connection row. Each row's picker offers
/// the device's named-pipe endpoint (the DOSBox-X fork's namedpipe backend
/// — the com0com-free path) alongside the machine's COM ports; nothing
/// opens automatically — pick an endpoint and Open. Point RIOJoy at the
/// other end of a null-modem pair and every click here arrives exactly as
/// if the physical cockpit sent it; point the game at
/// <see cref="RioPipeEndpoint"/>/<see cref="PlasmaPipeEndpoint"/> (or the
/// COM2 passthrough at the plasma pair) and the glass lights up.
/// </summary>
internal sealed class MainForm : Form
{
/// <summary>
/// The RIO's pipe endpoint as listed in the picker. Matches the
/// DOSBox-X conf syntax: <c>serial1=namedpipe pipe:vrio</c>.
/// </summary>
private static readonly string RioPipeEndpoint = $"pipe:{VRioPipeService.DefaultPipeName}";
/// <summary>
/// The plasma's pipe endpoint as listed in the picker
/// (<c>serial2=namedpipe pipe:vplasma</c>).
/// </summary>
private static readonly string PlasmaPipeEndpoint = $"pipe:{VPlasmaPipeService.DefaultPipeName}";
private readonly VRioDevice _device = new();
private readonly VRioSerialService _service;
private readonly VRioPipeService _pipeService;
private readonly VPlasmaDevice _plasmaDevice = new();
private readonly VPlasmaSerialService _plasmaService;
private readonly VPlasmaPipeService _plasmaPipeService;
private readonly PanelCanvas _canvas = new();
private readonly PlasmaCanvas _plasmaCanvas = new(PanelCanvas.PlasmaDotPitch)
{
Location = PanelCanvas.PlasmaSlot,
};
private int _selfTestPage;
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");
// Port rows: one line per built-in device — a name label whose colour is
// the port status (green = open, gray = closed), the port picker, and an
// Open/Close button. The one Rescan button refreshes both pickers.
private readonly Label _rioLabel = new()
{
Text = "vRIO",
Location = new Point(12, 15),
AutoSize = true,
ForeColor = Color.Gray,
};
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 _plasmaLabel = new()
{
Text = "vPLASMA",
Location = new Point(12, 45),
AutoSize = true,
ForeColor = Color.Gray,
};
private readonly ComboBox _plasmaPortBox = new()
{
Location = new Point(80, 42),
Width = 128,
DropDownStyle = ComboBoxStyle.DropDownList,
};
private readonly Button _plasmaOpenClose = new() { Text = "Open", Location = new Point(272, 41), Width = 46 };
private readonly CheckBox _spring = new() { Text = "Stick springs back to center", Location = new Point(10, 24), AutoSize = true, Checked = true };
private readonly Button _centerAxes = new() { Text = "Center all axes", Location = new Point(10, 52), Width = 140, Height = 26 };
private readonly Button _lampsOff = new() { Text = "All lamps off", Location = new Point(156, 52), 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 = "Gamepad", Location = new Point(100, 22), AutoSize = true, Checked = true };
private readonly CheckBox _invertY = new() { Text = "Invert Y", Location = new Point(190, 22), AutoSize = 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 CheckBox _kbLights = new()
{
Text = "Mirror lamps on RGB keyboard (Dynamic Lighting)",
Location = new Point(10, 100),
AutoSize = true,
};
private readonly ComboBox _kbLightsTarget = new()
{
Location = new Point(10, 122),
Width = 286,
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, 144),
AutoSize = true,
};
private readonly ComboBox _kbInputTarget = new()
{
Location = new Point(10, 162),
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)
{
public override string ToString() => Name;
}
private readonly Label _counters = new()
{
Location = new Point(12, 372),
AutoSize = true,
Font = new Font("Consolas", 8f),
};
private readonly Label _help = new()
{
Location = new Point(12, 428),
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. " +
"Double-click the plasma glass to cycle its self-test pages; right-click it to reset.",
};
private readonly TextBox _logBox = new()
{
Location = new Point(12, 498),
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);
_plasmaService = new VPlasmaSerialService(_plasmaDevice);
// The named-pipe endpoints for DOSBox-X's namedpipe serial backend,
// offered in the connection pickers alongside the COM ports.
_pipeService = new VRioPipeService(_device);
_plasmaPipeService = new VPlasmaPipeService(_plasmaDevice);
_router = new InputRouter(_device);
_lampMirror = new KeyboardLampMirror(_device);
// Panel canvas, scrolled if the window is smaller than the grid. The
// plasma glass rides along as a child in the encoder strip's left slot.
var scroller = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Color.FromArgb(28, 28, 28) };
_canvas.Controls.Add(_plasmaCanvas);
scroller.Controls.Add(_canvas);
Controls.Add(scroller);
Controls.Add(BuildControlStrip());
// Canvas ↔ device wiring.
_canvas.LampProvider = _device.GetLamp;
_canvas.AxisProvider = _device.GetAxis;
_canvas.WireAxisProvider = _device.GetWireAxis;
_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 += _ => RunOnUi(UpdateRioEndpointUi);
_service.HostHandshake += high => RunOnUi(() =>
PrependLog(high ? "Host raised DTR (board-reset handshake)" : "Host dropped DTR"));
_pipeService.Logged += line => RunOnUi(() => PrependLog($"pipe: {line}"));
_pipeService.HostHandshake += high => RunOnUi(() =>
PrependLog(high ? "Pipe host raised DTR (board-reset handshake)" : "Pipe host dropped DTR"));
_plasmaPipeService.Logged += line => RunOnUi(() => PrependLog($"vPLASMA pipe: {line}"));
// Built-in plasma glass: a pure listener on its own port, same gestures
// as the standalone vPLASMA — double-click cycles the self-test pages,
// right-click resets the display to its power-on state.
_plasmaDevice.Updated += () => RunOnUi(() => _plasmaCanvas.UpdateFrom(_plasmaDevice));
_plasmaService.Logged += line => RunOnUi(() => PrependLog($"vPLASMA: {line}"));
_plasmaService.ConnectionChanged += _ => RunOnUi(UpdatePlasmaEndpointUi);
_plasmaCanvas.DoubleClick += (_, _) => RunPlasmaSelfTest();
_plasmaCanvas.MouseClick += (_, e) =>
{
if (e.Button == MouseButtons.Right)
_plasmaDevice.Reset();
};
_rescan.Click += (_, _) => RefreshPorts();
_openClose.Click += (_, _) => ToggleOpen();
_plasmaOpenClose.Click += (_, _) => TogglePlasmaOpen();
_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();
};
_clearLog.Click += (_, _) => _logBox.Clear();
_kbInput.CheckedChanged += (_, _) =>
{
if (!_kbInput.Checked)
_router.ReleaseAllKeys();
};
_invertY.CheckedChanged += (_, _) =>
{
_device.InvertJoystickY = _invertY.Checked;
_canvas.Invalidate(); // the wire readout flips even though the axes didn't move
};
_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;
// 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();
_uiTimer.Tick += (_, _) => UpdateStatus();
_uiTimer.Start();
_inputTimer.Tick += (_, _) => InputTick();
_inputTimer.Start();
FormClosed += (_, _) =>
{
_uiTimer.Dispose();
_inputTimer.Dispose();
_lampMirror.Dispose();
_rawKeyboard.Dispose();
_service.Dispose();
_plasmaService.Dispose();
_pipeService.Dispose();
_plasmaPipeService.Dispose();
};
RefreshPorts();
UpdateStatus();
PrependLog($"vRIO ready. Pick an endpoint per device — {RioPipeEndpoint} / {PlasmaPipeEndpoint} " +
"for DOSBox-X, or a COM port for RIOJoy — and Open.");
LoadBindings();
_plasmaCanvas.UpdateFrom(_plasmaDevice); // paint the power-on frame
}
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(_rioLabel);
panel.Controls.Add(_portBox);
panel.Controls.Add(_rescan);
panel.Controls.Add(_openClose);
panel.Controls.Add(_plasmaLabel);
panel.Controls.Add(_plasmaPortBox);
panel.Controls.Add(_plasmaOpenClose);
var device = new GroupBox { Text = "Device", Location = new Point(12, 68), Size = new Size(306, 88) };
device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff });
panel.Controls.Add(device);
var input = new GroupBox { Text = "Input", Location = new Point(12, 162), Size = new Size(306, 202) };
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, 480), 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;
}
// ---- Endpoint handling ---------------------------------------------------
// Each device row serves one endpoint at a time — the RIO is a
// single-host device, so its pipe server and COM port never run together.
private bool RioOpen => _service.IsOpen || _pipeService.IsListening;
private bool PlasmaOpen => _plasmaService.IsOpen || _plasmaPipeService.IsListening;
private void RefreshPorts()
{
string[] names = SerialPort.GetPortNames().Distinct()
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToArray();
// An open row's picker is disabled and shows the served endpoint;
// leave it alone so the display can't drift from reality.
if (!RioOpen)
RefreshPicker(_portBox, RioPipeEndpoint, names);
if (!PlasmaOpen)
RefreshPicker(_plasmaPortBox, PlasmaPipeEndpoint, names);
}
private static void RefreshPicker(ComboBox box, string pipeEndpoint, string[] portNames)
{
string? current = box.SelectedItem?.ToString();
box.Items.Clear();
box.Items.Add(pipeEndpoint); // always present — a pipe server needs no hardware
foreach (string name in portNames)
box.Items.Add(name);
int idx = current is null ? -1 : box.Items.IndexOf(current);
box.SelectedIndex = idx >= 0 ? idx : 0;
}
private void ToggleOpen()
{
if (RioOpen)
{
_service.Close(); // idempotent —
_pipeService.Stop(); // whichever was serving closes
UpdateRioEndpointUi();
}
else
{
OpenFromPicker(_portBox, OpenRioEndpoint);
}
}
private void TogglePlasmaOpen()
{
if (PlasmaOpen)
{
_plasmaService.Close();
_plasmaPipeService.Stop();
UpdatePlasmaEndpointUi();
}
else
{
OpenFromPicker(_plasmaPortBox, OpenPlasmaEndpoint);
}
}
private void OpenRioEndpoint(string endpoint)
{
if (endpoint == RioPipeEndpoint)
_pipeService.Start();
else
_service.Open(endpoint);
UpdateRioEndpointUi();
}
private void OpenPlasmaEndpoint(string endpoint)
{
if (endpoint == PlasmaPipeEndpoint)
_plasmaPipeService.Start();
else
_plasmaService.Open(endpoint);
UpdatePlasmaEndpointUi();
}
private void OpenFromPicker(ComboBox box, Action<string> open)
{
if (box.SelectedItem?.ToString() is not { } endpoint)
{
MessageBox.Show(this, "No endpoint selected. Pick the device's named pipe (for " +
"DOSBox-X's namedpipe backend) or a COM port (for RIOJoy through a com0com " +
"null-modem pair).", "vRIO", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
try
{
open(endpoint);
}
catch (Exception ex)
{
MessageBox.Show(this, $"Could not open {endpoint}:\n{ex.Message}", "vRIO",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
private void UpdateRioEndpointUi()
{
bool open = RioOpen;
_openClose.Text = open ? "Close" : "Open";
_portBox.Enabled = !open;
_rioLabel.ForeColor = open ? Color.ForestGreen : Color.Gray;
UpdateStatus();
}
private void UpdatePlasmaEndpointUi()
{
bool open = PlasmaOpen;
_plasmaOpenClose.Text = open ? "Close" : "Open";
_plasmaPortBox.Enabled = !open;
_plasmaLabel.ForeColor = open ? Color.ForestGreen : Color.Gray;
}
/// <summary>
/// Feed the next canned self-test page through the plasma's wire parser —
/// the same end-to-end exercise the standalone vPLASMA runs on double-click.
/// </summary>
private void RunPlasmaSelfTest()
{
byte[] bytes = PlasmaSelfTest.BuildPage(_selfTestPage);
_plasmaDevice.OnReceived(bytes, bytes.Length);
_selfTestPage = (_selfTestPage + 1) % PlasmaSelfTest.PageCount;
}
// ---- Keyboard / gamepad input -------------------------------------------
/// <summary>
/// Keys route to the panel unless the user is in a control that needs
/// them (port list, log box scrolling).
/// </summary>
private bool KeyboardRoutingActive =>
_kbInput.Checked &&
!_portBox.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
// 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))
{
// 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;
}
}
return base.ProcessCmdKey(ref msg, keyData);
}
protected override void OnKeyUp(KeyEventArgs e)
{
// 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)
{
// 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;
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}";
string? endpoint = _service.IsOpen ? _service.PortName
: _pipeService.IsListening ? RioPipeEndpoint
: null;
_canvas.StatusText = endpoint is not null
? $"vRIO {_device.VersionMajor}.{_device.VersionMinor} on {endpoint}\n" +
(wedged
? "** ANALOG WEDGED (v4.2 bug) — awaiting host reset **"
: $"{polls} analog polls" + (polls > 0 ? " (host is alive)" : " (no host traffic yet)"))
: "OFFLINE\nOpen an endpoint (named pipe or 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();
}
}