using System.Runtime.Versioning;
using System.Text;
using RioJoy.Core.Editing;
using RioJoy.Core.Output;
using RioJoy.Core.Mapping;
using RioJoy.Core.Profiles;
using RioJoy.Core.Protocol;
namespace RioJoy.Tray.Editor;
///
/// Profile editor: shows the cockpit control panel (the functional grouping from
/// the original Win32 RIO design — MFD clusters, board columns, keypads, encoder
/// gauges, via ) as a clickable map, and edits the
/// selected button's label and action/modifiers/lamp. Writes back to the
/// 's (keyed by the
/// wallpaper region b-XX) and (the iRIO
/// word); Save raises .
///
[SupportedOSPlatform("windows")]
public sealed class ProfileEditorForm : Form
{
private static readonly Dictionary GroupOf =
CockpitPanel.Buttons().ToDictionary(b => b.Address, b => b.Group.Title);
private static readonly Dictionary LampCapableOf =
CockpitPanel.Buttons().ToDictionary(b => b.Address, b => b.LampCapable);
private readonly RioProfile _profile;
private readonly PanelCanvas _canvas = new();
private readonly TextBox _nameBox = new() { Location = new Point(66, 12), Width = 234 };
private readonly TextBox _matchBox = new() { Location = new Point(66, 40), Width = 234, PlaceholderText = "game.exe, other.exe — auto-switch" };
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 };
private readonly TextBox _statusBox = new()
{
Location = new Point(12, 646),
Size = new Size(306, 145),
Multiline = true,
ReadOnly = true,
ScrollBars = ScrollBars.Vertical,
BackColor = Color.FromArgb(24, 24, 24),
ForeColor = Color.Gainsboro,
Font = new Font("Consolas", 8f),
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;
// The most recent version/status reply, accumulated as CheckReply items stream in.
private VersionInfo? _version;
private readonly List _checks = new();
// The RIO device commands, mirroring the tray menu, surfaced as live buttons so
// they can be fired against the RIO while editing (the editor holds the port).
private static readonly (string Label, RioCommandCode Code)[] RioCommands =
{
("Reset all analog axes", RioCommandCode.ResetAllAxes),
("Reset throttle", RioCommandCode.ResetThrottle),
("Reset left pedal", RioCommandCode.ResetLeftPedal),
("Reset right pedal", RioCommandCode.ResetRightPedal),
("Reset vertical joystick", RioCommandCode.ResetVerticalJoystick),
("Reset horizontal joystick", RioCommandCode.ResetHorizontalJoystick),
("Reset digital (general)", RioCommandCode.GeneralReset),
("Request version & status", RioCommandCode.RequestVersionAndCheck),
};
/// A pick-list entry: what the user sees and the byte it maps to.
private sealed record ValueItem(string Display, byte Value)
{
public override string ToString() => Display;
}
/// 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;
_matchBox.Text = string.Join(", ", profile.MatchExecutables);
ClientSize = new Size(1320, 820);
StartPosition = FormStartPosition.CenterScreen;
MinimumSize = new Size(900, 500);
_kindBox.Items.AddRange(Enum.GetNames());
_kindBox.SelectedIndexChanged += OnKindChanged;
_canvas.LabelProvider = a => _profile.OverlayLabels.TryGetValue(RegionFor(a), out string? t) ? t : null;
_canvas.FunctionProvider = a => _profile.Buttons.TryGetValue(a, out int w) ? ButtonBinding.FromWord((ushort)w).Describe() : null;
_canvas.LitProvider = a => _profile.Buttons.TryGetValue(a, out int w) && ButtonBinding.FromWord((ushort)w).IsLit;
_canvas.CalibrationProvider = ReadCalibration;
_canvas.CalibrationToggled += ToggleCalibration;
_canvas.AddressSelected += OnAddressSelected;
var scroller = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Color.FromArgb(28, 28, 28) };
scroller.Controls.Add(_canvas);
Panel editPanel = BuildEditPanel();
Controls.Add(scroller);
Controls.Add(editPanel);
_apply.Click += (_, _) => ApplyToCell();
_unassign.Click += (_, _) => Unassign();
_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.";
}
/// The selected RIO address (for tests / offline rendering).
public int? SelectedAddress => _address;
private Panel BuildEditPanel()
{
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(new Label { Text = "Triggers:", Location = new Point(12, 43), AutoSize = true });
panel.Controls.Add(_matchBox);
panel.Controls.Add(_info);
panel.Controls.Add(new Label { Text = "Label:", Location = new Point(12, 103), AutoSize = true });
panel.Controls.Add(_labelBox);
panel.Controls.Add(new Label { Text = "Action:", Location = new Point(12, 137), 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(_statusBox);
return panel;
}
// 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) };
int y = 24;
foreach ((string label, RioCommandCode code) in RioCommands)
{
RioCommandCode captured = code;
var button = new Button { Text = label, Location = new Point(10, y), Width = 286, Height = 26, UseMnemonic = false };
button.Click += (_, _) =>
{
// The version/status reply streams back into the box below; clear it first.
if (captured == RioCommandCode.RequestVersionAndCheck)
{
_version = null;
_checks.Clear();
_statusBox.Text = "Requesting version & status…";
}
CommandRequested?.Invoke(captured);
};
group.Controls.Add(button);
y += 29;
}
return group;
}
private void OnAddressSelected(int addr)
{
_address = addr;
string region = RegionFor(addr);
string group = GroupOf.TryGetValue(addr, out string? gn) ? gn : "—";
_info.Text = $"Address 0x{addr:X2} {group} (wallpaper {region})";
ButtonBinding b = ButtonBinding.FromWord(
_profile.Buttons.TryGetValue(addr, out int w) ? (ushort)w : (ushort)0);
_loading = true;
_labelBox.Text = _profile.OverlayLabels.TryGetValue(region, out string? label) ? label : string.Empty;
_kindBox.SelectedItem = b.Kind.ToString();
PopulateValues(b.Kind);
SelectValue(b.Value);
_shift.Checked = b.Shift;
_ctrl.Checked = b.Ctrl;
_alt.Checked = b.Alt;
_ext.Checked = b.Extended;
_lit.Checked = b.IsLit;
_lit.Visible = LampCapableOf.GetValueOrDefault(addr); // keypads have no lamp
_loading = false;
SetEditingEnabled(true);
}
private void OnKindChanged(object? sender, EventArgs e)
{
if (_loading) return;
PopulateValues(SelectedKind());
if (_valueCombo.Items.Count > 0) _valueCombo.SelectedIndex = 0;
UpdateModifierAvailability();
}
// Fill the value picker with friendly choices for the chosen routing kind.
private void PopulateValues(RioRouteKind kind)
{
_valueLabel.Text = kind switch
{
RioRouteKind.Keyboard => "Key:",
RioRouteKind.Joystick => "Button:",
RioRouteKind.Hat => "Direction:",
RioRouteKind.Mouse => "Action:",
RioRouteKind.RioCommand => "Command:",
_ => "Value:",
};
_valueCombo.BeginUpdate();
_valueCombo.Items.Clear();
switch (kind)
{
case RioRouteKind.Keyboard:
foreach (KeyName k in KeyCatalog.Entries)
_valueCombo.Items.Add(new ValueItem(k.Name, k.Value));
break;
case RioRouteKind.Joystick:
// The signed ViGEmBus Xbox 360 pad exposes 11 buttons; show their names.
for (int btn = 1; btn <= ViGEmJoystickSink.MappableButtonCount; btn++)
_valueCombo.Items.Add(new ValueItem($"Button {btn} ({ViGEmJoystickSink.ButtonNames[btn - 1]})", (byte)btn));
break;
case RioRouteKind.Hat:
foreach (RioHat h in new[] { RioHat.Up, RioHat.Right, RioHat.Down, RioHat.Left })
_valueCombo.Items.Add(new ValueItem(h.ToString(), (byte)(int)h));
break;
case RioRouteKind.Mouse:
foreach (MouseAction m in Enum.GetValues())
_valueCombo.Items.Add(new ValueItem(m.ToString(), (byte)m));
break;
case RioRouteKind.RioCommand:
foreach (RioCommandCode c in Enum.GetValues())
_valueCombo.Items.Add(new ValueItem(c.ToString(), (byte)c));
break;
}
_valueCombo.EndUpdate();
UpdateModifierAvailability();
}
private void SelectValue(byte value)
{
for (int i = 0; i < _valueCombo.Items.Count; i++)
{
if (_valueCombo.Items[i] is ValueItem v && v.Value == value)
{
_valueCombo.SelectedIndex = i;
return;
}
}
int idx = _valueCombo.Items.Add(new ValueItem($"0x{value:X2}", value));
_valueCombo.SelectedIndex = idx;
}
private void UpdateModifierAvailability()
{
bool keyboard = SelectedKind() == RioRouteKind.Keyboard;
foreach (Control c in new Control[] { _shift, _ctrl, _alt, _ext })
c.Enabled = keyboard;
}
private RioRouteKind SelectedKind() =>
Enum.TryParse(_kindBox.SelectedItem?.ToString(), out RioRouteKind k) ? k : RioRouteKind.Keyboard;
private void ApplyToCell()
{
if (_address is not { } addr) return;
string region = RegionFor(addr);
string label = _labelBox.Text.Trim();
if (string.IsNullOrEmpty(label))
_profile.OverlayLabels.Remove(region);
else
_profile.OverlayLabels[region] = label;
RioRouteKind kind = SelectedKind();
bool keyboard = kind == RioRouteKind.Keyboard;
var binding = new ButtonBinding
{
Kind = kind,
Value = _valueCombo.SelectedItem is ValueItem vi ? vi.Value : (byte)0,
Shift = keyboard && _shift.Checked,
Ctrl = keyboard && _ctrl.Checked,
Alt = keyboard && _alt.Checked,
Extended = keyboard && _ext.Checked,
IsLit = LampCapableOf.GetValueOrDefault(addr) && _lit.Checked, // keypads never lit
};
ushort word = binding.ToWord();
if (word == 0)
_profile.Buttons.Remove(addr);
else
_profile.Buttons[addr] = word;
_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;
// The foreground executables that auto-activate this profile (comma-separated).
_profile.MatchExecutables = _matchBox.Text
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
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()
{
if (_address is not { } addr) return;
_profile.Buttons.Remove(addr);
_loading = true;
_kindBox.SelectedItem = RioRouteKind.Keyboard.ToString();
PopulateValues(RioRouteKind.Keyboard);
if (_valueCombo.Items.Count > 0) _valueCombo.SelectedIndex = 0;
_shift.Checked = _ctrl.Checked = _alt.Checked = _ext.Checked = _lit.Checked = false;
_loading = false;
_canvas.Refresh(addr);
}
private void SetEditingEnabled(bool enabled)
{
foreach (Control c in new Control[] { _labelBox, _kindBox, _valueCombo, _shift, _ctrl, _alt, _ext, _lit, _apply, _unassign })
c.Enabled = enabled;
if (enabled) UpdateModifierAvailability();
}
// Read a [JoyStick] setting from the profile's calibration (drives the cell shade).
private bool ReadCalibration(JoyStickSetting s) => s switch
{
JoyStickSetting.InvertX => _profile.Calibration.InvertX,
JoyStickSetting.InvertY => _profile.Calibration.InvertY,
JoyStickSetting.InvertZ => _profile.Calibration.InvertZ,
JoyStickSetting.InvertRx => _profile.Calibration.InvertXR,
JoyStickSetting.InvertRy => _profile.Calibration.InvertYR,
JoyStickSetting.InvertRz => _profile.Calibration.InvertZR,
JoyStickSetting.EnableZR => _profile.Calibration.EnableZR,
_ => false,
};
// Flip a [JoyStick] setting in place; persisted with the profile on Save.
private void ToggleCalibration(JoyStickSetting s)
{
var c = _profile.Calibration;
_profile.Calibration = s switch
{
JoyStickSetting.InvertX => c with { InvertX = !c.InvertX },
JoyStickSetting.InvertY => c with { InvertY = !c.InvertY },
JoyStickSetting.InvertZ => c with { InvertZ = !c.InvertZ },
JoyStickSetting.InvertRx => c with { InvertXR = !c.InvertXR },
JoyStickSetting.InvertRy => c with { InvertYR = !c.InvertYR },
JoyStickSetting.InvertRz => c with { InvertZR = !c.InvertZR },
JoyStickSetting.EnableZR => c with { EnableZR = !c.EnableZR },
_ => c,
};
}
private static string RegionFor(int address) => $"b-{address:X2}";
/// Select an address programmatically (for tests / offline preview).
public void SelectAddress(int address)
{
_canvas.Select(address);
OnAddressSelected(address);
}
///
/// Show live RIO button activity on the panel (called from the serial read thread).
/// Lets the user press a physical button and see which cell it is without any
/// keystrokes being injected.
///
public void ShowLiveActivity(int address, bool pressed) =>
RunOnUi(() => _canvas.SetLivePressed(address, pressed));
/// Show the firmware version from a RIO version reply (serial thread).
public void ShowVersion(VersionInfo version) =>
RunOnUi(() => { _version = version; RenderStatus(); });
/// Append a board/lamp status item from a RIO check reply (serial thread).
public void AddCheckStatus(CheckStatus status) =>
RunOnUi(() => { _checks.Add(status); RenderStatus(); });
// Marshal an action onto the UI thread, tolerating a closing/disposed form.
private void RunOnUi(Action action)
{
if (IsDisposed)
return;
if (InvokeRequired)
{
try { BeginInvoke(action); }
catch (ObjectDisposedException) { }
catch (InvalidOperationException) { } // handle not created / form closing
return;
}
action();
}
// Render the accumulated version + check reply into the status box.
private void RenderStatus()
{
var sb = new StringBuilder();
sb.AppendLine(_version is { } v ? $"Firmware: {v}" : "Firmware: (awaiting reply…)");
if (_checks.Count == 0)
{
sb.Append("Status: (awaiting reply…)");
}
else
{
foreach (IGrouping group in _checks.GroupBy(c => c.Type))
sb.AppendLine($"{Friendly(group.Key)}: {string.Join(", ", group.Select(c => c.Number))}");
}
_statusBox.Text = sb.ToString().TrimEnd();
}
private static string Friendly(RioStatusType type) => type switch
{
RioStatusType.BoardOk => "Boards OK",
RioStatusType.BoardMissing => "Boards missing",
RioStatusType.BoardBad => "Boards bad",
RioStatusType.LampBad => "Lamps bad",
RioStatusType.RestartCount => "Restart count",
RioStatusType.AbandonCount => "Abandon count",
RioStatusType.FullBufferCount => "Full-buffer count",
_ => type.ToString(),
};
}