Editor: profile rename/new, PC-output toggle, save & lamp feedback

- Profile name field in the editor (rename, with empty/duplicate checks);
  "Edit profile" becomes a submenu listing each profile plus "New profile…".
- "Send button output to the PC" toggle: editor sessions route keyboard/mouse
  and joystick through gates (GatedInputSink/GatedJoystickSink) that stay closed
  until the user opts in, so editing never injects input by default.
- Save profile now confirms ("Saved ✓"/"Save failed") — the write was silent.
- A lit button renders dim at idle and bright only when physically pressed, so
  the press stays visible (three states: off / dim-lit / bright-held).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-06-28 22:39:50 -05:00
co-authored by Claude Opus 4.8
parent 905b9facc9
commit 195038080e
5 changed files with 244 additions and 50 deletions
+50
View File
@@ -0,0 +1,50 @@
using RioJoy.Core.Calibration;
namespace RioJoy.Core.Mapping;
/// <summary>
/// An <see cref="IInputSink"/> that forwards to an inner sink only while
/// <see cref="Enabled"/> 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).
/// </summary>
public sealed class GatedInputSink : IInputSink
{
private readonly IInputSink _inner;
public GatedInputSink(IInputSink inner) =>
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
/// <summary>When false, all output is dropped.</summary>
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); }
}
/// <summary>
/// An <see cref="IJoystickSink"/> that forwards to an inner sink only while
/// <see cref="Enabled"/> is set. The editor's output toggle gates the virtual
/// joystick the same way as the keyboard/mouse.
/// </summary>
public sealed class GatedJoystickSink : IJoystickSink
{
private readonly IJoystickSink _inner;
public GatedJoystickSink(IJoystickSink inner) =>
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
/// <summary>When false, all output is dropped.</summary>
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); }
}
+13 -10
View File
@@ -110,19 +110,22 @@ public static class PanelView
string? fn = function(b.Address); string? fn = function(b.Address);
bool assigned = !string.IsNullOrEmpty(fn); bool assigned = !string.IsNullOrEmpty(fn);
// A live (physically held) button lights bright in its own colour, as does // Three lamp states in the cell's own colour: a physically held button is
// a lamp configured ON; otherwise the cell shows its idle/dim shade. // 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 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 Color fill = !b.LampCapable
? (live(b.Address) ? Color.FromArgb(205, 228, 255) // keypad — bright blue when pressed ? (held ? Color.FromArgb(205, 228, 255) // keypad — bright blue when pressed
: Color.FromArgb(150, 182, 226)) // keypad — neutral blue : Color.FromArgb(150, 182, 226)) // keypad — neutral blue
: yellow : yellow
? (bright ? Color.FromArgb(240, 205, 50) // Secondary/Screen — yellow lamp ? (held ? Color.FromArgb(245, 210, 60) // Secondary/Screen — bright
: Color.FromArgb(120, 102, 30)) // Secondary/Screen — dim yellow : litCfg ? Color.FromArgb(140, 118, 38) // dim (lamp on, idle)
: bright : Color.FromArgb(70, 60, 24)) // off
? Color.FromArgb(215, 60, 60) // lamp ON (lit or held) : (held ? Color.FromArgb(230, 70, 70) // lamp — bright (held)
: Color.FromArgb(86, 48, 48); // lamp OFF : 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); using (var fb = new SolidBrush(fill)) g.FillRectangle(fb, r);
Color fg = (b.LampCapable && !yellow) ? Color.White : Color.Black; Color fg = (b.LampCapable && !yellow) ? Color.White : Color.Black;
+98 -22
View File
@@ -29,25 +29,27 @@ public sealed class ProfileEditorForm : Form
private readonly RioProfile _profile; private readonly RioProfile _profile;
private readonly PanelCanvas _canvas = new(); 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 _nameBox = new() { Location = new Point(66, 12), Width = 234 };
private readonly TextBox _labelBox = new() { Location = new Point(70, 44), Width = 230 }; private readonly Label _info = new() { AutoSize = true, Location = new Point(12, 40), MaximumSize = new Size(310, 0) };
private readonly ComboBox _kindBox = new() { Location = new Point(70, 78), Width = 150, DropDownStyle = ComboBoxStyle.DropDownList }; private readonly TextBox _labelBox = new() { Location = new Point(70, 72), Width = 230 };
private readonly Label _valueLabel = new() { Text = "Key:", Location = new Point(12, 115), AutoSize = true }; private readonly ComboBox _kindBox = new() { Location = new Point(70, 106), Width = 150, DropDownStyle = ComboBoxStyle.DropDownList };
private readonly ComboBox _valueCombo = new() { Location = new Point(70, 112), Width = 230, DropDownStyle = ComboBoxStyle.DropDownList }; private readonly Label _valueLabel = new() { Text = "Key:", Location = new Point(12, 143), AutoSize = true };
private readonly CheckBox _shift = new() { Text = "Shift", Location = new Point(70, 144), AutoSize = true }; private readonly ComboBox _valueCombo = new() { Location = new Point(70, 140), Width = 230, DropDownStyle = ComboBoxStyle.DropDownList };
private readonly CheckBox _ctrl = new() { Text = "Ctrl", Location = new Point(140, 144), AutoSize = true }; private readonly CheckBox _shift = new() { Text = "Shift", Location = new Point(70, 172), AutoSize = true };
private readonly CheckBox _alt = new() { Text = "Alt", Location = new Point(200, 144), AutoSize = true }; private readonly CheckBox _ctrl = new() { Text = "Ctrl", Location = new Point(140, 172), AutoSize = true };
private readonly CheckBox _ext = new() { Text = "Ext", Location = new Point(250, 144), AutoSize = true }; private readonly CheckBox _alt = new() { Text = "Alt", Location = new Point(200, 172), AutoSize = true };
private readonly CheckBox _lit = new() { Text = "Lit", Location = new Point(70, 172), AutoSize = true }; private readonly CheckBox _ext = new() { Text = "Ext", Location = new Point(250, 172), AutoSize = true };
private readonly Button _apply = new() { Text = "Apply to cell", Location = new Point(70, 206), Width = 110 }; private readonly CheckBox _lit = new() { Text = "Lit", Location = new Point(70, 200), AutoSize = true };
private readonly Button _unassign = new() { Text = "Unassign", Location = new Point(190, 206), Width = 110 }; private readonly Button _apply = new() { Text = "Apply to cell", Location = new Point(70, 234), Width = 110 };
private readonly Button _save = new() { Text = "Save profile", Location = new Point(70, 240), Width = 110 }; private readonly Button _unassign = new() { Text = "Unassign", Location = new Point(190, 234), Width = 110 };
private readonly Button _close = new() { Text = "Close", Location = new Point(190, 240), Width = 80 }; 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() private readonly TextBox _statusBox = new()
{ {
Location = new Point(12, 566), Location = new Point(12, 618),
Size = new Size(306, 190), Size = new Size(306, 172),
Multiline = true, Multiline = true,
ReadOnly = true, ReadOnly = true,
ScrollBars = ScrollBars.Vertical, ScrollBars = ScrollBars.Vertical,
@@ -57,6 +59,9 @@ public sealed class ProfileEditorForm : Form
Text = "Press \"Request version & status\" to query the RIO.", 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 int? _address;
private bool _loading; private bool _loading;
@@ -87,14 +92,24 @@ public sealed class ProfileEditorForm : Form
/// <summary>Raised when the user saves; the argument is the edited profile.</summary> /// <summary>Raised when the user saves; the argument is the edited profile.</summary>
public event Action<RioProfile>? Saved; public event Action<RioProfile>? Saved;
/// <summary>
/// Returns whether a candidate profile name is free to use (i.e. not taken by a
/// <em>different</em> profile). Set by the host; null = no uniqueness check.
/// </summary>
public Func<string, bool>? IsNameAvailable { get; set; }
/// <summary>Raised when the user clicks one of the live RIO-command buttons.</summary> /// <summary>Raised when the user clicks one of the live RIO-command buttons.</summary>
public event Action<RioCommandCode>? CommandRequested; public event Action<RioCommandCode>? CommandRequested;
/// <summary>Raised when the "send output to the PC" toggle changes (true = send).</summary>
public event Action<bool>? OutputsEnabledChanged;
public ProfileEditorForm(RioProfile profile) public ProfileEditorForm(RioProfile profile)
{ {
_profile = profile ?? throw new ArgumentNullException(nameof(profile)); _profile = profile ?? throw new ArgumentNullException(nameof(profile));
Text = $"RIOJoy — Edit profile: {profile.Name}"; Text = $"RIOJoy — Edit profile: {profile.Name}";
_nameBox.Text = profile.Name;
ClientSize = new Size(1320, 820); ClientSize = new Size(1320, 820);
StartPosition = FormStartPosition.CenterScreen; StartPosition = FormStartPosition.CenterScreen;
MinimumSize = new Size(900, 500); MinimumSize = new Size(900, 500);
@@ -119,8 +134,17 @@ public sealed class ProfileEditorForm : Form
_apply.Click += (_, _) => ApplyToCell(); _apply.Click += (_, _) => ApplyToCell();
_unassign.Click += (_, _) => Unassign(); _unassign.Click += (_, _) => Unassign();
_save.Click += (_, _) => { ApplyToCell(); Saved?.Invoke(_profile); }; _save.Click += (_, _) => SaveProfile();
_close.Click += (_, _) => Close(); _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); SetEditingEnabled(false);
_info.Text = "Click a button to edit it."; _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 }; 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(_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(_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(_kindBox);
panel.Controls.Add(_valueLabel); panel.Controls.Add(_valueLabel);
panel.Controls.Add(_valueCombo); 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(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); panel.Controls.Add(_statusBox);
return panel; return panel;
@@ -151,7 +177,7 @@ public sealed class ProfileEditorForm : Form
// A button per RIO device command, fired against the live RIO via CommandRequested. // A button per RIO device command, fired against the live RIO via CommandRequested.
private GroupBox BuildCommandGroup() 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; int y = 24;
foreach ((string label, RioCommandCode code) in RioCommands) 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 _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). // Clear the selected button's action mapping (the label is left untouched).
private void Unassign() private void Unassign()
{ {
+44 -6
View File
@@ -31,6 +31,11 @@ public sealed class RioCoordinator : IDisposable
private bool _auto = true; private bool _auto = true;
private bool _editorSession; 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). // The active connection (null when dormant).
private IRioTransport? _transport; private IRioTransport? _transport;
private RioSerialLink? _link; 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 Activate(profile, routeInput: false); // hold the port but don't inject keystrokes/joystick
} }
/// <summary>
/// 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.
/// </summary>
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")}");
}
/// <summary>Release the editor's port hold and hand control back to the watcher.</summary> /// <summary>Release the editor's port hold and hand control back to the watcher.</summary>
public void EndEditorSession() public void EndEditorSession()
{ {
@@ -138,17 +160,31 @@ public sealed class RioCoordinator : IDisposable
try try
{ {
// Keyboard/mouse + joystick are suppressed while editing so the RIO can be // Keyboard/mouse + joystick are gated off while editing so the RIO can be
// exercised without injecting input; lamp feedback over serial still applies. // exercised without injecting input (the editor toggle opens the gates);
// lamp feedback over serial still applies.
IInputSink input; IInputSink input;
IJoystickSink joystick; IJoystickSink joystick;
string note; string note;
if (!routeInput) if (!routeInput)
{ {
input = new NullInputSink(); IJoystickSink realJoystick;
joystick = new NullJoystickSink(); if (HidFeederJoystickSink.TryCreate(out HidFeederJoystickSink? editorFeeder))
_joystick = null; {
note = " [editing — input suppressed]"; 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)) else if (HidFeederJoystickSink.TryCreate(out HidFeederJoystickSink? feeder))
{ {
@@ -252,6 +288,8 @@ public sealed class RioCoordinator : IDisposable
_joystick?.Dispose(); // closes the HID feeder handle _joystick?.Dispose(); // closes the HID feeder handle
_joystick = null; _joystick = null;
_editorInput = null;
_editorJoystick = null;
_transport?.Dispose(); // releases the COM port _transport?.Dispose(); // releases the COM port
_transport = null; _transport = null;
+39 -12
View File
@@ -69,7 +69,11 @@ internal sealed class TrayApplicationContext : ApplicationContext
RebuildProfileMenu(profileMenu); RebuildProfileMenu(profileMenu);
menu.Items.Add(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()); menu.Items.Add("Import .ini…", null, (_, _) => ImportIniProfiles());
// RIO commands mirroring the legacy console menu. // RIO commands mirroring the legacy console menu.
@@ -134,20 +138,50 @@ internal sealed class TrayApplicationContext : ApplicationContext
profileMenu.DropDownItems.Add(dormant); 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 editMenu.DropDownItems.Clear();
// a profile to edit.
RioProfile profile = _config.Profiles.FirstOrDefault() ?? AddBlankProfile();
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 // 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 // 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. // is suppressed (no keystrokes); the editor only shows which button is pressed.
_coordinator.BeginEditorSession(profile); _coordinator.BeginEditorSession(profile);
var editor = new ProfileEditorForm(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.Saved += _ => ConfigStore.Save(_config, ConfigPath);
editor.CommandRequested += cmd => _coordinator.Runtime?.Trigger(cmd); editor.CommandRequested += cmd => _coordinator.Runtime?.Trigger(cmd);
editor.OutputsEnabledChanged += enabled => _coordinator.SetEditorOutputs(enabled);
RioRuntime? runtime = _coordinator.Runtime; RioRuntime? runtime = _coordinator.Runtime;
Action<int, bool>? activity = null; Action<int, bool>? activity = null;
@@ -174,13 +208,6 @@ internal sealed class TrayApplicationContext : ApplicationContext
editor.Show(); 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 // Import one or more legacy RIO.ini files (the gsheet "INI_V2" format) as
// profiles, naming each after its filename and de-duping collisions. // profiles, naming each after its filename and de-duping collisions.
private void ImportIniProfiles() private void ImportIniProfiles()