diff --git a/src/RioJoy.Core/Editing/CockpitPanel.cs b/src/RioJoy.Core/Editing/CockpitPanel.cs index 02fcddf..e3a89f7 100644 --- a/src/RioJoy.Core/Editing/CockpitPanel.cs +++ b/src/RioJoy.Core/Editing/CockpitPanel.cs @@ -70,23 +70,23 @@ public static class CockpitPanel { // Upper MFD row. new PanelGroup("Upper Left MFD", PanelGroupKind.Mfd, 4, 2, true, 0, 1, Mfd(0x2F)), - new PanelGroup("Upper Middle MFD", PanelGroupKind.Mfd, 4, 2, true, 5, 1, Mfd(0x27)), - new PanelGroup("Upper Right MFD", PanelGroupKind.Mfd, 4, 2, true, 10, 1, Mfd(0x37)), + new PanelGroup("Upper Middle MFD", PanelGroupKind.Mfd, 4, 2, true, 4, 1, Mfd(0x27)), + new PanelGroup("Upper Right MFD", PanelGroupKind.Mfd, 4, 2, true, 8, 1, Mfd(0x37)), // Lower MFD row. new PanelGroup("Lower Left MFD", PanelGroupKind.Mfd, 4, 2, true, 0, 5, Mfd(0x0F)), - new PanelGroup("Lower Right MFD", PanelGroupKind.Mfd, 4, 2, true, 10, 5, Mfd(0x07)), + new PanelGroup("Lower Right MFD", PanelGroupKind.Mfd, 4, 2, true, 8, 5, Mfd(0x07)), // Board columns. new PanelGroup("Throttle", PanelGroupKind.Column, 1, 8, true, 0, 9, Col8(0x38)), - new PanelGroup("Secondary", PanelGroupKind.Column, 1, 8, true, 5, 9, Col8(0x10)), - new PanelGroup("Screen", PanelGroupKind.Column, 1, 8, true, 7, 9, Col8(0x18)), - new PanelGroup("Joystick / Hat", PanelGroupKind.Column, 1, 8, true, 13, 9, Col8(0x40)), + new PanelGroup("Secondary", PanelGroupKind.Column, 1, 8, true, 4, 9, Col8(0x10)), + new PanelGroup("Screen", PanelGroupKind.Column, 1, 8, true, 6, 9, Col8(0x18)), + new PanelGroup("Joystick", PanelGroupKind.Column, 1, 8, true, 11, 9, Col8(0x40)), // Keypads (no lamps): internal in the middle (below Upper Middle MFD, above // the Secondary/Screen columns); external between those columns and Joystick. - new PanelGroup("Internal Keypad", PanelGroupKind.Keypad, 4, 4, false, 5, 4, Keypad(0x50)), - new PanelGroup("External Keypad", PanelGroupKind.Keypad, 4, 4, false, 9, 9, Keypad(0x60)), + new PanelGroup("Internal Keypad", PanelGroupKind.Keypad, 4, 4, false, 4, 4, Keypad(0x50)), + new PanelGroup("External Keypad", PanelGroupKind.Keypad, 4, 4, false, 7, 9, Keypad(0x60)), }; /// Flatten the groups to positioned s (absolute cells). diff --git a/src/RioJoy.Core/Mapping/NullInputSink.cs b/src/RioJoy.Core/Mapping/NullInputSink.cs new file mode 100644 index 0000000..d44c99d --- /dev/null +++ b/src/RioJoy.Core/Mapping/NullInputSink.cs @@ -0,0 +1,17 @@ +namespace RioJoy.Core.Mapping; + +/// +/// A no-op : swallows all keyboard/mouse output. Used while +/// a profile is being edited so the RIO can be exercised to check button function +/// without injecting keystrokes/clicks into the OS (lamp feedback still applies). +/// +public sealed class NullInputSink : IInputSink +{ + public void KeyDown(byte virtualKey, bool extended) { } + + public void KeyUp(byte virtualKey, bool extended) { } + + public void MouseMove(int dx, int dy) { } + + public void MouseButton(MouseButton button, bool down) { } +} diff --git a/src/RioJoy.Core/Profiles/AutoSwitchWatcher.cs b/src/RioJoy.Core/Profiles/AutoSwitchWatcher.cs index 9287516..a37c96c 100644 --- a/src/RioJoy.Core/Profiles/AutoSwitchWatcher.cs +++ b/src/RioJoy.Core/Profiles/AutoSwitchWatcher.cs @@ -31,6 +31,13 @@ public sealed class AutoSwitchWatcher /// The most recently resolved decision (null until the first ). public SwitchDecision? Current => _last; + /// + /// Forget the last decision so the next re-raises + /// even if the resolved decision is unchanged. Used + /// after a manual/editor override to re-sync the coordinator with the foreground app. + /// + public void Reset() => _last = null; + /// Resolve once; raise if it changed. Returns the decision. public SwitchDecision Poll() { diff --git a/src/RioJoy.Core/RioRuntime.cs b/src/RioJoy.Core/RioRuntime.cs index 61055ce..ae8e388 100644 --- a/src/RioJoy.Core/RioRuntime.cs +++ b/src/RioJoy.Core/RioRuntime.cs @@ -40,6 +40,13 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable /// Raised when a diagnostic toggle RIO command fires (raw-axes / poll-rate). public event Action? DiagnosticToggle; + /// + /// Raised for every decoded button/keypad event (RIO address, true on press + /// and false on release) — independent of how the entry is routed, so the + /// editor can show live button activity even with no-op output sinks. + /// + public event Action? ButtonActivity; + /// Subscribe to the link and set all lamps to their idle state. public void Start() { @@ -85,20 +92,31 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable switch (packet.Command) { case RioCommand.ButtonPressed when p[0] < RioAddress.ButtonCount: - _router.Press(RioAddress.FromButton(p[0])); + Activity(RioAddress.FromButton(p[0]), pressed: true); break; case RioCommand.ButtonReleased when p[0] < RioAddress.ButtonCount: - _router.Release(RioAddress.FromButton(p[0])); + Activity(RioAddress.FromButton(p[0]), pressed: false); break; case RioCommand.KeyPressed when IsKeypad(p): - _router.Press(RioAddress.FromKeypad(p[0], p[1])); + Activity(RioAddress.FromKeypad(p[0], p[1]), pressed: true); break; case RioCommand.KeyReleased when IsKeypad(p): - _router.Release(RioAddress.FromKeypad(p[0], p[1])); + Activity(RioAddress.FromKeypad(p[0], p[1]), pressed: false); break; } } + // Route the event through the input pipeline and notify any live-activity listener. + private void Activity(int address, bool pressed) + { + if (pressed) + _router.Press(address); + else + _router.Release(address); + + ButtonActivity?.Invoke(address, pressed); + } + private static bool IsKeypad(ReadOnlySpan p) => p[0] is 0 or 1 && p[1] <= 0x0F; // IRioCommandSink: RIO commands routed from a button (RIOcmd port). diff --git a/src/RioJoy.Tray/Editor/PanelCanvas.cs b/src/RioJoy.Tray/Editor/PanelCanvas.cs index 774a877..4ea6c45 100644 --- a/src/RioJoy.Tray/Editor/PanelCanvas.cs +++ b/src/RioJoy.Tray/Editor/PanelCanvas.cs @@ -29,10 +29,25 @@ internal sealed class PanelCanvas : Control /// Whether an address's lamp is lit (the binding's IsLit flag) — drives the lamp shade. public Func? LitProvider { get; set; } + /// Resolves a [JoyStick] setting's current on/off value. + public Func? CalibrationProvider { get; set; } + + private readonly HashSet _livePressed = new(); + public int? SelectedAddress { get; private set; } public event Action? AddressSelected; + /// Mark a RIO address as live-pressed (or released) for the on-panel indicator. + public void SetLivePressed(int address, bool pressed) + { + if (pressed ? _livePressed.Add(address) : _livePressed.Remove(address)) + Invalidate(); + } + + /// Raised when a [JoyStick] toggle cell is clicked. + public event Action? CalibrationToggled; + public void Select(int? address) { SelectedAddress = address; @@ -45,17 +60,29 @@ internal sealed class PanelCanvas : Control Invalidate(); } - protected override void OnPaint(PaintEventArgs e) => + protected override void OnPaint(PaintEventArgs e) + { PanelView.Paint( e.Graphics, AllButtons, CockpitPanel.Groups, a => LabelProvider?.Invoke(a), a => FunctionProvider?.Invoke(a), a => LitProvider?.Invoke(a) ?? false, SelectedAddress); + PanelView.PaintCalibration(e.Graphics, s => CalibrationProvider?.Invoke(s) ?? false); + PanelView.PaintLivePressed(e.Graphics, AllButtons, _livePressed); + } protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); + + if (PanelView.HitTestCalibration(e.Location) is { } cal) + { + CalibrationToggled?.Invoke(cal.Setting); + Invalidate(); + return; + } + PanelButton? hit = PanelView.HitTest(AllButtons, e.Location); if (hit is not null) { diff --git a/src/RioJoy.Tray/Editor/PanelView.cs b/src/RioJoy.Tray/Editor/PanelView.cs index d5fd8c0..9bd6ff8 100644 --- a/src/RioJoy.Tray/Editor/PanelView.cs +++ b/src/RioJoy.Tray/Editor/PanelView.cs @@ -3,6 +3,24 @@ using RioJoy.Core.Editing; namespace RioJoy.Tray.Editor; +/// +/// A boolean from the legacy [JoyStick] ini section, surfaced as a clickable +/// cell on the panel (mapped to ). +/// +public enum JoyStickSetting +{ + InvertX, + InvertY, + InvertZ, + InvertRx, + InvertRy, + InvertRz, + EnableZR, +} + +/// One [JoyStick] toggle cell placed on the panel (absolute cells). +public sealed record CalibrationCell(JoyStickSetting Setting, string Label, int Col, int Row); + /// /// Draws the cockpit control panel — the functional groups from /// (MFD clusters, board columns, keypads) plus the @@ -15,7 +33,24 @@ public static class PanelView { public const int CellW = 66; public const int CellH = 34; - public const int TopStrip = 92; // encoder-gauge band height + public const int TopStrip = 108; // encoder-gauge band height (fits the U + Rz below it) + + // The [JoyStick] ini section as cells, stacked vertically in the gap between the + // Throttle column (col 0) and the Secondary column (col 4): a single column at + // col 2, rows 10–16, under an "Axis" title (row 9). + public const int CalOriginCol = 2; + public const int CalOriginRow = 9; + + public static IReadOnlyList CalibrationCells { get; } = new[] + { + new CalibrationCell(JoyStickSetting.InvertX, "Inv X", 2, 10), + new CalibrationCell(JoyStickSetting.InvertY, "Inv Y", 2, 11), + new CalibrationCell(JoyStickSetting.InvertZ, "Inv Z", 2, 12), + new CalibrationCell(JoyStickSetting.InvertRx, "Inv Rx", 2, 13), + new CalibrationCell(JoyStickSetting.InvertRy, "Inv Ry", 2, 14), + new CalibrationCell(JoyStickSetting.InvertRz, "Inv Rz", 2, 15), + new CalibrationCell(JoyStickSetting.EnableZR, "ZR mix", 2, 16), + }; public static Size GridSize(IReadOnlyList buttons) { @@ -25,7 +60,9 @@ public static class PanelView if (b.Col > maxCol) maxCol = b.Col; if (b.Row > maxRow) maxRow = b.Row; } - return new Size((maxCol + 2) * CellW, TopStrip + (maxRow + 2) * CellH); + // Fit snugly: the rightmost group frame ends just past (maxCol + 1) * CellW, + // so a few px of border avoids a full empty trailing column. + return new Size((maxCol + 1) * CellW + 6, TopStrip + (maxRow + 2) * CellH); } private static Rectangle Cell(int col, int row) => @@ -72,14 +109,18 @@ public static class PanelView string? fn = function(b.Address); bool assigned = !string.IsNullOrEmpty(fn); + bool yellow = b.Group.Title is "Secondary" or "Screen"; Color fill = !b.LampCapable ? Color.FromArgb(150, 182, 226) // keypad — neutral blue - : lit(b.Address) - ? Color.FromArgb(215, 60, 60) // lamp ON (IsLit checked) - : Color.FromArgb(86, 48, 48); // lamp OFF + : yellow + ? (lit(b.Address) ? Color.FromArgb(240, 205, 50) // Secondary/Screen — yellow lamp + : Color.FromArgb(120, 102, 30)) // Secondary/Screen — dim yellow + : lit(b.Address) + ? Color.FromArgb(215, 60, 60) // lamp ON (IsLit checked) + : Color.FromArgb(86, 48, 48); // lamp OFF using (var fb = new SolidBrush(fill)) g.FillRectangle(fb, r); - Color fg = b.LampCapable ? Color.White : Color.Black; + Color fg = (b.LampCapable && !yellow) ? Color.White : Color.Black; bool hasLab = !string.IsNullOrEmpty(lab); if (hasLab && assigned) @@ -108,20 +149,29 @@ public static class PanelView using var pen = new Pen(Color.FromArgb(120, 160, 120)); using var gauge = new SolidBrush(Color.FromArgb(20, 40, 20)); - (string Label, int X, int W)[] boxes = + // Centered over the Upper Middle MFD block and arranged to match the physical + // cockpit: Z on the left and the X/Y stick graph on the right (both full + // height). The L & R pedals and the Rz (mixed-rudder) gauge form a big "U" in + // the center — L and R the full-height arms, Rz the bar joining them. + const int top = 6; + const int fullH = 68; // Z, X/Y and the U's arms (full height) + const int bar = 30; // stroke thickness, matching Z's width / Rz bar + int baseX = 6 * CellW - 100; // cluster is 200 wide; 6*CellW centers the MFD + int uX = baseX + 34, uW = 82; + (string Label, Rectangle Box)[] boxes = { - ("X / Y", 4, 80), - ("Z", 92, 26), - ("L", 122, 26), - ("R", 152, 26), + ("Z", new Rectangle(baseX, top, bar, fullH)), + ("L", new Rectangle(uX, top, bar, fullH)), // left arm of the U + ("R", new Rectangle(uX + uW - bar, top, bar, fullH)), // right arm of the U + ("Rz", new Rectangle(uX, top + fullH, uW, bar)), // bottom bar, below L & R (no overlap) + ("X / Y", new Rectangle(baseX + 120, top, 80, fullH)), }; - foreach (var (lbl, x, w) in boxes) + foreach (var (lbl, box) in boxes) { - var r = new Rectangle(x, 6, w, TopStrip - 24); - g.FillRectangle(gauge, r); - g.DrawRectangle(pen, r); - TextRenderer.DrawText(g, lbl, font, new Rectangle(x, TopStrip - 18, w, 16), Color.Gainsboro, - TextFormatFlags.HorizontalCenter); + g.FillRectangle(gauge, box); + g.DrawRectangle(pen, box); + TextRenderer.DrawText(g, lbl, font, box, Color.Gainsboro, + TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter); } } @@ -132,4 +182,62 @@ public static class PanelView return b; return null; } + + /// + /// Draw the [JoyStick] toggle block. reports each + /// setting's current value; "on" cells render green (active invert / ZR mix). + /// + public static void PaintCalibration(Graphics g, Func state) + { + using var titleFont = new Font("Segoe UI", 8f, FontStyle.Bold); + using var cellFont = new Font("Segoe UI", 7.5f); + using var framePen = new Pen(Color.FromArgb(80, 80, 80)); + + var frame = new Rectangle( + CalOriginCol * CellW + 1, + TopStrip + CalOriginRow * CellH + 1, + CellW, + (CalibrationCells.Count + 1) * CellH); // title row + one cell row each + g.DrawRectangle(framePen, frame); + TextRenderer.DrawText(g, "Axis", titleFont, + new Rectangle(frame.X + 2, frame.Y, frame.Width - 2, CellH), Color.Gainsboro, + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis); + + const TextFormatFlags fmt = TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | + TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix; + foreach (CalibrationCell c in CalibrationCells) + { + Rectangle r = Cell(c.Col, c.Row); + bool on = state(c.Setting); + Color fill = on ? Color.FromArgb(60, 150, 80) : Color.FromArgb(58, 64, 72); // green on / slate off + using (var fb = new SolidBrush(fill)) g.FillRectangle(fb, r); + TextRenderer.DrawText(g, $"{c.Label}\n{(on ? "ON" : "off")}", cellFont, r, Color.White, fmt); + } + } + + /// + /// Outline the buttons currently held down on the live RIO (the editor's + /// check-button-function feedback), drawn over the normal cells. + /// + public static void PaintLivePressed(Graphics g, IReadOnlyList buttons, ISet pressed) + { + if (pressed.Count == 0) + return; + using var pen = new Pen(Color.FromArgb(90, 220, 255), 3f); // cyan = live press + foreach (PanelButton b in buttons) + if (pressed.Contains(b.Address)) + { + Rectangle r = Cell(b.Col, b.Row); + g.DrawRectangle(pen, r.X, r.Y, r.Width - 1, r.Height - 1); + } + } + + /// Hit-test the [JoyStick] toggle cells; null if the point misses. + public static CalibrationCell? HitTestCalibration(Point p) + { + foreach (CalibrationCell c in CalibrationCells) + if (Cell(c.Col, c.Row).Contains(p)) + return c; + return null; + } } diff --git a/src/RioJoy.Tray/Editor/ProfileEditorForm.cs b/src/RioJoy.Tray/Editor/ProfileEditorForm.cs index 3d65fb7..0e78907 100644 --- a/src/RioJoy.Tray/Editor/ProfileEditorForm.cs +++ b/src/RioJoy.Tray/Editor/ProfileEditorForm.cs @@ -45,6 +45,22 @@ public sealed class ProfileEditorForm : Form private int? _address; private bool _loading; + // 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), + ("Toggle raw-axes readout", RioCommandCode.ToggleRawAxesReadout), + ("Toggle poll-rate readout", RioCommandCode.TogglePollRateReadout), + }; + /// A pick-list entry: what the user sees and the byte it maps to. private sealed record ValueItem(string Display, byte Value) { @@ -54,6 +70,9 @@ public sealed class ProfileEditorForm : Form /// Raised when the user saves; the argument is the edited profile. public event Action? Saved; + /// Raised when the user clicks one of the live RIO-command buttons. + public event Action? CommandRequested; + public ProfileEditorForm(RioProfile profile) { _profile = profile ?? throw new ArgumentNullException(nameof(profile)); @@ -69,6 +88,8 @@ public sealed class ProfileEditorForm : Form _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) }; @@ -93,7 +114,7 @@ public sealed class ProfileEditorForm : Form private Panel BuildEditPanel() { - var panel = new Panel { Dock = DockStyle.Right, Width = 330, Padding = new Padding(8), BorderStyle = BorderStyle.FixedSingle }; + var panel = new Panel { Dock = DockStyle.Right, Width = 330, Padding = new Padding(8), BorderStyle = BorderStyle.FixedSingle, AutoScroll = true }; panel.Controls.Add(_info); panel.Controls.Add(new Label { Text = "Label:", Location = new Point(12, 47), AutoSize = true }); @@ -103,10 +124,29 @@ public sealed class ProfileEditorForm : Form panel.Controls.Add(_valueLabel); panel.Controls.Add(_valueCombo); panel.Controls.AddRange(new Control[] { _shift, _ctrl, _alt, _ext, _lit, _apply, _unassign, _save, _close }); + panel.Controls.Add(BuildCommandGroup()); 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, 278), Size = new Size(306, 330) }; + + 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 += (_, _) => CommandRequested?.Invoke(captured); + group.Controls.Add(button); + y += 29; + } + + return group; + } + private void OnAddressSelected(int addr) { _address = addr; @@ -263,6 +303,36 @@ public sealed class ProfileEditorForm : Form 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). @@ -271,4 +341,23 @@ public sealed class ProfileEditorForm : Form _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) + { + if (IsDisposed) + return; + if (InvokeRequired) + { + try { BeginInvoke(() => ShowLiveActivity(address, pressed)); } + catch (ObjectDisposedException) { } + catch (InvalidOperationException) { } // handle not created / form closing + return; + } + _canvas.SetLivePressed(address, pressed); + } } diff --git a/src/RioJoy.Tray/RioCoordinator.cs b/src/RioJoy.Tray/RioCoordinator.cs index a6e4613..3420040 100644 --- a/src/RioJoy.Tray/RioCoordinator.cs +++ b/src/RioJoy.Tray/RioCoordinator.cs @@ -16,6 +16,11 @@ namespace RioJoy.Tray; /// active profile, releasing the COM port when a native game runs or nothing /// matches (see docs/PLAN.md §Profiles). Manual override from the tray takes /// precedence over the watcher. +/// +/// The coordinator starts dormant and only takes the serial port when a +/// profiled game is in the foreground (via the watcher) or while a profile is being +/// edited (). This keeps the COM port free the rest +/// of the time so the native games can open it without clashing. /// [SupportedOSPlatform("windows")] public sealed class RioCoordinator : IDisposable @@ -24,6 +29,7 @@ public sealed class RioCoordinator : IDisposable private readonly Func _transportFactory; private bool _auto = true; + private bool _editorSession; // The active connection (null when dormant). private IRioTransport? _transport; @@ -43,7 +49,10 @@ public sealed class RioCoordinator : IDisposable public event Action? StatusChanged; /// Current status line for the tray. - public string Status { get; private set; } = "Starting…"; + public string Status { get; private set; } = "Dormant"; + + /// Whether a profile is currently being edited (port held for the editor). + public bool IsEditing => _editorSession; /// The name of the active profile, or null when dormant. public string? ActiveProfileName => _activeProfileName; @@ -95,10 +104,32 @@ public sealed class RioCoordinator : IDisposable GoDormant("Dormant (manual)"); } - private void Activate(RioProfile profile) + /// + /// Acquire the RIO for while its editor is open. The + /// editor takes precedence over the watcher (so the port stays put while editing); + /// call when the editor closes. + /// + public void BeginEditorSession(RioProfile profile) { - if (_activeProfileName == profile.Name && _runtime is not null) - return; // already running this profile + _editorSession = true; + _auto = false; // editor override beats the auto-switch watcher + Activate(profile, routeInput: false); // hold the port but don't inject keystrokes/joystick + } + + /// Release the editor's port hold and hand control back to the watcher. + public void EndEditorSession() + { + _editorSession = false; + _auto = true; + GoDormant("Dormant"); // the next watcher poll re-activates if a profiled game is foreground + } + + // routeInput=false holds the RIO link (and lamp feedback) but sends keyboard/ + // mouse/joystick output to no-op sinks — used for the editor's live button check. + private void Activate(RioProfile profile, bool routeInput = true) + { + if (_activeProfileName == profile.Name && _runtime is not null && !_editorSession) + return; // already running this profile (editor sessions always re-arm) Teardown(); @@ -107,21 +138,33 @@ public sealed class RioCoordinator : IDisposable try { - // Use the real virtual-joystick driver if installed; otherwise fall back - // to a no-op sink (keyboard/mouse still work) and note it in the status. + // Keyboard/mouse + joystick are suppressed while editing so the RIO can be + // exercised without injecting input; lamp feedback over serial still applies. + IInputSink input; IJoystickSink joystick; - string joystickNote; - if (HidFeederJoystickSink.TryCreate(out HidFeederJoystickSink? feeder)) + string note; + if (!routeInput) { + input = new NullInputSink(); + joystick = new NullJoystickSink(); + _joystick = null; + note = " [editing — input suppressed]"; + } + else if (HidFeederJoystickSink.TryCreate(out HidFeederJoystickSink? feeder)) + { + // Use the real virtual-joystick driver if installed. + input = new SendInputSink(); joystick = feeder!; _joystick = feeder; - joystickNote = string.Empty; + note = string.Empty; } else { + // Otherwise fall back to a no-op joystick (keyboard/mouse still work). + input = new SendInputSink(); joystick = new NullJoystickSink(); _joystick = null; - joystickNote = " [no joystick driver]"; + note = " [no joystick driver]"; } _transport = _transportFactory(port); @@ -129,7 +172,7 @@ public sealed class RioCoordinator : IDisposable _runtime = new RioRuntime( _link, profile.ToInputMap(), - new SendInputSink(), + input, joystick, new AxisCalibrator(profile.Calibration)); @@ -137,7 +180,7 @@ public sealed class RioCoordinator : IDisposable _ = _link.RunAsync(_cts.Token); _runtime.Start(); _activeProfileName = profile.Name; - SetStatus($"Active: {profile.Name} ({_link.Description}){joystickNote}"); + SetStatus($"{(routeInput ? "Active" : "Editing")}: {profile.Name} ({_link.Description}){note}"); } catch (Exception ex) { @@ -146,7 +189,8 @@ public sealed class RioCoordinator : IDisposable return; } - ApplyWallpaper(profile); + if (routeInput) + ApplyWallpaper(profile); } /// diff --git a/src/RioJoy.Tray/TrayApplicationContext.cs b/src/RioJoy.Tray/TrayApplicationContext.cs index 49de9d7..204feb7 100644 --- a/src/RioJoy.Tray/TrayApplicationContext.cs +++ b/src/RioJoy.Tray/TrayApplicationContext.cs @@ -1,4 +1,5 @@ using System.Runtime.Versioning; +using RioJoy.Core; using RioJoy.Core.Mapping; using RioJoy.Core.Profiles; using RioJoy.Tray.Editor; @@ -69,6 +70,7 @@ internal sealed class TrayApplicationContext : ApplicationContext menu.Items.Add(profileMenu); menu.Items.Add("Edit profile…", null, (_, _) => OpenEditor()); + menu.Items.Add("Import .ini…", null, (_, _) => ImportIniProfiles()); // RIO commands mirroring the legacy console menu. var commands = new ToolStripMenuItem("RIO commands"); @@ -138,8 +140,31 @@ internal sealed class TrayApplicationContext : ApplicationContext // a profile to edit. RioProfile profile = _config.Profiles.FirstOrDefault() ?? AddBlankProfile(); + // 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.Saved += _ => ConfigStore.Save(_config, ConfigPath); + editor.CommandRequested += cmd => _coordinator.Runtime?.Trigger(cmd); + + RioRuntime? runtime = _coordinator.Runtime; + Action? activity = null; + if (runtime is not null) + { + activity = editor.ShowLiveActivity; + runtime.ButtonActivity += activity; + } + + editor.FormClosed += (_, _) => + { + if (runtime is not null && activity is not null) + runtime.ButtonActivity -= activity; + _coordinator.EndEditorSession(); + _watcher.Reset(); // re-sync with the foreground app (may re-activate a game) + _watcher.Poll(); + }; editor.Show(); } @@ -150,6 +175,60 @@ internal sealed class TrayApplicationContext : ApplicationContext 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() + { + using var dialog = new OpenFileDialog + { + Title = "Import RIO .ini profile(s)", + Filter = "RIO INI files (*.ini)|*.ini|All files (*.*)|*.*", + Multiselect = true, + }; + if (dialog.ShowDialog() != DialogResult.OK) + return; + + var imported = new List(); + var failures = new List(); + foreach (string path in dialog.FileNames) + { + try + { + string name = UniqueProfileName(Path.GetFileNameWithoutExtension(path)); + RioProfile profile = RioIniImporter.Import(File.ReadAllText(path), name); + _config.Profiles.Add(profile); + imported.Add($"{name} ({profile.Buttons.Count} buttons mapped)"); + } + catch (Exception ex) + { + failures.Add($"{Path.GetFileName(path)}: {ex.Message}"); + } + } + + if (imported.Count > 0) + ConfigStore.Save(_config, ConfigPath); + + string summary = imported.Count > 0 + ? "Imported:\n " + string.Join("\n ", imported) + : "No profiles imported."; + if (failures.Count > 0) + summary += "\n\nFailed:\n " + string.Join("\n ", failures); + + MessageBox.Show(summary, "RIOJoy — Import .ini", + MessageBoxButtons.OK, + failures.Count > 0 ? MessageBoxIcon.Warning : MessageBoxIcon.Information); + } + + // Ensure the imported profile name is unique within the library. + private string UniqueProfileName(string desired) + { + string baseName = string.IsNullOrWhiteSpace(desired) ? "Imported" : desired.Trim(); + string candidate = baseName; + for (int i = 2; _config.FindProfile(candidate) is not null; i++) + candidate = $"{baseName} ({i})"; + return candidate; + } + private void AddCommand(ToolStripMenuItem parent, string text, RioCommandCode code) { var item = new ToolStripMenuItem(text); diff --git a/tests/RioJoy.Core.Tests/Editing/CockpitPanelTests.cs b/tests/RioJoy.Core.Tests/Editing/CockpitPanelTests.cs index 685ac1c..e4cdfa1 100644 --- a/tests/RioJoy.Core.Tests/Editing/CockpitPanelTests.cs +++ b/tests/RioJoy.Core.Tests/Editing/CockpitPanelTests.cs @@ -27,7 +27,7 @@ public class CockpitPanelTests [InlineData(0x38, "Throttle")] [InlineData(0x10, "Secondary")] [InlineData(0x18, "Screen")] - [InlineData(0x40, "Joystick / Hat")] + [InlineData(0x40, "Joystick")] [InlineData(0x50, "Internal Keypad")] [InlineData(0x6F, "External Keypad")] public void Address_LandsInExpectedGroup(int address, string group) diff --git a/tests/RioJoy.Core.Tests/Profiles/AutoSwitchTests.cs b/tests/RioJoy.Core.Tests/Profiles/AutoSwitchTests.cs index 1308be5..b6d4484 100644 --- a/tests/RioJoy.Core.Tests/Profiles/AutoSwitchTests.cs +++ b/tests/RioJoy.Core.Tests/Profiles/AutoSwitchTests.cs @@ -83,6 +83,25 @@ public class AutoSwitchTests Assert.Equal(SwitchMode.Yield, changes[1].Mode); } + [Fact] + public void Reset_ReRaises_SameDecision_OnNextPoll() + { + var config = Config(); + var provider = new FakeForeground(() => "doom.exe"); + var watcher = new AutoSwitchWatcher(provider, () => config); + + var changes = new List(); + watcher.DecisionChanged += changes.Add; + + watcher.Poll(); // doom → Activate (raise) + watcher.Poll(); // same → no raise + watcher.Reset(); // forget last decision (e.g. after an editor session) + watcher.Poll(); // same decision, but re-raised because of Reset + + Assert.Equal(2, changes.Count); + Assert.All(changes, c => Assert.Equal(SwitchMode.Activate, c.Mode)); + } + private sealed class FakeForeground(Func get) : IForegroundProcessProvider { public string? GetForegroundExecutable() => get(); diff --git a/tests/RioJoy.Core.Tests/RioRuntimeTests.cs b/tests/RioJoy.Core.Tests/RioRuntimeTests.cs index 00f4e4d..4380b95 100644 --- a/tests/RioJoy.Core.Tests/RioRuntimeTests.cs +++ b/tests/RioJoy.Core.Tests/RioRuntimeTests.cs @@ -72,6 +72,34 @@ public class RioRuntimeTests await run; } + [Fact] + public async Task ButtonActivity_FiresForPressAndRelease_EvenWhenUnmapped() + { + var fake = new FakeTransport(); + var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false }); + var recorder = new RecordingSink(); + + using var runtime = new RioRuntime(link, new RioInputMap(), recorder, recorder); // empty map + var activity = new List<(int Address, bool Pressed)>(); + runtime.ButtonActivity += (addr, pressed) => activity.Add((addr, pressed)); + runtime.Start(); + + using var cts = new CancellationTokenSource(); + Task run = link.RunAsync(cts.Token); + + fake.Enqueue(PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x05 })); + fake.Enqueue(PacketBuilder.Build(RioCommand.ButtonReleased, new byte[] { 0x05 })); + + await WaitUntilAsync(() => activity.Count == 2); + + Assert.Equal((0x05, true), activity[0]); + Assert.Equal((0x05, false), activity[1]); + Assert.Empty(recorder.Snapshot()); // unmapped → no output routed, but activity still fired + + cts.Cancel(); + await run; + } + [Fact] public async Task KeypadKey_RoutesThroughOffsetAddress() {