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