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); } }