Files
riojoy/src/RioJoy.Tray/Editor/PanelView.cs
T
CydandClaude Fable 5 1ff0b16015 Phase 8A (2/2): RioJoy.Core multi-targets net48 + net40 (Windows XP flavor)
- 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>
2026-07-11 20:41:58 -05:00

277 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 1016, 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;
}
}