From d13d434e88fd542773b92d4befa4640a6952ce48 Mon Sep 17 00:00:00 2001 From: Cyd Date: Thu, 30 Jul 2026 11:39:52 -0500 Subject: [PATCH] editor: RIO port/pipe picker on the profile edit panel The right-hand panel gains a "RIO port" row under Triggers: an editable combo offering the app default (shown with its value), the machine's COM ports, and pipe:vrio, with free text for anything else. Save stores the endpoint in RioProfile.RioComPort (blank or the default entry = null = follow DefaultRioComPort). Because the editor session holds the endpoint it opened with, a saved port change now re-arms the session live: the tray host unhooks the old runtime's editor wiring, re-activates on the new endpoint, re-hooks, and restores the output-gate state - so the live RIO commands and button echo follow the new port/pipe without closing the editor. Verified with the offline DrawToBitmap harness (layout, both default and pipe:vrio states) plus a scripted save round-trip (default->null, pipe:vrio->stored, blank->null, reopen shows stored COM7). Co-Authored-By: Claude Fable 5 --- src/RioJoy.Tray/Editor/ProfileEditorForm.cs | 83 +++++++++++++++------ src/RioJoy.Tray/TrayApplicationContext.cs | 62 +++++++++++---- 2 files changed, 108 insertions(+), 37 deletions(-) diff --git a/src/RioJoy.Tray/Editor/ProfileEditorForm.cs b/src/RioJoy.Tray/Editor/ProfileEditorForm.cs index 4d2c379..e353ea1 100644 --- a/src/RioJoy.Tray/Editor/ProfileEditorForm.cs +++ b/src/RioJoy.Tray/Editor/ProfileEditorForm.cs @@ -5,6 +5,7 @@ using RioJoy.Core.Output; using RioJoy.Core.Mapping; using RioJoy.Core.Profiles; using RioJoy.Core.Protocol; +using RioJoy.Core.Serial; namespace RioJoy.Tray.Editor; @@ -30,26 +31,32 @@ public sealed class ProfileEditorForm : Form private readonly TextBox _nameBox = new() { Location = new Point(66, 12), Width = 234 }; private readonly TextBox _matchBox = new() { Location = new Point(66, 40), Width = 234 }; - private readonly Label _info = new() { AutoSize = true, Location = new Point(12, 68), MaximumSize = new Size(310, 0) }; - private readonly TextBox _labelBox = new() { Location = new Point(70, 100), Width = 230 }; - private readonly ComboBox _kindBox = new() { Location = new Point(70, 134), Width = 150, DropDownStyle = ComboBoxStyle.DropDownList }; - private readonly Label _valueLabel = new() { Text = "Key:", Location = new Point(12, 171), AutoSize = true }; - private readonly ComboBox _valueCombo = new() { Location = new Point(70, 168), Width = 230, DropDownStyle = ComboBoxStyle.DropDownList }; - private readonly CheckBox _shift = new() { Text = "Shift", Location = new Point(70, 200), AutoSize = true }; - private readonly CheckBox _ctrl = new() { Text = "Ctrl", Location = new Point(140, 200), AutoSize = true }; - private readonly CheckBox _alt = new() { Text = "Alt", Location = new Point(200, 200), AutoSize = true }; - private readonly CheckBox _ext = new() { Text = "Ext", Location = new Point(250, 200), AutoSize = true }; - private readonly CheckBox _lit = new() { Text = "Lit", Location = new Point(70, 228), AutoSize = true }; - private readonly Button _apply = new() { Text = "Apply to cell", Location = new Point(70, 262), Width = 110 }; - private readonly Button _unassign = new() { Text = "Unassign", Location = new Point(190, 262), Width = 110 }; - private readonly Button _save = new() { Text = "Save profile", Location = new Point(70, 296), Width = 110 }; - private readonly Button _close = new() { Text = "Close", Location = new Point(190, 296), Width = 80 }; - private readonly CheckBox _outputToggle = new() { Text = "Send button output to the PC", Location = new Point(12, 328), AutoSize = true }; + + // RIO endpoint: editable so any COM name or pipe:name goes; the drop-down + // offers the app default, the machine's COM ports, and the vRIO pipe. + private readonly ComboBox _portBox = new() { Location = new Point(66, 68), Width = 234, DropDownStyle = ComboBoxStyle.DropDown }; + private readonly string _defaultPortItem; + + private readonly Label _info = new() { AutoSize = true, Location = new Point(12, 96), MaximumSize = new Size(310, 0) }; + private readonly TextBox _labelBox = new() { Location = new Point(70, 128), Width = 230 }; + private readonly ComboBox _kindBox = new() { Location = new Point(70, 162), Width = 150, DropDownStyle = ComboBoxStyle.DropDownList }; + private readonly Label _valueLabel = new() { Text = "Key:", Location = new Point(12, 199), AutoSize = true }; + private readonly ComboBox _valueCombo = new() { Location = new Point(70, 196), Width = 230, DropDownStyle = ComboBoxStyle.DropDownList }; + private readonly CheckBox _shift = new() { Text = "Shift", Location = new Point(70, 228), AutoSize = true }; + private readonly CheckBox _ctrl = new() { Text = "Ctrl", Location = new Point(140, 228), AutoSize = true }; + private readonly CheckBox _alt = new() { Text = "Alt", Location = new Point(200, 228), AutoSize = true }; + private readonly CheckBox _ext = new() { Text = "Ext", Location = new Point(250, 228), AutoSize = true }; + private readonly CheckBox _lit = new() { Text = "Lit", Location = new Point(70, 256), AutoSize = true }; + private readonly Button _apply = new() { Text = "Apply to cell", Location = new Point(70, 290), Width = 110 }; + private readonly Button _unassign = new() { Text = "Unassign", Location = new Point(190, 290), Width = 110 }; + private readonly Button _save = new() { Text = "Save profile", Location = new Point(70, 324), Width = 110 }; + private readonly Button _close = new() { Text = "Close", Location = new Point(190, 324), Width = 80 }; + private readonly CheckBox _outputToggle = new() { Text = "Send button output to the PC", Location = new Point(12, 356), AutoSize = true }; private readonly TextBox _statusBox = new() { - Location = new Point(12, 646), - Size = new Size(306, 145), + Location = new Point(12, 674), + Size = new Size(306, 140), Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Vertical, @@ -104,13 +111,38 @@ public sealed class ProfileEditorForm : Form /// Raised when the "send output to the PC" toggle changes (true = send). public event Action? OutputsEnabledChanged; - public ProfileEditorForm(RioProfile profile) + /// The profile to edit (mutated in place; Save persists). + /// + /// The app-wide RIO endpoint (), shown + /// on the port picker's "(app default)" entry. Null just hides the value. + /// + public ProfileEditorForm(RioProfile profile, string? defaultEndpoint = null) { _profile = profile ?? throw new ArgumentNullException(nameof(profile)); Text = $"RIOJoy — Edit profile: {profile.Name}"; _nameBox.Text = profile.Name; _matchBox.Text = string.Join(", ", profile.MatchExecutables); + + // Endpoint suggestions: app default, the machine's COM ports, the vRIO + // pipe. Free text stays allowed — any COM name or pipe:name works. + _defaultPortItem = defaultEndpoint is null ? "(app default)" : $"(app default: {defaultEndpoint})"; + _portBox.Items.Add(_defaultPortItem); + try + { + foreach (string port in System.IO.Ports.SerialPort.GetPortNames() + .Distinct().OrderBy(p => p, StringComparer.OrdinalIgnoreCase)) + _portBox.Items.Add(port); + } + catch (Exception) + { + // Enumerating ports is best-effort (registry read) — typing still works. + } + _portBox.Items.Add(RioTransportFactory.PipeScheme + "vrio"); + if (string.IsNullOrWhiteSpace(profile.RioComPort)) + _portBox.SelectedIndex = 0; + else + _portBox.Text = profile.RioComPort; ClientSize = new Size(1320, 820); StartPosition = FormStartPosition.CenterScreen; MinimumSize = new Size(900, 500); @@ -162,16 +194,18 @@ public sealed class ProfileEditorForm : Form panel.Controls.Add(_nameBox); panel.Controls.Add(new Label { Text = "Triggers:", Location = new Point(12, 43), AutoSize = true }); panel.Controls.Add(_matchBox); + panel.Controls.Add(new Label { Text = "RIO port:", Location = new Point(12, 71), AutoSize = true }); + panel.Controls.Add(_portBox); panel.Controls.Add(_info); - panel.Controls.Add(new Label { Text = "Label:", Location = new Point(12, 103), AutoSize = true }); + panel.Controls.Add(new Label { Text = "Label:", Location = new Point(12, 131), AutoSize = true }); panel.Controls.Add(_labelBox); - panel.Controls.Add(new Label { Text = "Action:", Location = new Point(12, 137), AutoSize = true }); + panel.Controls.Add(new Label { Text = "Action:", Location = new Point(12, 165), AutoSize = true }); panel.Controls.Add(_kindBox); panel.Controls.Add(_valueLabel); panel.Controls.Add(_valueCombo); panel.Controls.AddRange(new Control[] { _shift, _ctrl, _alt, _ext, _lit, _apply, _unassign, _save, _close, _outputToggle }); panel.Controls.Add(BuildCommandGroup()); - panel.Controls.Add(new Label { Text = "RIO reply:", Location = new Point(12, 628), AutoSize = true }); + panel.Controls.Add(new Label { Text = "RIO reply:", Location = new Point(12, 656), AutoSize = true }); panel.Controls.Add(_statusBox); return panel; @@ -180,7 +214,7 @@ public sealed class ProfileEditorForm : Form // A button per RIO device command, fired against the live RIO via CommandRequested. private GroupBox BuildCommandGroup() { - var group = new GroupBox { Text = "RIO commands (live)", Location = new Point(12, 358), Size = new Size(306, 262) }; + var group = new GroupBox { Text = "RIO commands (live)", Location = new Point(12, 386), Size = new Size(306, 262) }; int y = 24; foreach ((string label, RioCommandCode code) in RioCommands) @@ -359,6 +393,11 @@ public sealed class ProfileEditorForm : Form .Where(s => s.Length > 0) .ToList(); + // RIO endpoint: a COM name or pipe:name (e.g. pipe:vrio); blank or the + // "(app default)" entry stores null = follow DefaultRioComPort. + string port = _portBox.Text.Trim(); + _profile.RioComPort = port.Length == 0 || port == _defaultPortItem ? null : port; + ApplyToCell(); try { diff --git a/src/RioJoy.Tray/TrayApplicationContext.cs b/src/RioJoy.Tray/TrayApplicationContext.cs index cd04b55..2478062 100644 --- a/src/RioJoy.Tray/TrayApplicationContext.cs +++ b/src/RioJoy.Tray/TrayApplicationContext.cs @@ -231,33 +231,65 @@ internal sealed class TrayApplicationContext : ApplicationContext // is suppressed (no keystrokes); the editor only shows which button is pressed. _coordinator.BeginEditorSession(profile); - var editor = new ProfileEditorForm(profile); + var editor = new ProfileEditorForm(profile, _config.DefaultRioComPort); editor.IsNameAvailable = name => !_config.Profiles.Any( p => !ReferenceEquals(p, profile) && string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)); - editor.Saved += _ => ConfigStore.Save(_config, ConfigPath); - editor.CommandRequested += cmd => _coordinator.Runtime?.Trigger(cmd); - editor.OutputsEnabledChanged += enabled => _coordinator.SetEditorOutputs(enabled); - RioRuntime? runtime = _coordinator.Runtime; - Action? activity = null; - if (runtime is not null) + // The editor's live wiring targets the CURRENT runtime, which is replaced + // when the session re-arms (endpoint change below) — so hook/unhook by pair. + Action? unhook = null; + void HookRuntime() { - activity = editor.ShowLiveActivity; - runtime.ButtonActivity += activity; + RioRuntime? runtime = _coordinator.Runtime; + if (runtime is null) + { + unhook = null; + return; + } + runtime.ButtonActivity += editor.ShowLiveActivity; runtime.AxesUpdated += editor.ShowAxes; runtime.VersionReceived += editor.ShowVersion; runtime.CheckReceived += editor.AddCheckStatus; - } - - editor.FormClosed += (_, _) => - { - if (runtime is not null && activity is not null) + unhook = () => { - runtime.ButtonActivity -= activity; + runtime.ButtonActivity -= editor.ShowLiveActivity; runtime.AxesUpdated -= editor.ShowAxes; runtime.VersionReceived -= editor.ShowVersion; runtime.CheckReceived -= editor.AddCheckStatus; + }; + } + + bool outputsOn = false; + string armedEndpoint = profile.RioComPort ?? _config.DefaultRioComPort; + + editor.Saved += _ => + { + ConfigStore.Save(_config, ConfigPath); + + // A saved port/pipe change takes effect immediately: re-arm the held + // session on the new endpoint so the live RIO buttons follow it. + string endpoint = profile.RioComPort ?? _config.DefaultRioComPort; + if (!string.Equals(endpoint, armedEndpoint, StringComparison.OrdinalIgnoreCase)) + { + unhook?.Invoke(); + _coordinator.BeginEditorSession(profile); + HookRuntime(); + _coordinator.SetEditorOutputs(outputsOn); // new gates start closed + armedEndpoint = endpoint; } + }; + editor.CommandRequested += cmd => _coordinator.Runtime?.Trigger(cmd); + editor.OutputsEnabledChanged += enabled => + { + outputsOn = enabled; + _coordinator.SetEditorOutputs(enabled); + }; + + HookRuntime(); + + editor.FormClosed += (_, _) => + { + unhook?.Invoke(); _coordinator.EndEditorSession(); _watcher.Reset(); // re-sync with the foreground app (may re-activate a game) _watcher.Poll();