using System.Diagnostics; using System.IO.Ports; using VRio.Core.Device; using VRio.Core.Input; namespace VRio.App; /// /// 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. /// 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 }; /// A keyboard choice in the lamp-mirror picker (null id = all). 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 ------------------------------------------- /// /// Keys route to the panel unless the user is in a control that needs /// them (port list, firmware spinners, log box scrolling). /// 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); } } /// Refresh the lamp-mirror keyboard picker, keeping the pick if it survived. 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(); } }