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(), }; }