diff --git a/src/RioJoy.Core/Mapping/GatedSinks.cs b/src/RioJoy.Core/Mapping/GatedSinks.cs
new file mode 100644
index 0000000..ae883ce
--- /dev/null
+++ b/src/RioJoy.Core/Mapping/GatedSinks.cs
@@ -0,0 +1,50 @@
+using RioJoy.Core.Calibration;
+
+namespace RioJoy.Core.Mapping;
+
+///
+/// An that forwards to an inner sink only while
+/// is set, and swallows output otherwise. Lets the editor put
+/// real keyboard/mouse output behind a toggle (off by default, so editing the RIO
+/// never injects input until the user opts in).
+///
+public sealed class GatedInputSink : IInputSink
+{
+ private readonly IInputSink _inner;
+
+ public GatedInputSink(IInputSink inner) =>
+ _inner = inner ?? throw new ArgumentNullException(nameof(inner));
+
+ /// When false, all output is dropped.
+ public bool Enabled { get; set; }
+
+ public void KeyDown(byte virtualKey, bool extended) { if (Enabled) _inner.KeyDown(virtualKey, extended); }
+
+ public void KeyUp(byte virtualKey, bool extended) { if (Enabled) _inner.KeyUp(virtualKey, extended); }
+
+ public void MouseMove(int dx, int dy) { if (Enabled) _inner.MouseMove(dx, dy); }
+
+ public void MouseButton(MouseButton button, bool down) { if (Enabled) _inner.MouseButton(button, down); }
+}
+
+///
+/// An that forwards to an inner sink only while
+/// is set. The editor's output toggle gates the virtual
+/// joystick the same way as the keyboard/mouse.
+///
+public sealed class GatedJoystickSink : IJoystickSink
+{
+ private readonly IJoystickSink _inner;
+
+ public GatedJoystickSink(IJoystickSink inner) =>
+ _inner = inner ?? throw new ArgumentNullException(nameof(inner));
+
+ /// When false, all output is dropped.
+ public bool Enabled { get; set; }
+
+ public void SetButton(int button, bool pressed) { if (Enabled) _inner.SetButton(button, pressed); }
+
+ public void SetHat(RioHat position) { if (Enabled) _inner.SetHat(position); }
+
+ public void SetAxis(JoyAxis axis, int value) { if (Enabled) _inner.SetAxis(axis, value); }
+}
diff --git a/src/RioJoy.Tray/Editor/PanelView.cs b/src/RioJoy.Tray/Editor/PanelView.cs
index 7f8df8a..c90513c 100644
--- a/src/RioJoy.Tray/Editor/PanelView.cs
+++ b/src/RioJoy.Tray/Editor/PanelView.cs
@@ -110,19 +110,22 @@ public static class PanelView
string? fn = function(b.Address);
bool assigned = !string.IsNullOrEmpty(fn);
- // A live (physically held) button lights bright in its own colour, as does
- // a lamp configured ON; otherwise the cell shows its idle/dim shade.
+ // Three lamp states in the cell's own colour: a physically held button is
+ // BRIGHT; a lamp configured on (IsLit) is DIM at idle (so the press still
+ // reads brighter); everything else is OFF.
bool yellow = b.Group.Title is "Secondary" or "Screen";
- bool bright = lit(b.Address) || live(b.Address);
+ bool held = live(b.Address);
+ bool litCfg = lit(b.Address);
Color fill = !b.LampCapable
- ? (live(b.Address) ? Color.FromArgb(205, 228, 255) // keypad — bright blue when pressed
- : Color.FromArgb(150, 182, 226)) // keypad — neutral blue
+ ? (held ? Color.FromArgb(205, 228, 255) // keypad — bright blue when pressed
+ : Color.FromArgb(150, 182, 226)) // keypad — neutral blue
: yellow
- ? (bright ? Color.FromArgb(240, 205, 50) // Secondary/Screen — yellow lamp
- : Color.FromArgb(120, 102, 30)) // Secondary/Screen — dim yellow
- : bright
- ? Color.FromArgb(215, 60, 60) // lamp ON (lit or held)
- : Color.FromArgb(86, 48, 48); // lamp OFF
+ ? (held ? Color.FromArgb(245, 210, 60) // Secondary/Screen — bright
+ : litCfg ? Color.FromArgb(140, 118, 38) // dim (lamp on, idle)
+ : Color.FromArgb(70, 60, 24)) // off
+ : (held ? Color.FromArgb(230, 70, 70) // lamp — bright (held)
+ : litCfg ? Color.FromArgb(120, 50, 50) // dim (lamp on, idle)
+ : Color.FromArgb(64, 40, 40)); // off
using (var fb = new SolidBrush(fill)) g.FillRectangle(fb, r);
Color fg = (b.LampCapable && !yellow) ? Color.White : Color.Black;
diff --git a/src/RioJoy.Tray/Editor/ProfileEditorForm.cs b/src/RioJoy.Tray/Editor/ProfileEditorForm.cs
index c716d51..dd57f3b 100644
--- a/src/RioJoy.Tray/Editor/ProfileEditorForm.cs
+++ b/src/RioJoy.Tray/Editor/ProfileEditorForm.cs
@@ -29,25 +29,27 @@ public sealed class ProfileEditorForm : Form
private readonly RioProfile _profile;
private readonly PanelCanvas _canvas = new();
- private readonly Label _info = new() { AutoSize = true, Location = new Point(12, 12), MaximumSize = new Size(310, 0) };
- private readonly TextBox _labelBox = new() { Location = new Point(70, 44), Width = 230 };
- private readonly ComboBox _kindBox = new() { Location = new Point(70, 78), Width = 150, DropDownStyle = ComboBoxStyle.DropDownList };
- private readonly Label _valueLabel = new() { Text = "Key:", Location = new Point(12, 115), AutoSize = true };
- private readonly ComboBox _valueCombo = new() { Location = new Point(70, 112), Width = 230, DropDownStyle = ComboBoxStyle.DropDownList };
- private readonly CheckBox _shift = new() { Text = "Shift", Location = new Point(70, 144), AutoSize = true };
- private readonly CheckBox _ctrl = new() { Text = "Ctrl", Location = new Point(140, 144), AutoSize = true };
- private readonly CheckBox _alt = new() { Text = "Alt", Location = new Point(200, 144), AutoSize = true };
- private readonly CheckBox _ext = new() { Text = "Ext", Location = new Point(250, 144), AutoSize = true };
- private readonly CheckBox _lit = new() { Text = "Lit", Location = new Point(70, 172), AutoSize = true };
- private readonly Button _apply = new() { Text = "Apply to cell", Location = new Point(70, 206), Width = 110 };
- private readonly Button _unassign = new() { Text = "Unassign", Location = new Point(190, 206), Width = 110 };
- private readonly Button _save = new() { Text = "Save profile", Location = new Point(70, 240), Width = 110 };
- private readonly Button _close = new() { Text = "Close", Location = new Point(190, 240), Width = 80 };
+ private readonly TextBox _nameBox = new() { Location = new Point(66, 12), Width = 234 };
+ private readonly Label _info = new() { AutoSize = true, Location = new Point(12, 40), MaximumSize = new Size(310, 0) };
+ private readonly TextBox _labelBox = new() { Location = new Point(70, 72), Width = 230 };
+ private readonly ComboBox _kindBox = new() { Location = new Point(70, 106), Width = 150, DropDownStyle = ComboBoxStyle.DropDownList };
+ private readonly Label _valueLabel = new() { Text = "Key:", Location = new Point(12, 143), AutoSize = true };
+ private readonly ComboBox _valueCombo = new() { Location = new Point(70, 140), Width = 230, DropDownStyle = ComboBoxStyle.DropDownList };
+ private readonly CheckBox _shift = new() { Text = "Shift", Location = new Point(70, 172), AutoSize = true };
+ private readonly CheckBox _ctrl = new() { Text = "Ctrl", Location = new Point(140, 172), AutoSize = true };
+ private readonly CheckBox _alt = new() { Text = "Alt", Location = new Point(200, 172), AutoSize = true };
+ private readonly CheckBox _ext = new() { Text = "Ext", Location = new Point(250, 172), AutoSize = true };
+ private readonly CheckBox _lit = new() { Text = "Lit", Location = new Point(70, 200), AutoSize = true };
+ private readonly Button _apply = new() { Text = "Apply to cell", Location = new Point(70, 234), Width = 110 };
+ private readonly Button _unassign = new() { Text = "Unassign", Location = new Point(190, 234), Width = 110 };
+ private readonly Button _save = new() { Text = "Save profile", Location = new Point(70, 268), Width = 110 };
+ private readonly Button _close = new() { Text = "Close", Location = new Point(190, 268), Width = 80 };
+ private readonly CheckBox _outputToggle = new() { Text = "Send button output to the PC", Location = new Point(12, 300), AutoSize = true };
private readonly TextBox _statusBox = new()
{
- Location = new Point(12, 566),
- Size = new Size(306, 190),
+ Location = new Point(12, 618),
+ Size = new Size(306, 172),
Multiline = true,
ReadOnly = true,
ScrollBars = ScrollBars.Vertical,
@@ -57,6 +59,9 @@ public sealed class ProfileEditorForm : Form
Text = "Press \"Request version & status\" to query the RIO.",
};
+ // Briefly flips the Save button to a confirmation, then reverts.
+ private readonly System.Windows.Forms.Timer _saveFlash = new() { Interval = 1600 };
+
private int? _address;
private bool _loading;
@@ -87,14 +92,24 @@ public sealed class ProfileEditorForm : Form
/// Raised when the user saves; the argument is the edited profile.
public event Action? Saved;
+ ///
+ /// Returns whether a candidate profile name is free to use (i.e. not taken by a
+ /// different profile). Set by the host; null = no uniqueness check.
+ ///
+ public Func? IsNameAvailable { get; set; }
+
/// Raised when the user clicks one of the live RIO-command buttons.
public event Action? CommandRequested;
+ /// Raised when the "send output to the PC" toggle changes (true = send).
+ public event Action? OutputsEnabledChanged;
+
public ProfileEditorForm(RioProfile profile)
{
_profile = profile ?? throw new ArgumentNullException(nameof(profile));
Text = $"RIOJoy — Edit profile: {profile.Name}";
+ _nameBox.Text = profile.Name;
ClientSize = new Size(1320, 820);
StartPosition = FormStartPosition.CenterScreen;
MinimumSize = new Size(900, 500);
@@ -119,8 +134,17 @@ public sealed class ProfileEditorForm : Form
_apply.Click += (_, _) => ApplyToCell();
_unassign.Click += (_, _) => Unassign();
- _save.Click += (_, _) => { ApplyToCell(); Saved?.Invoke(_profile); };
+ _save.Click += (_, _) => SaveProfile();
_close.Click += (_, _) => Close();
+ _outputToggle.CheckedChanged += (_, _) => OutputsEnabledChanged?.Invoke(_outputToggle.Checked);
+
+ _saveFlash.Tick += (_, _) =>
+ {
+ _saveFlash.Stop();
+ _save.Text = "Save profile";
+ _save.ForeColor = SystemColors.ControlText;
+ };
+ FormClosed += (_, _) => _saveFlash.Dispose();
SetEditingEnabled(false);
_info.Text = "Click a button to edit it.";
@@ -133,16 +157,18 @@ public sealed class ProfileEditorForm : Form
{
var panel = new Panel { Dock = DockStyle.Right, Width = 330, Padding = new Padding(8), BorderStyle = BorderStyle.FixedSingle, AutoScroll = true };
+ panel.Controls.Add(new Label { Text = "Profile:", Location = new Point(12, 15), AutoSize = true });
+ panel.Controls.Add(_nameBox);
panel.Controls.Add(_info);
- panel.Controls.Add(new Label { Text = "Label:", Location = new Point(12, 47), AutoSize = true });
+ panel.Controls.Add(new Label { Text = "Label:", Location = new Point(12, 75), AutoSize = true });
panel.Controls.Add(_labelBox);
- panel.Controls.Add(new Label { Text = "Action:", Location = new Point(12, 81), AutoSize = true });
+ panel.Controls.Add(new Label { Text = "Action:", Location = new Point(12, 109), 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 });
+ 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, 548), AutoSize = true });
+ panel.Controls.Add(new Label { Text = "RIO reply:", Location = new Point(12, 600), AutoSize = true });
panel.Controls.Add(_statusBox);
return panel;
@@ -151,7 +177,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, 278), Size = new Size(306, 262) };
+ var group = new GroupBox { Text = "RIO commands (live)", Location = new Point(12, 330), Size = new Size(306, 262) };
int y = 24;
foreach ((string label, RioCommandCode code) in RioCommands)
@@ -309,6 +335,56 @@ public sealed class ProfileEditorForm : Form
_canvas.Refresh(addr); // update lamp shade + label on the panel
}
+ // Apply the pending cell edit, persist via the Saved handler, and confirm on the
+ // button (the actual write happens in the host's Saved handler — config.json).
+ private void SaveProfile()
+ {
+ if (!ApplyName())
+ return;
+
+ ApplyToCell();
+ try
+ {
+ Saved?.Invoke(_profile);
+ _save.Text = "Saved ✓";
+ _save.ForeColor = Color.ForestGreen;
+ }
+ catch (Exception ex)
+ {
+ _save.Text = "Save failed";
+ _save.ForeColor = Color.Firebrick;
+ MessageBox.Show(this, $"Could not save the profile:\n{ex.Message}",
+ "RIOJoy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ }
+ _saveFlash.Stop();
+ _saveFlash.Start();
+ }
+
+ // Validate and apply the profile name from the name box. Returns false (and warns)
+ // if the name is blank or already taken by another profile, so the save is aborted.
+ private bool ApplyName()
+ {
+ string name = _nameBox.Text.Trim();
+ if (string.IsNullOrEmpty(name))
+ {
+ MessageBox.Show(this, "Profile name cannot be empty.", "RIOJoy",
+ MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ return false;
+ }
+
+ bool changed = !string.Equals(name, _profile.Name, StringComparison.OrdinalIgnoreCase);
+ if (changed && IsNameAvailable?.Invoke(name) == false)
+ {
+ MessageBox.Show(this, $"A profile named \"{name}\" already exists.", "RIOJoy",
+ MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ return false;
+ }
+
+ _profile.Name = name;
+ Text = $"RIOJoy — Edit profile: {name}";
+ return true;
+ }
+
// Clear the selected button's action mapping (the label is left untouched).
private void Unassign()
{
diff --git a/src/RioJoy.Tray/RioCoordinator.cs b/src/RioJoy.Tray/RioCoordinator.cs
index bf55eff..8cc8e93 100644
--- a/src/RioJoy.Tray/RioCoordinator.cs
+++ b/src/RioJoy.Tray/RioCoordinator.cs
@@ -31,6 +31,11 @@ public sealed class RioCoordinator : IDisposable
private bool _auto = true;
private bool _editorSession;
+ // While editing, real keyboard/mouse + joystick run through these gates (off until
+ // the editor's "send output" toggle opens them).
+ private GatedInputSink? _editorInput;
+ private GatedJoystickSink? _editorJoystick;
+
// The active connection (null when dormant).
private IRioTransport? _transport;
private RioSerialLink? _link;
@@ -116,6 +121,23 @@ public sealed class RioCoordinator : IDisposable
Activate(profile, routeInput: false); // hold the port but don't inject keystrokes/joystick
}
+ ///
+ /// Open or close the editor's output gates: when enabled, RIO button presses
+ /// drive real keyboard/mouse and the virtual joystick; when disabled they don't.
+ /// No-op outside an editor session.
+ ///
+ public void SetEditorOutputs(bool enabled)
+ {
+ if (_editorInput is null || _editorJoystick is null)
+ return;
+
+ _editorInput.Enabled = enabled;
+ _editorJoystick.Enabled = enabled;
+
+ if (_activeProfileName is not null)
+ SetStatus($"Editing: {_activeProfileName} — outputs {(enabled ? "ON (sending to PC)" : "off")}");
+ }
+
/// Release the editor's port hold and hand control back to the watcher.
public void EndEditorSession()
{
@@ -138,17 +160,31 @@ public sealed class RioCoordinator : IDisposable
try
{
- // Keyboard/mouse + joystick are suppressed while editing so the RIO can be
- // exercised without injecting input; lamp feedback over serial still applies.
+ // Keyboard/mouse + joystick are gated off while editing so the RIO can be
+ // exercised without injecting input (the editor toggle opens the gates);
+ // lamp feedback over serial still applies.
IInputSink input;
IJoystickSink joystick;
string note;
if (!routeInput)
{
- input = new NullInputSink();
- joystick = new NullJoystickSink();
- _joystick = null;
- note = " [editing — input suppressed]";
+ IJoystickSink realJoystick;
+ if (HidFeederJoystickSink.TryCreate(out HidFeederJoystickSink? editorFeeder))
+ {
+ realJoystick = editorFeeder!;
+ _joystick = editorFeeder;
+ }
+ else
+ {
+ realJoystick = new NullJoystickSink();
+ _joystick = null;
+ }
+
+ _editorInput = new GatedInputSink(new SendInputSink());
+ _editorJoystick = new GatedJoystickSink(realJoystick);
+ input = _editorInput;
+ joystick = _editorJoystick;
+ note = " [editing — outputs off]";
}
else if (HidFeederJoystickSink.TryCreate(out HidFeederJoystickSink? feeder))
{
@@ -252,6 +288,8 @@ public sealed class RioCoordinator : IDisposable
_joystick?.Dispose(); // closes the HID feeder handle
_joystick = null;
+ _editorInput = null;
+ _editorJoystick = null;
_transport?.Dispose(); // releases the COM port
_transport = null;
diff --git a/src/RioJoy.Tray/TrayApplicationContext.cs b/src/RioJoy.Tray/TrayApplicationContext.cs
index 6e2e93c..82f66b1 100644
--- a/src/RioJoy.Tray/TrayApplicationContext.cs
+++ b/src/RioJoy.Tray/TrayApplicationContext.cs
@@ -69,7 +69,11 @@ internal sealed class TrayApplicationContext : ApplicationContext
RebuildProfileMenu(profileMenu);
menu.Items.Add(profileMenu);
- menu.Items.Add("Edit profile…", null, (_, _) => OpenEditor());
+ var editMenu = new ToolStripMenuItem("Edit profile");
+ editMenu.DropDownOpening += (_, _) => RebuildEditMenu(editMenu);
+ RebuildEditMenu(editMenu);
+ menu.Items.Add(editMenu);
+
menu.Items.Add("Import .ini…", null, (_, _) => ImportIniProfiles());
// RIO commands mirroring the legacy console menu.
@@ -134,20 +138,50 @@ internal sealed class TrayApplicationContext : ApplicationContext
profileMenu.DropDownItems.Add(dormant);
}
- private void OpenEditor()
+ // Rebuild the "Edit profile" submenu: one entry per profile (opens it in the
+ // editor) plus an entry to create a new one.
+ private void RebuildEditMenu(ToolStripMenuItem editMenu)
{
- // The cockpit panel layout is built in (CockpitPanel); the editor just needs
- // a profile to edit.
- RioProfile profile = _config.Profiles.FirstOrDefault() ?? AddBlankProfile();
+ editMenu.DropDownItems.Clear();
+ foreach (RioProfile profile in _config.Profiles)
+ {
+ RioProfile captured = profile;
+ editMenu.DropDownItems.Add(new ToolStripMenuItem(profile.Name, null, (_, _) => OpenEditor(captured)));
+ }
+
+ if (_config.Profiles.Count > 0)
+ editMenu.DropDownItems.Add(new ToolStripSeparator());
+
+ editMenu.DropDownItems.Add(new ToolStripMenuItem("New profile…", null, (_, _) => OpenEditor(NewProfile())));
+ }
+
+ // Create, register and persist a fresh empty profile with a unique name.
+ private RioProfile NewProfile()
+ {
+ string name = "New profile";
+ for (int i = 2; _config.FindProfile(name) is not null; i++)
+ name = $"New profile {i}";
+
+ var profile = new RioProfile { Name = name };
+ _config.Profiles.Add(profile);
+ ConfigStore.Save(_config, ConfigPath);
+ return profile;
+ }
+
+ private void OpenEditor(RioProfile profile)
+ {
// Acquiring the RIO is one of the two reasons we take the serial port (the
// other being a profiled game) — so the editor can check button function. Input
// is suppressed (no keystrokes); the editor only shows which button is pressed.
_coordinator.BeginEditorSession(profile);
var editor = new ProfileEditorForm(profile);
+ 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;
@@ -174,13 +208,6 @@ internal sealed class TrayApplicationContext : ApplicationContext
editor.Show();
}
- private RioProfile AddBlankProfile()
- {
- var profile = new RioProfile { Name = "New profile" };
- _config.Profiles.Add(profile);
- return profile;
- }
-
// Import one or more legacy RIO.ini files (the gsheet "INI_V2" format) as
// profiles, naming each after its filename and de-duping collisions.
private void ImportIniProfiles()