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()