using VPlasma.App;
using VRio.Core.Device;
using VRio.Core.Panel;
using VRio.Core.Protocol;
namespace VRio.App;
///
/// 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 physical controls: 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 (),
/// 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.
///
/// The strip's left slot hosts the built-in plasma glass: MainForm parks
/// its child at , and the
/// strip height and gauge positions derive from the glass size.
///
/// Left-click = momentary press (release on mouse-up). Right-click =
/// latch the button down / release it (handy for testing holds).
///
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 static readonly IReadOnlyList AllButtons = CockpitLayout.Buttons();
// First grid row any group occupies (the shared layout leaves row 0
// empty). Rendering shifts all rows up by this, so the button grid starts
// right under the encoder strip instead of a blank cell row below it.
private static readonly int FirstRow = CockpitLayout.Groups.Min(g => g.OriginRow);
// Encoder-strip geometry. The built-in plasma glass parks at the strip's
// left edge (MainForm adds its PlasmaCanvas as a child at PlasmaSlot),
// the axis gauges — Z, the pedal gauges L / R, and the X/Y stick box —
// hug the grid's right edge, and the green status text fills the gap
// between them. The strip is exactly as tall as the glass.
internal const int PlasmaDotPitch = 3; // 2 px dots: the 128×32 glass fits the strip
internal static readonly Size PlasmaSize = PlasmaCanvas.SizeFor(PlasmaDotPitch);
private const int StripTop = 6;
internal static readonly Point PlasmaSlot = new(6, StripTop);
private static readonly int StripH = PlasmaSize.Height;
private static readonly int TopStrip = StripTop + StripH + 6; // button grid starts under the strip
private const int Bar = 30;
private static readonly int BaseX = GridWidth() - 6 - 200; // the gauge cluster spans 200 px, right-aligned
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 _latched = new();
private readonly HashSet _externalHeld = 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();
}
/// Current lamp-state byte for an address (from the device).
public Func? LampProvider { get; set; }
/// Current raw value of an axis (from the device).
public Func? AxisProvider { get; set; }
///
/// Axis value as transmitted to the host (joystick Y sign flip applied).
/// The text readout shows this; the dot and gauges track physical state.
///
public Func? WireAxisProvider { get; set; }
/// The user pressed a panel control.
public event Action? AddressPressed;
/// The user released a panel control.
public event Action? AddressReleased;
/// The user dragged an axis to a new raw value.
public event Action? AxisMoved;
/// When true (default), the X/Y stick snaps back to center on release.
public bool StickSpringsBack { get; set; } = true;
/// Green status text drawn in the strip area (the mockup's status panel).
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);
}
// Split from GridSize so the static gauge layout can use the width
// without touching TopStrip (which initializes after AllButtons).
private static int GridWidth()
{
int maxCol = 0;
foreach (PanelButton b in AllButtons)
if (b.Col > maxCol) maxCol = b.Col;
return (maxCol + 1) * CellW + 6;
}
private static Size GridSize()
{
int maxRow = 0;
foreach (PanelButton b in AllButtons)
if (b.Row > maxRow) maxRow = b.Row;
// The last used row plus a small margin under the lowest group frame.
return new Size(GridWidth(), TopStrip + (maxRow - FirstRow + 1) * CellH + 6);
}
private static Rectangle Cell(int col, int row) =>
new(col * CellW + 2, TopStrip + (row - FirstRow) * 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 - FirstRow) * 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) || _externalHeld.Contains(address);
///
/// Mark an address held/released by a non-mouse source (keyboard or
/// gamepad via the input router) so it lights up like a click.
///
public void SetExternalHeld(int address, bool held)
{
if (held ? _externalHeld.Add(address) : _externalHeld.Remove(address))
Invalidate();
}
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, between the plasma glass
// and the gauges; the live axis readout sits directly under the status
// lines (painted per frame, so drags track).
var statusRect = new Rectangle(
PlasmaSlot.X + PlasmaSize.Width + 12, StripTop,
BoxZ.Left - PlasmaSlot.X - PlasmaSize.Width - 24, StripH);
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;
}
short WireAxis(RioAxis a) => WireAxisProvider?.Invoke(a) ?? Axis(a);
string readout =
$"Z {WireAxis(RioAxis.Throttle),4} L {WireAxis(RioAxis.LeftPedal),3} R {WireAxis(RioAxis.RightPedal),3}\n" +
$"X {WireAxis(RioAxis.JoystickX),3} Y {WireAxis(RioAxis.JoystickY),3}";
TextRenderer.DrawText(g, readout, statusFont,
new Rectangle(statusRect.X, readoutTop, statusRect.Width, statusRect.Bottom - readoutTop), green,
TextFormatFlags.Left | TextFormatFlags.Top);
}
}
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
}
/// Raw axis value → 0..1 across its ±extent travel, clamped.
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);
}
}