- TargetFrameworks net48;net40. net48 keeps x64 + ViGEm + System.IO.Ports package; net40 adds Microsoft.Bcl.Async + System.ValueTuple and uses the in-box SerialPort. - Compat/TaskCompat bridges Task.Run/Delay/WhenAny/WhenAll (TaskEx on net40) and SemaphoreSlim.WaitAsync (net40 blocks briefly - trivial at 9600 baud). - IReadOnlyList/IReadOnlyDictionary -> IList/IDictionary throughout (net40 predates the IReadOnly* interfaces and the Bcl backport cannot make arrays implement them). - HashCode.Combine replaced with a manual combine (Bcl.HashCode has no net40 build); Marshal.SizeOf<T> -> typeof form; ViGEmJoystickSink gated #if !NET40. HidFeederJoystickSink stays on both flavors - it will drive RioGamepadXP.sys on XP via the same contract. Both TFMs build; 275 tests green on net48. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
277 lines
13 KiB
C#
277 lines
13 KiB
C#
using RioJoy.Core.Calibration;
|
||
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
|
||
/// encoder-gauge strip — mirroring the original Win32 RIO design. Lamp buttons are
|
||
/// shaded by whether the address is assigned (Off vs Dim); keypads are neutral
|
||
/// (no lamps). Shared by <see cref="PanelCanvas"/> and offline rendering.
|
||
/// </summary>
|
||
public static class PanelView
|
||
{
|
||
public const int CellW = 66;
|
||
public const int CellH = 34;
|
||
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 IList<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(IList<PanelButton> buttons)
|
||
{
|
||
int maxCol = 0, maxRow = 0;
|
||
foreach (PanelButton b in buttons)
|
||
{
|
||
if (b.Col > maxCol) maxCol = b.Col;
|
||
if (b.Row > maxRow) maxRow = b.Row;
|
||
}
|
||
// 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) =>
|
||
new(col * CellW + 2, TopStrip + row * CellH + 2, CellW - 4, CellH - 4);
|
||
|
||
public static void Paint(
|
||
Graphics g,
|
||
IList<PanelButton> buttons,
|
||
IList<PanelGroup> groups,
|
||
Func<int, string?> label,
|
||
Func<int, string?> function,
|
||
Func<int, bool> lit,
|
||
Func<int, bool> live,
|
||
AxisReadout? axes,
|
||
int? selected)
|
||
{
|
||
g.Clear(Color.FromArgb(28, 28, 28));
|
||
using var titleFont = new Font("Segoe UI", 8f, FontStyle.Bold);
|
||
using var btnFont = new Font("Segoe UI", 7.5f);
|
||
using var subFont = new Font("Segoe UI", 6.75f);
|
||
using var groupPen = new Pen(Color.FromArgb(80, 80, 80));
|
||
|
||
DrawEncoderStrip(g, titleFont, axes);
|
||
|
||
// Group frames + titles.
|
||
foreach (PanelGroup grp in groups)
|
||
{
|
||
var frame = new Rectangle(
|
||
grp.OriginCol * CellW + 1,
|
||
TopStrip + grp.OriginRow * CellH + 1,
|
||
grp.Cols * CellW,
|
||
(grp.Rows + 1) * CellH);
|
||
g.DrawRectangle(groupPen, frame);
|
||
TextRenderer.DrawText(g, grp.Title, titleFont,
|
||
new Rectangle(frame.X + 2, frame.Y, frame.Width - 2, CellH), Color.Gainsboro,
|
||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
|
||
}
|
||
|
||
// Buttons: caption = label (top) + assigned function (bottom).
|
||
const TextFormatFlags oneLine = TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter |
|
||
TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix;
|
||
foreach (PanelButton b in buttons)
|
||
{
|
||
Rectangle r = Cell(b.Col, b.Row);
|
||
string? lab = label(b.Address);
|
||
string? fn = function(b.Address);
|
||
bool assigned = !string.IsNullOrEmpty(fn);
|
||
|
||
// Three lamp states in the cell's own colour: a physically held button is
|
||
// BRIGHT; a lamp configured on (IsLit) is DIM at idle (so the press still
|
||
// reads brighter); everything else is OFF.
|
||
bool yellow = b.Group.Title is "Secondary" or "Screen";
|
||
bool held = live(b.Address);
|
||
bool litCfg = lit(b.Address);
|
||
Color fill = !b.LampCapable
|
||
? (held ? Color.FromArgb(205, 228, 255) // keypad — bright blue when pressed
|
||
: Color.FromArgb(150, 182, 226)) // keypad — neutral blue
|
||
: yellow
|
||
? (held ? Color.FromArgb(245, 210, 60) // Secondary/Screen — bright
|
||
: litCfg ? Color.FromArgb(140, 118, 38) // dim (lamp on, idle)
|
||
: Color.FromArgb(70, 60, 24)) // off
|
||
: (held ? Color.FromArgb(230, 70, 70) // lamp — bright (held)
|
||
: litCfg ? Color.FromArgb(120, 50, 50) // dim (lamp on, idle)
|
||
: Color.FromArgb(64, 40, 40)); // off
|
||
using (var fb = new SolidBrush(fill)) g.FillRectangle(fb, r);
|
||
|
||
Color fg = (b.LampCapable && !yellow) ? Color.White : Color.Black;
|
||
bool hasLab = !string.IsNullOrEmpty(lab);
|
||
|
||
if (hasLab && assigned)
|
||
{
|
||
TextRenderer.DrawText(g, lab, btnFont, new Rectangle(r.X, r.Y + 1, r.Width, r.Height / 2), fg,
|
||
TextFormatFlags.HorizontalCenter | TextFormatFlags.Top | TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix);
|
||
TextRenderer.DrawText(g, fn, subFont, new Rectangle(r.X, r.Bottom - r.Height / 2 - 1, r.Width, r.Height / 2), fg,
|
||
TextFormatFlags.HorizontalCenter | TextFormatFlags.Bottom | TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix);
|
||
}
|
||
else
|
||
{
|
||
string single = hasLab ? lab! : assigned ? fn! : $"{b.Address:X2}";
|
||
TextRenderer.DrawText(g, single, btnFont, r, fg, oneLine);
|
||
}
|
||
|
||
if (b.Address == selected)
|
||
{
|
||
using var hi = new Pen(Color.Yellow, 2f);
|
||
g.DrawRectangle(hi, r.X, r.Y, r.Width - 1, r.Height - 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
private static void DrawEncoderStrip(Graphics g, Font font, AxisReadout? axes)
|
||
{
|
||
using var pen = new Pen(Color.FromArgb(120, 160, 120));
|
||
using var gauge = new SolidBrush(Color.FromArgb(20, 40, 20));
|
||
using var fill = new SolidBrush(Color.FromArgb(52, 128, 70));
|
||
|
||
// 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;
|
||
var zBox = new Rectangle(baseX, top, bar, fullH);
|
||
var lBox = new Rectangle(uX, top, bar, fullH); // left arm of the U
|
||
var rBox = new Rectangle(uX + uW - bar, top, bar, fullH); // right arm of the U
|
||
var rzBox = new Rectangle(uX, top + fullH, uW, bar); // bottom bar, below L & R (no overlap)
|
||
var xyBox = new Rectangle(baseX + 120, top, 80, fullH);
|
||
(string Label, Rectangle Box)[] boxes =
|
||
{
|
||
("Z", zBox), ("L", lBox), ("R", rBox), ("Rz", rzBox), ("X / Y", xyBox),
|
||
};
|
||
|
||
foreach (var (_, box) in boxes)
|
||
g.FillRectangle(gauge, box);
|
||
|
||
// Live values from the RIO's analog stream (null when the link is down).
|
||
// Z/Rz/X/Y show the virtual-joystick outputs; L and R show the calibrated
|
||
// pedal positions from before the ZR mix, which pins Rx/Ry to center — the
|
||
// pedal gauges must track the physical pedals either way.
|
||
if (axes is { } a)
|
||
{
|
||
// Z (throttle) and the L/R pedals fill bottom-up.
|
||
foreach (var (box, value) in new[] { (zBox, a.Axes.Z), (lBox, a.LeftPedal), (rBox, a.RightPedal) })
|
||
{
|
||
int h = (int)Math.Round(box.Height * AxisGauges.Fraction(value));
|
||
if (h > 0)
|
||
g.FillRectangle(fill, box.X, box.Bottom - h, box.Width, h);
|
||
}
|
||
|
||
// Rz (mixed rudder) deflects left/right from the bar's center.
|
||
double d = AxisGauges.Deflection(a.Axes.Rz);
|
||
int cx = rzBox.X + rzBox.Width / 2;
|
||
int w = (int)Math.Round(rzBox.Width / 2.0 * Math.Abs(d));
|
||
if (w > 0)
|
||
g.FillRectangle(fill, d < 0 ? cx - w : cx, rzBox.Y, w, rzBox.Height);
|
||
}
|
||
|
||
// Reference marks: Rz center tick + X/Y crosshair (drawn even when idle).
|
||
g.DrawLine(pen, rzBox.X + rzBox.Width / 2, rzBox.Y, rzBox.X + rzBox.Width / 2, rzBox.Bottom);
|
||
g.DrawLine(pen, xyBox.X, xyBox.Y + xyBox.Height / 2, xyBox.Right, xyBox.Y + xyBox.Height / 2);
|
||
g.DrawLine(pen, xyBox.X + xyBox.Width / 2, xyBox.Y, xyBox.X + xyBox.Width / 2, xyBox.Bottom);
|
||
|
||
foreach (var (lbl, box) in boxes)
|
||
{
|
||
g.DrawRectangle(pen, box);
|
||
TextRenderer.DrawText(g, lbl, font, box, Color.Gainsboro,
|
||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
||
}
|
||
|
||
// The X/Y stick dot goes on top of the crosshair and label
|
||
// (0 = left/top, matching the HID axis convention).
|
||
if (axes is { } live)
|
||
{
|
||
int px = xyBox.X + (int)Math.Round(AxisGauges.Fraction(live.Axes.X) * (xyBox.Width - 1));
|
||
int py = xyBox.Y + (int)Math.Round(AxisGauges.Fraction(live.Axes.Y) * (xyBox.Height - 1));
|
||
using var dot = new SolidBrush(Color.FromArgb(150, 230, 150));
|
||
g.FillEllipse(dot, px - 3, py - 3, 7, 7);
|
||
}
|
||
}
|
||
|
||
public static PanelButton? HitTest(IList<PanelButton> buttons, Point p)
|
||
{
|
||
foreach (PanelButton b in buttons)
|
||
if (Cell(b.Col, b.Row).Contains(p))
|
||
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>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;
|
||
}
|
||
}
|