- Axes now span the hardware windows from RIOJoy's calibrator instead of the full 14-bit wire range: throttle rests at 0 and travels to -800 (forward runs negative, matching the ratchet math), spring-loaded pedals rest at 0 and press to +500, stick covers +/-80 around center (new RioAxisRange in VRio.Core documents the provenance). - Stick X sign flipped: RIOJoy maps negative samples to the high half of its output axis, so dragging right now lowers raw X. - Removed the Rz mix bar - the real RIO has no such indicator. - Wire log: newest entries on top with the bound trimming the oldest lines, and fixed the anchoring bug that let the box grow past the window edge (the panel got its height only after children snapshotted their anchors), which also hid the Clear log button. - Tighter layout: axis readout sits under the status lines, encoder strip shrunk to fit the gauges, and the window sizes itself to the canvas + control strip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
484 lines
19 KiB
C#
484 lines
19 KiB
C#
using VRio.Core.Device;
|
|
using VRio.Core.Panel;
|
|
using VRio.Core.Protocol;
|
|
|
|
namespace VRio.App;
|
|
|
|
/// <summary>
|
|
/// The interactive cockpit control panel. Visually it mirrors RIOJoy's profile
|
|
/// editor map (same functional groups, cell geometry, and lamp colours), but
|
|
/// here the cells are the <em>physical controls</em>: pressing one emits a
|
|
/// button/keypad packet on the wire, and the fill shade tracks the lamp state
|
|
/// the host last commanded (including flash modes). The encoder strip is live
|
|
/// too — drag the X/Y box and the Z/L/R gauges to move the analog axes. The
|
|
/// gauges span each axis' realistic hardware travel (<see cref="RioAxisRange"/>),
|
|
/// not the full 14-bit wire range: Z/L/R rest at raw 0 at the gauge bottom and
|
|
/// fill toward full travel (negative on the wire for the throttle, positive
|
|
/// for the pedals), and the stick covers ±80 around its center.
|
|
///
|
|
/// <para>Left-click = momentary press (release on mouse-up). Right-click =
|
|
/// latch the button down / release it (handy for testing holds).</para>
|
|
/// </summary>
|
|
internal sealed class PanelCanvas : Control
|
|
{
|
|
// Cell geometry — identical to RIOJoy's PanelView so the two panels align.
|
|
private const int CellW = 66;
|
|
private const int CellH = 34;
|
|
private const int TopStrip = 78; // encoder strip: gauges end at 74, small gap to the grid
|
|
|
|
private static readonly IReadOnlyList<PanelButton> AllButtons = CockpitLayout.Buttons();
|
|
|
|
// Encoder-strip geometry (same placement as RIOJoy's PanelView): Z gauge,
|
|
// the pedal gauges L / R, and the X/Y stick box.
|
|
private const int StripTop = 6;
|
|
private const int StripH = 68;
|
|
private const int Bar = 30;
|
|
private static readonly int BaseX = 6 * CellW - 100;
|
|
private static readonly Rectangle BoxZ = new(BaseX, StripTop, Bar, StripH);
|
|
private static readonly Rectangle BoxL = new(BaseX + 34, StripTop, Bar, StripH);
|
|
private static readonly Rectangle BoxR = new(BaseX + 34 + 82 - Bar, StripTop, Bar, StripH);
|
|
private static readonly Rectangle BoxXY = new(BaseX + 120, StripTop, 80, StripH);
|
|
|
|
private readonly System.Windows.Forms.Timer _flashTimer = new() { Interval = 100 };
|
|
private bool _wasFlashing;
|
|
|
|
private readonly HashSet<int> _latched = new();
|
|
private int? _mouseDownAddress;
|
|
|
|
private RioAxis? _dragAxis; // Z / L / R gauge drag
|
|
private bool _dragStick; // X/Y box drag
|
|
|
|
private string _statusText = string.Empty;
|
|
|
|
public PanelCanvas()
|
|
{
|
|
DoubleBuffered = true;
|
|
BackColor = Color.FromArgb(28, 28, 28);
|
|
Size = GridSize();
|
|
|
|
_flashTimer.Tick += (_, _) =>
|
|
{
|
|
bool flashing = AnyLampFlashing();
|
|
if (flashing || _wasFlashing)
|
|
Invalidate();
|
|
_wasFlashing = flashing;
|
|
};
|
|
_flashTimer.Start();
|
|
}
|
|
|
|
/// <summary>Current lamp-state byte for an address (from the device).</summary>
|
|
public Func<int, byte>? LampProvider { get; set; }
|
|
|
|
/// <summary>Current raw value of an axis (from the device).</summary>
|
|
public Func<RioAxis, short>? AxisProvider { get; set; }
|
|
|
|
/// <summary>The user pressed a panel control.</summary>
|
|
public event Action<int>? AddressPressed;
|
|
|
|
/// <summary>The user released a panel control.</summary>
|
|
public event Action<int>? AddressReleased;
|
|
|
|
/// <summary>The user dragged an axis to a new raw value.</summary>
|
|
public event Action<RioAxis, short>? AxisMoved;
|
|
|
|
/// <summary>When true (default), the X/Y stick snaps back to center on release.</summary>
|
|
public bool StickSpringsBack { get; set; } = true;
|
|
|
|
/// <summary>Green status text drawn in the strip area (the mockup's status panel).</summary>
|
|
public string StatusText
|
|
{
|
|
get => _statusText;
|
|
set
|
|
{
|
|
if (_statusText == value) return;
|
|
_statusText = value;
|
|
Invalidate();
|
|
}
|
|
}
|
|
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
if (disposing) _flashTimer.Dispose();
|
|
base.Dispose(disposing);
|
|
}
|
|
|
|
private static Size GridSize()
|
|
{
|
|
int maxCol = 0, maxRow = 0;
|
|
foreach (PanelButton b in AllButtons)
|
|
{
|
|
if (b.Col > maxCol) maxCol = b.Col;
|
|
if (b.Row > maxRow) maxRow = b.Row;
|
|
}
|
|
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);
|
|
|
|
// ---- Painting ----------------------------------------------------------
|
|
|
|
protected override void OnPaint(PaintEventArgs e)
|
|
{
|
|
Graphics g = e.Graphics;
|
|
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, subFont);
|
|
|
|
// Group frames + titles.
|
|
foreach (PanelGroup grp in CockpitLayout.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);
|
|
}
|
|
|
|
const TextFormatFlags oneLine = TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter |
|
|
TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix;
|
|
int tick = Environment.TickCount;
|
|
|
|
foreach (PanelButton b in AllButtons)
|
|
{
|
|
Rectangle r = Cell(b.Col, b.Row);
|
|
bool held = IsHeld(b.Address);
|
|
bool yellow = b.Group.Title is "Secondary" or "Screen";
|
|
|
|
// Lamp shade: locally held renders bright (a real pressed cap floods
|
|
// bright); otherwise the host-commanded lamp state decides, with the
|
|
// flash bits blinking between the commanded brightness and off.
|
|
LampBrightness shade = LampBrightness.Off;
|
|
if (b.LampCapable)
|
|
{
|
|
byte state = LampProvider?.Invoke(b.Address) ?? 0;
|
|
shade = RioLampState.Brightness(state);
|
|
if (shade != LampBrightness.Off && !FlashPhaseOn(RioLampState.Flash(state), tick))
|
|
shade = LampBrightness.Off;
|
|
}
|
|
if (held)
|
|
shade = LampBrightness.Bright;
|
|
|
|
Color fill = !b.LampCapable
|
|
? (held ? Color.FromArgb(205, 228, 255) // keypad — bright blue when pressed
|
|
: Color.FromArgb(150, 182, 226)) // keypad — neutral blue
|
|
: yellow
|
|
? shade switch
|
|
{
|
|
LampBrightness.Bright => Color.FromArgb(245, 210, 60),
|
|
LampBrightness.Dim => Color.FromArgb(140, 118, 38),
|
|
_ => Color.FromArgb(70, 60, 24),
|
|
}
|
|
: shade switch
|
|
{
|
|
LampBrightness.Bright => Color.FromArgb(230, 70, 70),
|
|
LampBrightness.Dim => Color.FromArgb(120, 50, 50),
|
|
_ => Color.FromArgb(64, 40, 40),
|
|
};
|
|
using (var fb = new SolidBrush(fill)) g.FillRectangle(fb, r);
|
|
|
|
Color fg = (b.LampCapable && !yellow) ? Color.White : Color.Black;
|
|
string hex = $"{b.Address:X2}";
|
|
string? label = CockpitLayout.PhysicalLabel(b.Address);
|
|
|
|
if (label is not null && b.Group.Kind != PanelGroupKind.Keypad)
|
|
{
|
|
TextRenderer.DrawText(g, label, 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, hex, 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
|
|
{
|
|
TextRenderer.DrawText(g, label ?? hex, btnFont, r, fg, oneLine);
|
|
}
|
|
|
|
if (_latched.Contains(b.Address))
|
|
{
|
|
using var latch = new Pen(Color.Gold, 2f);
|
|
g.DrawRectangle(latch, r.X, r.Y, r.Width - 1, r.Height - 1);
|
|
}
|
|
else if (held)
|
|
{
|
|
using var hi = new Pen(Color.White, 2f);
|
|
g.DrawRectangle(hi, r.X, r.Y, r.Width - 1, r.Height - 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
private bool IsHeld(int address) => _mouseDownAddress == address || _latched.Contains(address);
|
|
|
|
private static bool FlashPhaseOn(LampFlash flash, int tick)
|
|
{
|
|
int halfPeriod = flash switch
|
|
{
|
|
LampFlash.FlashSlow => 500,
|
|
LampFlash.FlashMed => 250,
|
|
LampFlash.FlashFast => 125,
|
|
_ => 0,
|
|
};
|
|
return halfPeriod == 0 || tick / halfPeriod % 2 == 0;
|
|
}
|
|
|
|
private bool AnyLampFlashing()
|
|
{
|
|
if (LampProvider is not { } lamps)
|
|
return false;
|
|
for (int a = 0; a < RioAddressSpace.LampCount; a++)
|
|
{
|
|
byte state = lamps(a);
|
|
if (RioLampState.Flash(state) != LampFlash.Solid && RioLampState.Brightness(state) != LampBrightness.Off)
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private void DrawEncoderStrip(Graphics g, Font labelFont, Font valueFont)
|
|
{
|
|
using var pen = new Pen(Color.FromArgb(120, 160, 120));
|
|
using var bg = new SolidBrush(Color.FromArgb(20, 40, 20));
|
|
using var fillBrush = new SolidBrush(Color.FromArgb(90, 190, 90));
|
|
|
|
short Axis(RioAxis a) => AxisProvider?.Invoke(a) ?? (short)0;
|
|
|
|
// Vertical gauges: Z (throttle) and the pedals L / R. Each rests at
|
|
// raw 0 (gauge bottom) and fills toward full travel at the top.
|
|
DrawVGauge(g, pen, bg, fillBrush, labelFont, BoxZ, "Z", Axis(RioAxis.Throttle), RioAxisRange.ThrottleFull);
|
|
DrawVGauge(g, pen, bg, fillBrush, labelFont, BoxL, "L", Axis(RioAxis.LeftPedal), RioAxisRange.PedalFull);
|
|
DrawVGauge(g, pen, bg, fillBrush, labelFont, BoxR, "R", Axis(RioAxis.RightPedal), RioAxisRange.PedalFull);
|
|
|
|
// X/Y stick box with a crosshair at the current position.
|
|
g.FillRectangle(bg, BoxXY);
|
|
g.DrawRectangle(pen, BoxXY);
|
|
using (var mid = new Pen(Color.FromArgb(50, 90, 50)))
|
|
{
|
|
g.DrawLine(mid, BoxXY.X, BoxXY.Y + BoxXY.Height / 2, BoxXY.Right, BoxXY.Y + BoxXY.Height / 2);
|
|
g.DrawLine(mid, BoxXY.X + BoxXY.Width / 2, BoxXY.Y, BoxXY.X + BoxXY.Width / 2, BoxXY.Bottom);
|
|
}
|
|
PointF stick = StickToPoint(Axis(RioAxis.JoystickX), Axis(RioAxis.JoystickY));
|
|
g.FillEllipse(fillBrush, stick.X - 4, stick.Y - 4, 8, 8);
|
|
TextRenderer.DrawText(g, "X / Y", labelFont,
|
|
new Rectangle(BoxXY.X, BoxXY.Bottom - 16, BoxXY.Width, 14), Color.Gainsboro,
|
|
TextFormatFlags.HorizontalCenter | TextFormatFlags.Bottom);
|
|
|
|
// The mockup's green status/instruction area, right of the gauges; the
|
|
// live axis readout sits directly under the status lines (painted per
|
|
// frame, so drags track).
|
|
var statusRect = new Rectangle(BoxXY.Right + 16, StripTop, Width - BoxXY.Right - 24, TopStrip - StripTop - 6);
|
|
if (statusRect.Width > 60)
|
|
{
|
|
using var statusFont = new Font("Consolas", 8f);
|
|
var green = Color.FromArgb(120, 220, 120);
|
|
int readoutTop = statusRect.Y;
|
|
if (_statusText.Length > 0)
|
|
{
|
|
TextRenderer.DrawText(g, _statusText, statusFont, statusRect, green,
|
|
TextFormatFlags.Left | TextFormatFlags.Top | TextFormatFlags.WordBreak);
|
|
readoutTop += TextRenderer.MeasureText(g, _statusText, statusFont,
|
|
new Size(statusRect.Width, 0), TextFormatFlags.WordBreak).Height + 2;
|
|
}
|
|
|
|
string readout =
|
|
$"Z {Axis(RioAxis.Throttle),4} L {Axis(RioAxis.LeftPedal),3} R {Axis(RioAxis.RightPedal),3} " +
|
|
$"X {Axis(RioAxis.JoystickX),3} Y {Axis(RioAxis.JoystickY),3}";
|
|
TextRenderer.DrawText(g, readout, statusFont,
|
|
new Rectangle(statusRect.X, readoutTop, statusRect.Width, statusRect.Bottom - readoutTop), green,
|
|
TextFormatFlags.Left | TextFormatFlags.Top | TextFormatFlags.SingleLine);
|
|
}
|
|
}
|
|
|
|
private static void DrawVGauge(Graphics g, Pen pen, Brush bg, Brush fill, Font labelFont,
|
|
Rectangle box, string label, short value, int full)
|
|
{
|
|
g.FillRectangle(bg, box);
|
|
// Fill upward from the raw-0 rest position at the bottom, proportional
|
|
// to how far the control has travelled toward `full` (either sign).
|
|
float norm = Math.Max(0f, Math.Min(1f, value / (float)full));
|
|
int h = (int)(norm * (box.Height - 2));
|
|
if (h > 0)
|
|
g.FillRectangle(fill, box.X + 1, box.Bottom - 1 - h, box.Width - 2, h);
|
|
g.DrawRectangle(pen, box);
|
|
TextRenderer.DrawText(g, label, labelFont, box, Color.Gainsboro,
|
|
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
|
}
|
|
|
|
private static PointF StickToPoint(short x, short y)
|
|
{
|
|
// Raw X runs opposite the screen direction: stick right = negative on
|
|
// the wire (RIOJoy's calibrator maps negative samples to the high half
|
|
// of its output axis), so mirror the value for display.
|
|
float nx = Norm(-x, RioAxisRange.JoystickExtent);
|
|
float ny = Norm(y, RioAxisRange.JoystickExtent);
|
|
return new PointF(
|
|
BoxXY.X + 1 + nx * (BoxXY.Width - 2),
|
|
BoxXY.Bottom - 1 - ny * (BoxXY.Height - 2)); // up = positive Y
|
|
}
|
|
|
|
/// <summary>Raw axis value → 0..1 across its ±extent travel, clamped.</summary>
|
|
private static float Norm(int value, int extent) =>
|
|
Math.Max(0f, Math.Min(1f, (value + extent) / (2f * extent)));
|
|
|
|
// ---- Interaction -------------------------------------------------------
|
|
|
|
protected override void OnMouseDown(MouseEventArgs e)
|
|
{
|
|
base.OnMouseDown(e);
|
|
Focus();
|
|
|
|
// Axis gauges first (they live above the button grid).
|
|
if (e.Button == MouseButtons.Left)
|
|
{
|
|
if (BoxXY.Contains(e.Location))
|
|
{
|
|
_dragStick = true;
|
|
Capture = true;
|
|
UpdateStick(e.Location);
|
|
return;
|
|
}
|
|
RioAxis? gauge = HitGauge(e.Location);
|
|
if (gauge is { } axis)
|
|
{
|
|
_dragAxis = axis;
|
|
Capture = true;
|
|
UpdateGauge(axis, e.Location);
|
|
return;
|
|
}
|
|
}
|
|
|
|
PanelButton? hit = HitButton(e.Location);
|
|
if (hit is null)
|
|
return;
|
|
|
|
if (e.Button == MouseButtons.Right)
|
|
{
|
|
// Latch toggle: press-and-hold without keeping the mouse down.
|
|
if (_latched.Remove(hit.Address))
|
|
AddressReleased?.Invoke(hit.Address);
|
|
else
|
|
{
|
|
_latched.Add(hit.Address);
|
|
AddressPressed?.Invoke(hit.Address);
|
|
}
|
|
Invalidate();
|
|
}
|
|
else if (e.Button == MouseButtons.Left)
|
|
{
|
|
if (_latched.Remove(hit.Address))
|
|
{
|
|
// Left-clicking a latched button releases it.
|
|
AddressReleased?.Invoke(hit.Address);
|
|
Invalidate();
|
|
return;
|
|
}
|
|
_mouseDownAddress = hit.Address;
|
|
AddressPressed?.Invoke(hit.Address);
|
|
Invalidate();
|
|
}
|
|
}
|
|
|
|
protected override void OnMouseMove(MouseEventArgs e)
|
|
{
|
|
base.OnMouseMove(e);
|
|
if (_dragStick)
|
|
UpdateStick(e.Location);
|
|
else if (_dragAxis is { } axis)
|
|
UpdateGauge(axis, e.Location);
|
|
}
|
|
|
|
protected override void OnMouseUp(MouseEventArgs e)
|
|
{
|
|
base.OnMouseUp(e);
|
|
|
|
if (_dragStick)
|
|
{
|
|
_dragStick = false;
|
|
Capture = false;
|
|
if (StickSpringsBack)
|
|
{
|
|
AxisMoved?.Invoke(RioAxis.JoystickX, 0);
|
|
AxisMoved?.Invoke(RioAxis.JoystickY, 0);
|
|
}
|
|
return;
|
|
}
|
|
if (_dragAxis is not null)
|
|
{
|
|
_dragAxis = null;
|
|
Capture = false;
|
|
return;
|
|
}
|
|
|
|
ReleaseMomentary();
|
|
}
|
|
|
|
protected override void OnMouseCaptureChanged(EventArgs e)
|
|
{
|
|
base.OnMouseCaptureChanged(e);
|
|
_dragStick = false;
|
|
_dragAxis = null;
|
|
ReleaseMomentary();
|
|
}
|
|
|
|
private void ReleaseMomentary()
|
|
{
|
|
if (_mouseDownAddress is not { } addr)
|
|
return;
|
|
_mouseDownAddress = null;
|
|
AddressReleased?.Invoke(addr);
|
|
Invalidate();
|
|
}
|
|
|
|
private static PanelButton? HitButton(Point p)
|
|
{
|
|
foreach (PanelButton b in AllButtons)
|
|
if (Cell(b.Col, b.Row).Contains(p))
|
|
return b;
|
|
return null;
|
|
}
|
|
|
|
private static RioAxis? HitGauge(Point p)
|
|
{
|
|
if (BoxZ.Contains(p)) return RioAxis.Throttle;
|
|
if (BoxL.Contains(p)) return RioAxis.LeftPedal;
|
|
if (BoxR.Contains(p)) return RioAxis.RightPedal;
|
|
return null;
|
|
}
|
|
|
|
private void UpdateStick(Point p)
|
|
{
|
|
// Dragging right lowers raw X — the wire sign is opposite the screen
|
|
// direction (see StickToPoint).
|
|
short x = FromNorm((BoxXY.Right - p.X) / (float)BoxXY.Width, RioAxisRange.JoystickExtent);
|
|
short y = FromNorm((BoxXY.Bottom - p.Y) / (float)BoxXY.Height, RioAxisRange.JoystickExtent);
|
|
AxisMoved?.Invoke(RioAxis.JoystickX, x);
|
|
AxisMoved?.Invoke(RioAxis.JoystickY, y);
|
|
}
|
|
|
|
private void UpdateGauge(RioAxis axis, Point p)
|
|
{
|
|
Rectangle box = axis switch
|
|
{
|
|
RioAxis.Throttle => BoxZ,
|
|
RioAxis.LeftPedal => BoxL,
|
|
_ => BoxR,
|
|
};
|
|
// Gauge bottom = rest (raw 0), top = full travel (sign per axis).
|
|
float norm = Math.Max(0f, Math.Min(1f, (box.Bottom - p.Y) / (float)box.Height));
|
|
AxisMoved?.Invoke(axis, (short)(norm * RioAxisRange.Full(axis)));
|
|
}
|
|
|
|
private static short FromNorm(float norm, int extent)
|
|
{
|
|
norm = Math.Max(0f, Math.Min(1f, norm));
|
|
return (short)(-extent + norm * 2 * extent);
|
|
}
|
|
}
|