Editor cockpit rework + dormant-by-default serial port use
Profile editor: - Cockpit-style encoder gauges centered over the Upper Middle MFD: Z left, X/Y right, and L/R/Rz arranged as a big "U". - [JoyStick] section surfaced as a vertical "Axis" toggle column (6 inverts + ZR mix), written back to the profile's calibration. - Tighten the panel layout (close gaps, trim empty border columns); colour the Secondary/Screen columns yellow; rename the "Joystick / Hat" board to "Joystick"; add an "Import .ini..." tray entry. - "RIO commands (live)" button group (all RioCommandCodes) fired at the live RIO. Serial-port handling: - Start dormant; only take the COM port for a profiled game (auto-switch) or while editing, so the native games can open the port without clashing. - Editor sessions hold the port but route keyboard/mouse/joystick to no-op sinks (NullInputSink), so button function can be checked without injecting input; RioRuntime.ButtonActivity drives a live cyan press indicator and lamp feedback still applies. - AutoSwitchWatcher.Reset() re-syncs the watcher after an editor session. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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)),
|
||||
};
|
||||
|
||||
/// <summary>Flatten the groups to positioned <see cref="PanelButton"/>s (absolute cells).</summary>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace RioJoy.Core.Mapping;
|
||||
|
||||
/// <summary>
|
||||
/// A no-op <see cref="IInputSink"/>: 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).
|
||||
/// </summary>
|
||||
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) { }
|
||||
}
|
||||
@@ -31,6 +31,13 @@ public sealed class AutoSwitchWatcher
|
||||
/// <summary>The most recently resolved decision (null until the first <see cref="Poll"/>).</summary>
|
||||
public SwitchDecision? Current => _last;
|
||||
|
||||
/// <summary>
|
||||
/// Forget the last decision so the next <see cref="Poll"/> re-raises
|
||||
/// <see cref="DecisionChanged"/> even if the resolved decision is unchanged. Used
|
||||
/// after a manual/editor override to re-sync the coordinator with the foreground app.
|
||||
/// </summary>
|
||||
public void Reset() => _last = null;
|
||||
|
||||
/// <summary>Resolve once; raise <see cref="DecisionChanged"/> if it changed. Returns the decision.</summary>
|
||||
public SwitchDecision Poll()
|
||||
{
|
||||
|
||||
@@ -40,6 +40,13 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
|
||||
/// <summary>Raised when a diagnostic toggle RIO command fires (raw-axes / poll-rate).</summary>
|
||||
public event Action<RioCommandCode>? DiagnosticToggle;
|
||||
|
||||
/// <summary>
|
||||
/// Raised for every decoded button/keypad event (RIO address, <c>true</c> on press
|
||||
/// and <c>false</c> on release) — independent of how the entry is routed, so the
|
||||
/// editor can show live button activity even with no-op output sinks.
|
||||
/// </summary>
|
||||
public event Action<int, bool>? ButtonActivity;
|
||||
|
||||
/// <summary>Subscribe to the link and set all lamps to their idle state.</summary>
|
||||
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<byte> p) => p[0] is 0 or 1 && p[1] <= 0x0F;
|
||||
|
||||
// IRioCommandSink: RIO commands routed from a button (RIOcmd port).
|
||||
|
||||
@@ -29,10 +29,25 @@ internal sealed class PanelCanvas : Control
|
||||
/// <summary>Whether an address's lamp is lit (the binding's IsLit flag) — drives the lamp shade.</summary>
|
||||
public Func<int, bool>? LitProvider { get; set; }
|
||||
|
||||
/// <summary>Resolves a <c>[JoyStick]</c> setting's current on/off value.</summary>
|
||||
public Func<JoyStickSetting, bool>? CalibrationProvider { get; set; }
|
||||
|
||||
private readonly HashSet<int> _livePressed = new();
|
||||
|
||||
public int? SelectedAddress { get; private set; }
|
||||
|
||||
public event Action<int>? AddressSelected;
|
||||
|
||||
/// <summary>Mark a RIO address as live-pressed (or released) for the on-panel indicator.</summary>
|
||||
public void SetLivePressed(int address, bool pressed)
|
||||
{
|
||||
if (pressed ? _livePressed.Add(address) : _livePressed.Remove(address))
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>Raised when a <c>[JoyStick]</c> toggle cell is clicked.</summary>
|
||||
public event Action<JoyStickSetting>? 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)
|
||||
{
|
||||
|
||||
@@ -3,6 +3,24 @@ using RioJoy.Core.Editing;
|
||||
|
||||
namespace RioJoy.Tray.Editor;
|
||||
|
||||
/// <summary>
|
||||
/// A boolean from the legacy <c>[JoyStick]</c> ini section, surfaced as a clickable
|
||||
/// cell on the panel (mapped to <see cref="RioJoy.Core.Calibration.AxisCalibrationConfig"/>).
|
||||
/// </summary>
|
||||
public enum JoyStickSetting
|
||||
{
|
||||
InvertX,
|
||||
InvertY,
|
||||
InvertZ,
|
||||
InvertRx,
|
||||
InvertRy,
|
||||
InvertRz,
|
||||
EnableZR,
|
||||
}
|
||||
|
||||
/// <summary>One <c>[JoyStick]</c> toggle cell placed on the panel (absolute cells).</summary>
|
||||
public sealed record CalibrationCell(JoyStickSetting Setting, string Label, int Col, int Row);
|
||||
|
||||
/// <summary>
|
||||
/// Draws the cockpit control panel — the functional groups from
|
||||
/// <see cref="CockpitPanel"/> (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<CalibrationCell> 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<PanelButton> 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
|
||||
: 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draw the <c>[JoyStick]</c> toggle block. <paramref name="state"/> reports each
|
||||
/// setting's current value; "on" cells render green (active invert / ZR mix).
|
||||
/// </summary>
|
||||
public static void PaintCalibration(Graphics g, Func<JoyStickSetting, bool> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outline the buttons currently held down on the live RIO (the editor's
|
||||
/// check-button-function feedback), drawn over the normal cells.
|
||||
/// </summary>
|
||||
public static void PaintLivePressed(Graphics g, IReadOnlyList<PanelButton> buttons, ISet<int> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Hit-test the <c>[JoyStick]</c> toggle cells; null if the point misses.</summary>
|
||||
public static CalibrationCell? HitTestCalibration(Point p)
|
||||
{
|
||||
foreach (CalibrationCell c in CalibrationCells)
|
||||
if (Cell(c.Col, c.Row).Contains(p))
|
||||
return c;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
/// <summary>A pick-list entry: what the user sees and the byte it maps to.</summary>
|
||||
private sealed record ValueItem(string Display, byte Value)
|
||||
{
|
||||
@@ -54,6 +70,9 @@ public sealed class ProfileEditorForm : Form
|
||||
/// <summary>Raised when the user saves; the argument is the edited profile.</summary>
|
||||
public event Action<RioProfile>? Saved;
|
||||
|
||||
/// <summary>Raised when the user clicks one of the live RIO-command buttons.</summary>
|
||||
public event Action<RioCommandCode>? 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}";
|
||||
|
||||
/// <summary>Select an address programmatically (for tests / offline preview).</summary>
|
||||
@@ -271,4 +341,23 @@ public sealed class ProfileEditorForm : Form
|
||||
_canvas.Select(address);
|
||||
OnAddressSelected(address);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <b>dormant</b> and only takes the serial port when a
|
||||
/// profiled game is in the foreground (via the watcher) or while a profile is being
|
||||
/// edited (<see cref="BeginEditorSession"/>). This keeps the COM port free the rest
|
||||
/// of the time so the native games can open it without clashing.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class RioCoordinator : IDisposable
|
||||
@@ -24,6 +29,7 @@ public sealed class RioCoordinator : IDisposable
|
||||
private readonly Func<string, IRioTransport> _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<string>? StatusChanged;
|
||||
|
||||
/// <summary>Current status line for the tray.</summary>
|
||||
public string Status { get; private set; } = "Starting…";
|
||||
public string Status { get; private set; } = "Dormant";
|
||||
|
||||
/// <summary>Whether a profile is currently being edited (port held for the editor).</summary>
|
||||
public bool IsEditing => _editorSession;
|
||||
|
||||
/// <summary>The name of the active profile, or null when dormant.</summary>
|
||||
public string? ActiveProfileName => _activeProfileName;
|
||||
@@ -95,10 +104,32 @@ public sealed class RioCoordinator : IDisposable
|
||||
GoDormant("Dormant (manual)");
|
||||
}
|
||||
|
||||
private void Activate(RioProfile profile)
|
||||
/// <summary>
|
||||
/// Acquire the RIO for <paramref name="profile"/> while its editor is open. The
|
||||
/// editor takes precedence over the watcher (so the port stays put while editing);
|
||||
/// call <see cref="EndEditorSession"/> when the editor closes.
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
|
||||
/// <summary>Release the editor's port hold and hand control back to the watcher.</summary>
|
||||
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,6 +189,7 @@ public sealed class RioCoordinator : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
if (routeInput)
|
||||
ApplyWallpaper(profile);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<int, bool>? 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<string>();
|
||||
var failures = new List<string>();
|
||||
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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<SwitchDecision>();
|
||||
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<string?> get) : IForegroundProcessProvider
|
||||
{
|
||||
public string? GetForegroundExecutable() => get();
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user