diff --git a/docs/PLAN.md b/docs/PLAN.md
index d9c5809..fde7b8e 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -238,21 +238,22 @@ Replaces the legacy Google-Sheet → `.data` → GIMP → Script-Fu pipeline
activation). The live `SystemParametersInfo` apply changes a user setting, so it
is gated behind config and not exercised by tests. ⏳ Optional: restore the prior
wallpaper when going dormant.
-- **Mapping editor — first cut done ✅.** `RioJoy.Tray/Editor/ProfileEditorForm`
- shows the cockpit as a scrollable, clickable grid that mirrors the legacy
- authoring spreadsheet's **logical** layout — parsed from the sheet's CSV export
- (`docs/reference/customBackground/config-sheet.csv`) by `SheetLayout`, coloured
- by section. (The layout deliberately follows the sheet, **not** the wallpaper
- region positions: the wallpaper drives 6 displays via VGA chroma-channel
- splitting, so its label positions stack and don't reflect the physical cockpit.)
- Clicking an address cell edits its label and action/modifiers/lamp; the `iRIO`
- word is read/written through `ButtonBinding` ↔ `RioMapEntry.Create` (round-trip
- tested) — no hex bit-twiddling. The value field is a **context-sensitive picker**:
- keyboard actions pick a key by name (`KeyCatalog` VK names), joystick a Button N,
- hat a direction, mouse/RIO-command the enum name; modifiers enable only for
- keyboard. Opened from the tray ("Edit profile…"); Save persists the profile.
- ⏳ Still to refine: grouping a button's two bank addresses (edit once → both),
- and clone-from-existing.
+- **Mapping editor — done ✅ (cockpit control-panel layout).**
+ `RioJoy.Tray/Editor/ProfileEditorForm` shows the cockpit as a clickable control
+ panel matching the **original Win32 RIO design** (docs/Win32RIO/, by FASA/Michel
+ Lowrance): five MFD clusters, four board columns (Throttle/Secondary/Screen/
+ Joystick-Hat), an encoder-gauge strip, and the two later-added 4×4 keypads
+ (rendered without lamps). The layout (`RioJoy.Core.Editing.CockpitPanel`, which
+ places every address 0x00–0x47 / 0x50–0x6F exactly once, unit-tested) deliberately
+ follows the design, **not** the wallpaper positions (those are a VGA chroma-split
+ display artifact). Lamp buttons shade Off/assigned; keypads are neutral. Clicking
+ a button edits its label and action/modifiers/lamp; the `iRIO` word goes through
+ `ButtonBinding` ↔ `RioMapEntry.Create` — no hex. The value field is a
+ context-sensitive picker (keyboard key by name via `KeyCatalog`, joystick Button N,
+ hat direction, mouse/RIO-command enum); modifiers enable only for keyboard.
+ Opened from the tray ("Edit profile…"); Save persists the profile.
+ ⏳ Still to refine: live encoder gauges, showing each button's assigned key as a
+ caption, grouping a button's two bank addresses, and clone-from-existing.
- The unified profile JSON supersedes both `RIO.ini` and the Google Sheet, with
importers for each.
- To confirm at Phase 7: target wallpaper resolution(s); static wallpaper vs.
diff --git a/docs/Win32RIO/RemoteDriver.doc b/docs/Win32RIO/RemoteDriver.doc
new file mode 100644
index 0000000..e6d2bc9
Binary files /dev/null and b/docs/Win32RIO/RemoteDriver.doc differ
diff --git a/docs/Win32RIO/control-panel-mockup.png b/docs/Win32RIO/control-panel-mockup.png
new file mode 100644
index 0000000..2b4720a
Binary files /dev/null and b/docs/Win32RIO/control-panel-mockup.png differ
diff --git a/docs/Win32RIO/game-controllers-mockup.png b/docs/Win32RIO/game-controllers-mockup.png
new file mode 100644
index 0000000..744d6e7
Binary files /dev/null and b/docs/Win32RIO/game-controllers-mockup.png differ
diff --git a/docs/Win32RIO/oemsetup.inf b/docs/Win32RIO/oemsetup.inf
new file mode 100644
index 0000000..7a38cc6
--- /dev/null
+++ b/docs/Win32RIO/oemsetup.inf
@@ -0,0 +1,133 @@
+; Copyright (c) 1997-1999 Microsoft Corporation
+;
+[Version]
+
+Signature="$CHICAGO$"
+Class=HIDClass
+ClassGuid={745a17a0-74d3-11d0-b6fe-00a0c90f57da}
+;
+Provider=%VENDOR%
+LayoutFile=layout.inf
+DriverVer=04/14/2002,5.00.2000.24
+
+;
+; Layout.inf (etc.) list all files shipped with the operating system so the
+; source description sections are only needed if other files are needed.
+;
+; In order to use IHV drivers, the SourceDisksNames section must list the
+; disk(s) containing the drivers and the SourceDisksFiles section must list
+; which disk number each file to be copied is found on.
+; Disk number 99 is used to avoid a conflict with the disk numbers defined
+; in layout.inf
+;
+; tasgame.Sys is the name of the standard analog game device driver so
+; another name must be used for an IHV driver. NT will not load a driver
+; compiled as tasgame.Sys and renamed as another so it must be compiled
+; under it's final name.
+;
+; Files used in a driver installation need to be digitally signed otherwise
+; installation may fail. See documentation elsewhere in the DDK regarding
+; driver signing.
+;
+; [SourceDisksNames]
+; 99=%DiskId%,,
+;
+; [SourceDisksFiles]
+; tasgame.sys = 99
+
+[DefaultInstall]
+CopyFiles=CopyFilesSYS
+AddReg=Joystick.AddReg
+
+[DestinationDirs]
+CopyFilesSYS = 10,system32\drivers ;%SystemRoot%\system32\drivers
+DefaultDestDir = 10,system32\drivers
+CopyFilesDLL = 11 ;%SystemRoot%\system or system32 - 98 or Win2000
+
+[ControlFlags]
+ExcludeFromSelect = GamePort\Joystick
+
+; Notes
+;
+; In order that the game controllers control panel can display the device
+; name registry keys describing the device must be set up. In this sample
+; these values are setup up in the Joystick.AddReg and Gamepad.AddReg
+; sections. Clearly these values must be set up before the CPL can cause
+; the PnP ID to be exposed.
+;
+; If no PnP ID match is found when a gameport device is exposed, the default
+; driver will be matched. This allows most analog joysticks to be supported
+; without an additional driver. Unfortunately, this means that a device
+; which requires a driver must have its PnP ID matched or the default driver
+; will be loaded. To avoid the user having to change the driver the INF
+; containing the match may be copied into the INF directory in advance.
+;
+; To satisfy the two goals above, some form of preliminary setup is required.
+; This could be a program, a batch file that runs an INF section or an INF
+; with SetupClass=BASE and a DefaultInstall section.
+;
+
+[Manufacturer]
+%VENDOR%=Vendor
+
+[Vendor]
+;
+; For each device the following must be defined:
+; Device name - name seen in the Add New Hardware selection dialog
+; Install section - section in this INF to be run to install the device
+; PnP ID - ID which is matched by PnP when the device is detected
+; This should not contain spaces and for Win98 compatibility
+; should use the form GamePort\VID_9999&PID_9999.
+; A PnP ID must be used or this device will be detected as
+; always present and so loaded during boot even if the
+; StartType of the service is SERVICE_DEMAND_START.
+;
+;Device Name Install Section PnP ID
+;
+%Joystick%= Joystick.Inst, GamePort\VID_7777&PID_0001
+
+
+[Joystick.Inst]
+CopyFiles = CopyFilesSYS, CopyFilesDLL
+AddReg = Joystick.AddReg, AddReg.Vendor_Driver
+
+[Joystick.Inst.NT]
+CopyFiles = CopyFilesSYS, CopyFilesDLL
+AddReg = Joystick.AddReg
+
+[Joystick.AddReg]
+HKLM,%KEY_OEM%\VID_7777&PID_0001,OEMName,,%Joystick%
+HKLM,%KEY_OEM%\VID_7777&PID_0001,OEMData,1, 07, 00, 08, 00, 0A, 00, 00, 00
+HKLM,%KEY_OEM%\VID_7777&PID_0001,OEMHardwareID,,"GamePort\VID_7777&PID_0001"
+
+[Joystick.Inst.NT.Services]
+AddService = VendorJoystickEnabler,%SPSVCINST_ASSOCSERVICE%,Vendor_Service_Inst
+
+
+[CopyFilesSYS]
+tasgame.sys
+
+[CopyFilesDLL]
+
+[AddReg.Vendor_Driver]
+HKR,,DevLoader,,*ntkern
+HKR,,NTMPDriver,,"tasgame.sys"
+
+[Vendor_Service_Inst]
+DisplayName = %SvcDesc%
+ServiceType = 1 ; SERVICE_KERNEL_DRIVER
+StartType = 3 ; SERVICE_DEMAND_START
+ErrorControl = 0 ; SERVICE_ERROR_IGNORE
+ServiceBinary = %10%\system32\drivers\tasgame.sys
+LoadOrderGroup = Extended Base
+
+[Strings]
+; non localized strings
+SPSVCINST_ASSOCSERVICE= 0x00000002
+KEY_OEM="SYSTEM\CurrentControlSet\Control\MediaProperties\PrivateProperties\Joystick\OEM"
+
+;localized strings
+VENDOR = "Thomas Steinke"
+SvcDesc = "VWE Pod"
+Joystick = "VWE Pod"
+
diff --git a/docs/Win32RIO/rename b/docs/Win32RIO/rename
new file mode 100644
index 0000000..659f30a
Binary files /dev/null and b/docs/Win32RIO/rename differ
diff --git a/docs/Win32RIO/tasgame.sys b/docs/Win32RIO/tasgame.sys
new file mode 100644
index 0000000..659f30a
Binary files /dev/null and b/docs/Win32RIO/tasgame.sys differ
diff --git a/src/RioJoy.Core/Editing/CockpitPanel.cs b/src/RioJoy.Core/Editing/CockpitPanel.cs
new file mode 100644
index 0000000..02fcddf
--- /dev/null
+++ b/src/RioJoy.Core/Editing/CockpitPanel.cs
@@ -0,0 +1,109 @@
+namespace RioJoy.Core.Editing;
+
+/// Shape of a panel group.
+public enum PanelGroupKind
+{
+ /// A 4×2 MFD button cluster (lamp buttons).
+ Mfd,
+
+ /// A vertical 1×8 board column (lamp buttons).
+ Column,
+
+ /// A 4×4 keypad (added later; no lamps).
+ Keypad,
+}
+
+///
+/// One functional group on the cockpit control panel: a titled block of address
+/// buttons laid out in × (row-major, null =
+/// empty slot). / place the block on
+/// the panel in button-cell units (the renderer scales cells to pixels).
+///
+public sealed record PanelGroup(
+ string Title,
+ PanelGroupKind Kind,
+ int Cols,
+ int Rows,
+ bool LampCapable,
+ int OriginCol,
+ int OriginRow,
+ IReadOnlyList Addresses);
+
+/// One address button placed on the panel (absolute cell coordinates).
+public sealed record PanelButton(int Address, PanelGroup Group, int Col, int Row, bool LampCapable);
+
+///
+/// The cockpit control panel's logical layout — the functional grouping from the
+/// original Win32 RIO design (docs/Win32RIO/control-panel-mockup.png): five MFD
+/// clusters, four board columns, and the two later-added 4×4 keypads. Every RIO
+/// address 0x00–0x47 / 0x50–0x6F is placed exactly once. Pure data so the mapping
+/// is unit-tested; the WinForms editor renders these groups and binds each button.
+///
+public static class CockpitPanel
+{
+ // Helper: 4×2 MFD address block, top row then bottom row (addresses descend).
+ private static int?[] Mfd(int hi) => new int?[]
+ {
+ hi, hi - 1, hi - 2, hi - 3,
+ hi - 4, hi - 5, hi - 6, hi - 7,
+ };
+
+ // Helper: vertical 1×8 column from a base address upward.
+ private static int?[] Col8(int baseAddr) => new int?[]
+ {
+ baseAddr, baseAddr + 1, baseAddr + 2, baseAddr + 3,
+ baseAddr + 4, baseAddr + 5, baseAddr + 6, baseAddr + 7,
+ };
+
+ // Helper: 4×4 keypad. Physical keys 1 2 3 C / 4 5 6 D / 7 8 9 E / A 0 B F map to
+ // address = base + that hex digit.
+ private static int?[] Keypad(int baseAddr)
+ {
+ int[] digits = { 0x1, 0x2, 0x3, 0xC, 0x4, 0x5, 0x6, 0xD, 0x7, 0x8, 0x9, 0xE, 0xA, 0x0, 0xB, 0xF };
+ var a = new int?[16];
+ for (int i = 0; i < 16; i++) a[i] = baseAddr + digits[i];
+ return a;
+ }
+
+ /// All panel groups, positioned for rendering.
+ public static IReadOnlyList Groups { get; } = new[]
+ {
+ // Upper MFD row.
+ new PanelGroup("Upper Left MFD", PanelGroupKind.Mfd, 4, 2, true, 0, 1, Mfd(0x2F)),
+ new PanelGroup("Upper Middle MFD", PanelGroupKind.Mfd, 4, 2, true, 5, 1, Mfd(0x27)),
+ new PanelGroup("Upper Right MFD", PanelGroupKind.Mfd, 4, 2, true, 10, 1, Mfd(0x37)),
+
+ // Lower MFD row.
+ new PanelGroup("Lower Left MFD", PanelGroupKind.Mfd, 4, 2, true, 0, 5, Mfd(0x0F)),
+ new PanelGroup("Lower Right MFD", PanelGroupKind.Mfd, 4, 2, true, 10, 5, Mfd(0x07)),
+
+ // Board columns.
+ new PanelGroup("Throttle", PanelGroupKind.Column, 1, 8, true, 0, 9, Col8(0x38)),
+ new PanelGroup("Secondary", PanelGroupKind.Column, 1, 8, true, 5, 9, Col8(0x10)),
+ new PanelGroup("Screen", PanelGroupKind.Column, 1, 8, true, 7, 9, Col8(0x18)),
+ new PanelGroup("Joystick / Hat", PanelGroupKind.Column, 1, 8, true, 13, 9, Col8(0x40)),
+
+ // Keypads (no lamps): internal in the middle (below Upper Middle MFD, above
+ // the Secondary/Screen columns); external between those columns and Joystick.
+ new PanelGroup("Internal Keypad", PanelGroupKind.Keypad, 4, 4, false, 5, 4, Keypad(0x50)),
+ new PanelGroup("External Keypad", PanelGroupKind.Keypad, 4, 4, false, 9, 9, Keypad(0x60)),
+ };
+
+ /// Flatten the groups to positioned s (absolute cells).
+ public static IReadOnlyList Buttons()
+ {
+ var buttons = new List();
+ foreach (PanelGroup g in Groups)
+ {
+ for (int i = 0; i < g.Addresses.Count; i++)
+ {
+ if (g.Addresses[i] is not { } addr) continue;
+ int localCol = i % g.Cols;
+ int localRow = i / g.Cols;
+ // +1 row leaves space under the title.
+ buttons.Add(new PanelButton(addr, g, g.OriginCol + localCol, g.OriginRow + 1 + localRow, g.LampCapable));
+ }
+ }
+ return buttons;
+ }
+}
diff --git a/src/RioJoy.Core/Mapping/ButtonBinding.cs b/src/RioJoy.Core/Mapping/ButtonBinding.cs
index d47c638..679017f 100644
--- a/src/RioJoy.Core/Mapping/ButtonBinding.cs
+++ b/src/RioJoy.Core/Mapping/ButtonBinding.cs
@@ -34,6 +34,39 @@ public sealed class ButtonBinding
/// True when this binding does nothing (an empty slot).
public bool IsUnmapped => ToWord() == 0;
+ ///
+ /// A short human-readable description of the assigned function — e.g.
+ /// CTL-N, JOY1, POV-U, Mse LB, or a RIO command name.
+ /// Empty when unmapped. Used as the editor cell caption.
+ ///
+ public string Describe() => Kind switch
+ {
+ _ when IsUnmapped => string.Empty,
+ RioRouteKind.Keyboard =>
+ (Ctrl ? "CTL-" : "") + (Shift ? "SFT-" : "") + (Alt ? "ALT-" : "") + KeyCatalog.NameFor(Value),
+ RioRouteKind.Joystick => $"JOY{Value}",
+ RioRouteKind.Hat => (RioHat)Value switch
+ {
+ RioHat.Up => "POV-U",
+ RioHat.Right => "POV-R",
+ RioHat.Down => "POV-D",
+ RioHat.Left => "POV-L",
+ _ => "POV",
+ },
+ RioRouteKind.Mouse => "Mse " + (MouseAction)Value switch
+ {
+ MouseAction.MoveUp => "Up",
+ MouseAction.MoveDown => "Dn",
+ MouseAction.MoveLeft => "Lt",
+ MouseAction.MoveRight => "Rt",
+ MouseAction.LeftClick => "LB",
+ MouseAction.RightClick => "RB",
+ _ => Value.ToString(),
+ },
+ RioRouteKind.RioCommand => ((RioCommandCode)Value).ToString(),
+ _ => string.Empty,
+ };
+
/// Pack to the 16-bit iRIO map word.
public ushort ToWord() =>
RioMapEntry.Create(Kind, Value, IsLit, Shift, Ctrl, Alt, Extended).Raw;
diff --git a/src/RioJoy.Tray/Editor/PanelCanvas.cs b/src/RioJoy.Tray/Editor/PanelCanvas.cs
new file mode 100644
index 0000000..774a877
--- /dev/null
+++ b/src/RioJoy.Tray/Editor/PanelCanvas.cs
@@ -0,0 +1,67 @@
+using System.Runtime.Versioning;
+using RioJoy.Core.Editing;
+
+namespace RioJoy.Tray.Editor;
+
+///
+/// Interactive cockpit control-panel map: draws the functional groups (via
+/// ) and raises when a button
+/// is clicked. Sized to the full panel so the host scrolls it.
+///
+[SupportedOSPlatform("windows")]
+internal sealed class PanelCanvas : Control
+{
+ private static readonly IReadOnlyList AllButtons = CockpitPanel.Buttons();
+
+ public PanelCanvas()
+ {
+ DoubleBuffered = true;
+ BackColor = Color.FromArgb(28, 28, 28);
+ Size = PanelView.GridSize(AllButtons);
+ }
+
+ /// Resolves the display label for an address (from the profile).
+ public Func? LabelProvider { get; set; }
+
+ /// Resolves the assigned-function caption for an address (empty = unmapped).
+ public Func? FunctionProvider { get; set; }
+
+ /// Whether an address's lamp is lit (the binding's IsLit flag) — drives the lamp shade.
+ public Func? LitProvider { get; set; }
+
+ public int? SelectedAddress { get; private set; }
+
+ public event Action? AddressSelected;
+
+ public void Select(int? address)
+ {
+ SelectedAddress = address;
+ Invalidate();
+ }
+
+ public void Refresh(int? address)
+ {
+ SelectedAddress = address;
+ Invalidate();
+ }
+
+ protected override void OnPaint(PaintEventArgs e) =>
+ PanelView.Paint(
+ e.Graphics, AllButtons, CockpitPanel.Groups,
+ a => LabelProvider?.Invoke(a),
+ a => FunctionProvider?.Invoke(a),
+ a => LitProvider?.Invoke(a) ?? false,
+ SelectedAddress);
+
+ protected override void OnMouseDown(MouseEventArgs e)
+ {
+ base.OnMouseDown(e);
+ PanelButton? hit = PanelView.HitTest(AllButtons, e.Location);
+ if (hit is not null)
+ {
+ SelectedAddress = hit.Address;
+ AddressSelected?.Invoke(hit.Address);
+ Invalidate();
+ }
+ }
+}
diff --git a/src/RioJoy.Tray/Editor/PanelView.cs b/src/RioJoy.Tray/Editor/PanelView.cs
new file mode 100644
index 0000000..d5fd8c0
--- /dev/null
+++ b/src/RioJoy.Tray/Editor/PanelView.cs
@@ -0,0 +1,135 @@
+using System.Runtime.Versioning;
+using RioJoy.Core.Editing;
+
+namespace RioJoy.Tray.Editor;
+
+///
+/// Draws the cockpit control panel — the functional groups from
+/// (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 and offline rendering.
+///
+[SupportedOSPlatform("windows")]
+public static class PanelView
+{
+ public const int CellW = 66;
+ public const int CellH = 34;
+ public const int TopStrip = 92; // encoder-gauge band height
+
+ public static Size GridSize(IReadOnlyList 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;
+ }
+ return new Size((maxCol + 2) * CellW, 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,
+ IReadOnlyList buttons,
+ IReadOnlyList groups,
+ Func label,
+ Func function,
+ Func lit,
+ 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);
+
+ // 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);
+
+ Color fill = !b.LampCapable
+ ? Color.FromArgb(150, 182, 226) // keypad — neutral blue
+ : lit(b.Address)
+ ? Color.FromArgb(215, 60, 60) // lamp ON (IsLit checked)
+ : Color.FromArgb(86, 48, 48); // lamp OFF
+ using (var fb = new SolidBrush(fill)) g.FillRectangle(fb, r);
+
+ Color fg = b.LampCapable ? 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)
+ {
+ using var pen = new Pen(Color.FromArgb(120, 160, 120));
+ using var gauge = new SolidBrush(Color.FromArgb(20, 40, 20));
+
+ (string Label, int X, int W)[] boxes =
+ {
+ ("X / Y", 4, 80),
+ ("Z", 92, 26),
+ ("L", 122, 26),
+ ("R", 152, 26),
+ };
+ foreach (var (lbl, x, w) in boxes)
+ {
+ var r = new Rectangle(x, 6, w, TopStrip - 24);
+ g.FillRectangle(gauge, r);
+ g.DrawRectangle(pen, r);
+ TextRenderer.DrawText(g, lbl, font, new Rectangle(x, TopStrip - 18, w, 16), Color.Gainsboro,
+ TextFormatFlags.HorizontalCenter);
+ }
+ }
+
+ public static PanelButton? HitTest(IReadOnlyList buttons, Point p)
+ {
+ foreach (PanelButton b in buttons)
+ if (Cell(b.Col, b.Row).Contains(p))
+ return b;
+ return null;
+ }
+}
diff --git a/src/RioJoy.Tray/Editor/ProfileEditorForm.cs b/src/RioJoy.Tray/Editor/ProfileEditorForm.cs
index c0c65e5..3d65fb7 100644
--- a/src/RioJoy.Tray/Editor/ProfileEditorForm.cs
+++ b/src/RioJoy.Tray/Editor/ProfileEditorForm.cs
@@ -7,9 +7,10 @@ using RioJoy.Core.Profiles;
namespace RioJoy.Tray.Editor;
///
-/// Profile editor: shows the cockpit authoring sheet (its logical layout, from
-/// ) as a scrollable, clickable grid and edits the
-/// selected address cell's label and action/modifiers/lamp. Writes back to the
+/// Profile editor: shows the cockpit control panel (the functional grouping from
+/// the original Win32 RIO design — MFD clusters, board columns, keypads, encoder
+/// gauges, via ) as a clickable map, and edits the
+/// selected button's label and action/modifiers/lamp. Writes back to the
/// 's (keyed by the
/// wallpaper region b-XX) and (the iRIO
/// word); Save raises .
@@ -17,9 +18,14 @@ namespace RioJoy.Tray.Editor;
[SupportedOSPlatform("windows")]
public sealed class ProfileEditorForm : Form
{
+ private static readonly Dictionary GroupOf =
+ CockpitPanel.Buttons().ToDictionary(b => b.Address, b => b.Group.Title);
+
+ private static readonly Dictionary LampCapableOf =
+ CockpitPanel.Buttons().ToDictionary(b => b.Address, b => b.LampCapable);
+
private readonly RioProfile _profile;
- private readonly IReadOnlyList _cells;
- private readonly SheetCanvas _canvas = new();
+ private readonly PanelCanvas _canvas = new();
private readonly Label _info = new() { AutoSize = true, Location = new Point(12, 12), MaximumSize = new Size(310, 0) };
private readonly TextBox _labelBox = new() { Location = new Point(70, 44), Width = 230 };
@@ -32,6 +38,7 @@ public sealed class ProfileEditorForm : Form
private readonly CheckBox _ext = new() { Text = "Ext", Location = new Point(250, 144), AutoSize = true };
private readonly CheckBox _lit = new() { Text = "Lit", Location = new Point(70, 172), AutoSize = true };
private readonly Button _apply = new() { Text = "Apply to cell", Location = new Point(70, 206), Width = 110 };
+ private readonly Button _unassign = new() { Text = "Unassign", Location = new Point(190, 206), Width = 110 };
private readonly Button _save = new() { Text = "Save profile", Location = new Point(70, 240), Width = 110 };
private readonly Button _close = new() { Text = "Close", Location = new Point(190, 240), Width = 80 };
@@ -47,19 +54,21 @@ public sealed class ProfileEditorForm : Form
/// Raised when the user saves; the argument is the edited profile.
public event Action? Saved;
- public ProfileEditorForm(IReadOnlyList cells, RioProfile profile)
+ public ProfileEditorForm(RioProfile profile)
{
- _cells = cells ?? throw new ArgumentNullException(nameof(cells));
_profile = profile ?? throw new ArgumentNullException(nameof(profile));
Text = $"RIOJoy — Edit profile: {profile.Name}";
- ClientSize = new Size(1500, 820);
+ ClientSize = new Size(1320, 820);
StartPosition = FormStartPosition.CenterScreen;
MinimumSize = new Size(900, 500);
_kindBox.Items.AddRange(Enum.GetNames());
_kindBox.SelectedIndexChanged += OnKindChanged;
- _canvas.SetCells(cells);
+
+ _canvas.LabelProvider = a => _profile.OverlayLabels.TryGetValue(RegionFor(a), out string? t) ? t : null;
+ _canvas.FunctionProvider = a => _profile.Buttons.TryGetValue(a, out int w) ? ButtonBinding.FromWord((ushort)w).Describe() : null;
+ _canvas.LitProvider = a => _profile.Buttons.TryGetValue(a, out int w) && ButtonBinding.FromWord((ushort)w).IsLit;
_canvas.AddressSelected += OnAddressSelected;
var scroller = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Color.FromArgb(28, 28, 28) };
@@ -71,11 +80,12 @@ public sealed class ProfileEditorForm : Form
Controls.Add(editPanel);
_apply.Click += (_, _) => ApplyToCell();
+ _unassign.Click += (_, _) => Unassign();
_save.Click += (_, _) => { ApplyToCell(); Saved?.Invoke(_profile); };
_close.Click += (_, _) => Close();
SetEditingEnabled(false);
- _info.Text = "Click a coloured address cell to edit it.";
+ _info.Text = "Click a button to edit it.";
}
/// The selected RIO address (for tests / offline rendering).
@@ -92,25 +102,23 @@ public sealed class ProfileEditorForm : Form
panel.Controls.Add(_kindBox);
panel.Controls.Add(_valueLabel);
panel.Controls.Add(_valueCombo);
- panel.Controls.AddRange(new Control[] { _shift, _ctrl, _alt, _ext, _lit, _apply, _save, _close });
+ panel.Controls.AddRange(new Control[] { _shift, _ctrl, _alt, _ext, _lit, _apply, _unassign, _save, _close });
return panel;
}
- private void OnAddressSelected(SheetCell cell)
+ private void OnAddressSelected(int addr)
{
- if (cell.Address is not { } addr) { SetEditingEnabled(false); return; }
-
_address = addr;
string region = RegionFor(addr);
- _info.Text = $"Cell {cell.Text} Address 0x{addr:X2} {cell.Section} (wallpaper {region})";
-
- _labelBox.Text = _profile.OverlayLabels.TryGetValue(region, out string? label) ? label : string.Empty;
+ string group = GroupOf.TryGetValue(addr, out string? gn) ? gn : "—";
+ _info.Text = $"Address 0x{addr:X2} {group} (wallpaper {region})";
ButtonBinding b = ButtonBinding.FromWord(
_profile.Buttons.TryGetValue(addr, out int w) ? (ushort)w : (ushort)0);
_loading = true;
+ _labelBox.Text = _profile.OverlayLabels.TryGetValue(region, out string? label) ? label : string.Empty;
_kindBox.SelectedItem = b.Kind.ToString();
PopulateValues(b.Kind);
SelectValue(b.Value);
@@ -119,6 +127,7 @@ public sealed class ProfileEditorForm : Form
_alt.Checked = b.Alt;
_ext.Checked = b.Extended;
_lit.Checked = b.IsLit;
+ _lit.Visible = LampCapableOf.GetValueOrDefault(addr); // keypads have no lamp
_loading = false;
SetEditingEnabled(true);
@@ -147,7 +156,6 @@ public sealed class ProfileEditorForm : Form
_valueCombo.BeginUpdate();
_valueCombo.Items.Clear();
-
switch (kind)
{
case RioRouteKind.Keyboard:
@@ -171,7 +179,6 @@ public sealed class ProfileEditorForm : Form
_valueCombo.Items.Add(new ValueItem(c.ToString(), (byte)c));
break;
}
-
_valueCombo.EndUpdate();
UpdateModifierAvailability();
}
@@ -186,12 +193,10 @@ public sealed class ProfileEditorForm : Form
return;
}
}
- // Not in the list (e.g. an uncatalogued VK): add a raw fallback entry.
int idx = _valueCombo.Items.Add(new ValueItem($"0x{value:X2}", value));
_valueCombo.SelectedIndex = idx;
}
- // Modifiers/extended only make sense for keyboard actions.
private void UpdateModifierAvailability()
{
bool keyboard = SelectedKind() == RioRouteKind.Keyboard;
@@ -223,7 +228,7 @@ public sealed class ProfileEditorForm : Form
Ctrl = keyboard && _ctrl.Checked,
Alt = keyboard && _alt.Checked,
Extended = keyboard && _ext.Checked,
- IsLit = _lit.Checked,
+ IsLit = LampCapableOf.GetValueOrDefault(addr) && _lit.Checked, // keypads never lit
};
ushort word = binding.ToWord();
@@ -231,11 +236,29 @@ public sealed class ProfileEditorForm : Form
_profile.Buttons.Remove(addr);
else
_profile.Buttons[addr] = word;
+
+ _canvas.Refresh(addr); // update lamp shade + label on the panel
+ }
+
+ // Clear the selected button's action mapping (the label is left untouched).
+ private void Unassign()
+ {
+ if (_address is not { } addr) return;
+ _profile.Buttons.Remove(addr);
+
+ _loading = true;
+ _kindBox.SelectedItem = RioRouteKind.Keyboard.ToString();
+ PopulateValues(RioRouteKind.Keyboard);
+ if (_valueCombo.Items.Count > 0) _valueCombo.SelectedIndex = 0;
+ _shift.Checked = _ctrl.Checked = _alt.Checked = _ext.Checked = _lit.Checked = false;
+ _loading = false;
+
+ _canvas.Refresh(addr);
}
private void SetEditingEnabled(bool enabled)
{
- foreach (Control c in new Control[] { _labelBox, _kindBox, _valueCombo, _shift, _ctrl, _alt, _ext, _lit, _apply })
+ foreach (Control c in new Control[] { _labelBox, _kindBox, _valueCombo, _shift, _ctrl, _alt, _ext, _lit, _apply, _unassign })
c.Enabled = enabled;
if (enabled) UpdateModifierAvailability();
}
@@ -245,11 +268,7 @@ public sealed class ProfileEditorForm : Form
/// Select an address programmatically (for tests / offline preview).
public void SelectAddress(int address)
{
- SheetCell? cell = _cells.FirstOrDefault(c => c.Address == address);
- if (cell is not null)
- {
- _canvas.Select(address);
- OnAddressSelected(cell);
- }
+ _canvas.Select(address);
+ OnAddressSelected(address);
}
}
diff --git a/src/RioJoy.Tray/Editor/SheetCanvas.cs b/src/RioJoy.Tray/Editor/SheetCanvas.cs
deleted file mode 100644
index 0a070f8..0000000
--- a/src/RioJoy.Tray/Editor/SheetCanvas.cs
+++ /dev/null
@@ -1,57 +0,0 @@
-using System.Runtime.Versioning;
-using RioJoy.Core.Editing;
-
-namespace RioJoy.Tray.Editor;
-
-///
-/// Scrollable cockpit-sheet grid: draws the cells (via ) at a
-/// fixed cell size and raises when an address cell is
-/// clicked. Sized to the full grid so the host panel scrolls it.
-///
-[SupportedOSPlatform("windows")]
-internal sealed class SheetCanvas : Control
-{
- private const int CellW = 62;
- private const int CellH = 22;
-
- private IReadOnlyList _cells = Array.Empty();
-
- public SheetCanvas()
- {
- DoubleBuffered = true;
- BackColor = Color.FromArgb(28, 28, 28);
- }
-
- /// The RIO address currently highlighted, or null.
- public int? SelectedAddress { get; private set; }
-
- public event Action? AddressSelected;
-
- public void SetCells(IReadOnlyList cells)
- {
- _cells = cells;
- Size = SheetView.GridSize(cells, CellW, CellH);
- Invalidate();
- }
-
- public void Select(int? address)
- {
- SelectedAddress = address;
- Invalidate();
- }
-
- protected override void OnPaint(PaintEventArgs e) =>
- SheetView.Paint(e.Graphics, _cells, CellW, CellH, SelectedAddress);
-
- protected override void OnMouseDown(MouseEventArgs e)
- {
- base.OnMouseDown(e);
- SheetCell? hit = SheetView.HitTest(_cells, e.Location, CellW, CellH);
- if (hit?.Address is { } addr)
- {
- SelectedAddress = addr;
- AddressSelected?.Invoke(hit);
- Invalidate();
- }
- }
-}
diff --git a/src/RioJoy.Tray/Editor/SheetView.cs b/src/RioJoy.Tray/Editor/SheetView.cs
deleted file mode 100644
index 5c80ca9..0000000
--- a/src/RioJoy.Tray/Editor/SheetView.cs
+++ /dev/null
@@ -1,83 +0,0 @@
-using System.Runtime.Versioning;
-using RioJoy.Core.Editing;
-
-namespace RioJoy.Tray.Editor;
-
-///
-/// Draws the cockpit authoring sheet as a grid of cells at their spreadsheet
-/// (row, col) positions — the cockpit's logical layout. Address cells are filled by
-/// section and clickable; scaffolding/label cells are drawn as context text. Shared
-/// by and offline bitmap rendering.
-///
-[SupportedOSPlatform("windows")]
-public static class SheetView
-{
- public static Color SectionColor(SheetSection section) => section switch
- {
- SheetSection.Contacts or SheetSection.Letters => Color.FromArgb(232, 150, 150), // red
- SheetSection.Secondary or SheetSection.Screen => Color.FromArgb(245, 224, 150), // yellow
- SheetSection.Throttle or SheetSection.Joystick => Color.FromArgb(150, 182, 226), // blue
- SheetSection.Keypad or SheetSection.ExtKeypad => Color.FromArgb(165, 212, 165), // green
- _ => Color.Gainsboro,
- };
-
- public static Size GridSize(IReadOnlyList cells, int cellW, int cellH)
- {
- int maxCol = 0, maxRow = 0;
- foreach (SheetCell c in cells)
- {
- if (c.Col > maxCol) maxCol = c.Col;
- if (c.Row > maxRow) maxRow = c.Row;
- }
- return new Size((maxCol + 1) * cellW, (maxRow + 1) * cellH);
- }
-
- public static void Paint(
- Graphics g,
- IReadOnlyList cells,
- int cellW,
- int cellH,
- int? selectedAddress)
- {
- g.Clear(Color.FromArgb(28, 28, 28));
-
- using var addrFont = new Font("Segoe UI", 7.5f, FontStyle.Bold);
- using var textFont = new Font("Segoe UI", 7f);
- using var border = new Pen(Color.FromArgb(70, 70, 70));
-
- foreach (SheetCell cell in cells)
- {
- var r = new Rectangle(cell.Col * cellW, cell.Row * cellH, cellW, cellH);
-
- if (cell.Address is { } addr)
- {
- using var fill = new SolidBrush(SectionColor(cell.Section));
- g.FillRectangle(fill, r);
- g.DrawRectangle(border, r);
- TextRenderer.DrawText(g, cell.Text, addrFont, r, Color.Black,
- TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPrefix);
-
- if (addr == selectedAddress)
- {
- using var hi = new Pen(Color.Yellow, 2f);
- g.DrawRectangle(hi, r.X + 1, r.Y + 1, r.Width - 2, r.Height - 2);
- }
- }
- else
- {
- // Scaffolding / KEY / LABEL / section headers — context only.
- TextRenderer.DrawText(g, cell.Text, textFont, r, Color.Silver,
- TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter |
- TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix);
- }
- }
- }
-
- /// The address cell at a pixel point, or null.
- public static SheetCell? HitTest(IReadOnlyList cells, Point p, int cellW, int cellH)
- {
- int col = p.X / cellW;
- int row = p.Y / cellH;
- return cells.FirstOrDefault(c => c.Row == row && c.Col == col && c.Address is not null);
- }
-}
diff --git a/src/RioJoy.Tray/TrayApplicationContext.cs b/src/RioJoy.Tray/TrayApplicationContext.cs
index 53d466d..49de9d7 100644
--- a/src/RioJoy.Tray/TrayApplicationContext.cs
+++ b/src/RioJoy.Tray/TrayApplicationContext.cs
@@ -1,5 +1,4 @@
using System.Runtime.Versioning;
-using RioJoy.Core.Editing;
using RioJoy.Core.Mapping;
using RioJoy.Core.Profiles;
using RioJoy.Tray.Editor;
@@ -135,35 +134,11 @@ internal sealed class TrayApplicationContext : ApplicationContext
private void OpenEditor()
{
- // The cockpit sheet (config-sheet.csv) sits beside the overlay template.
- string? templatePath = _config.OverlayTemplatePath;
- string? sheetPath = string.IsNullOrWhiteSpace(templatePath)
- ? null
- : Path.Combine(Path.GetDirectoryName(Path.GetFullPath(templatePath)) ?? ".", "config-sheet.csv");
-
- if (sheetPath is null || !File.Exists(sheetPath))
- {
- MessageBox.Show(
- "Set 'OverlayTemplatePath' in the config and place 'config-sheet.csv' beside it to use the editor.",
- "RIOJoy", MessageBoxButtons.OK, MessageBoxIcon.Information);
- return;
- }
-
- IReadOnlyList cells;
- try
- {
- cells = SheetLayout.Load(sheetPath);
- }
- catch (Exception ex)
- {
- MessageBox.Show($"Could not load the cockpit sheet:\n{ex.Message}", "RIOJoy",
- MessageBoxButtons.OK, MessageBoxIcon.Error);
- return;
- }
-
+ // The cockpit panel layout is built in (CockpitPanel); the editor just needs
+ // a profile to edit.
RioProfile profile = _config.Profiles.FirstOrDefault() ?? AddBlankProfile();
- var editor = new ProfileEditorForm(cells, profile);
+ var editor = new ProfileEditorForm(profile);
editor.Saved += _ => ConfigStore.Save(_config, ConfigPath);
editor.Show();
}
diff --git a/tests/RioJoy.Core.Tests/Editing/CockpitPanelTests.cs b/tests/RioJoy.Core.Tests/Editing/CockpitPanelTests.cs
new file mode 100644
index 0000000..685ac1c
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Editing/CockpitPanelTests.cs
@@ -0,0 +1,68 @@
+using RioJoy.Core.Editing;
+using RioJoy.Core.Mapping;
+using Xunit;
+
+namespace RioJoy.Core.Tests.Editing;
+
+public class CockpitPanelTests
+{
+ [Fact]
+ public void Covers_EveryValidAddress_ExactlyOnce()
+ {
+ List addrs = CockpitPanel.Buttons().Select(b => b.Address).ToList();
+
+ // 0x00–0x47 buttons + 0x50–0x6F keypads = 104 valid addresses; 0x48–0x4F don't exist.
+ var expected = Enumerable.Range(0x00, 0x48).Concat(Enumerable.Range(0x50, 0x20)).ToHashSet();
+ Assert.Equal(expected.Count, addrs.Count); // no duplicates / no extras
+ Assert.Equal(expected, addrs.ToHashSet()); // exactly the valid set
+ Assert.DoesNotContain(0x48, addrs); // gap excluded
+ }
+
+ [Theory]
+ [InlineData(0x2F, "Upper Left MFD")]
+ [InlineData(0x20, "Upper Middle MFD")]
+ [InlineData(0x37, "Upper Right MFD")]
+ [InlineData(0x00, "Lower Right MFD")]
+ [InlineData(0x0F, "Lower Left MFD")]
+ [InlineData(0x38, "Throttle")]
+ [InlineData(0x10, "Secondary")]
+ [InlineData(0x18, "Screen")]
+ [InlineData(0x40, "Joystick / Hat")]
+ [InlineData(0x50, "Internal Keypad")]
+ [InlineData(0x6F, "External Keypad")]
+ public void Address_LandsInExpectedGroup(int address, string group)
+ {
+ PanelButton b = CockpitPanel.Buttons().Single(x => x.Address == address);
+ Assert.Equal(group, b.Group.Title);
+ }
+
+ [Fact]
+ public void Keypads_AreNotLampCapable()
+ {
+ foreach (PanelButton b in CockpitPanel.Buttons().Where(x => x.Group.Kind == PanelGroupKind.Keypad))
+ Assert.False(b.LampCapable);
+ // ...and everything else is.
+ foreach (PanelButton b in CockpitPanel.Buttons().Where(x => x.Group.Kind != PanelGroupKind.Keypad))
+ Assert.True(b.LampCapable);
+ }
+
+ [Fact]
+ public void Keypad_PhysicalLayout_MapsDigitsToAddresses()
+ {
+ // Internal keypad: top-left "1" -> 0x51, bottom-middle "0" -> 0x50, "C" -> 0x5C.
+ IReadOnlyList all = CockpitPanel.Buttons();
+ PanelGroup pad = CockpitPanel.Groups.Single(g => g.Title == "Internal Keypad");
+
+ Assert.Equal(0x51, pad.Addresses[0]); // row0 col0 = "1"
+ Assert.Equal(0x5C, pad.Addresses[3]); // row0 col3 = "C"
+ Assert.Equal(0x50, pad.Addresses[13]); // row3 col1 = "0"
+ Assert.Equal(0x5F, pad.Addresses[15]); // row3 col3 = "F"
+ }
+
+ [Fact]
+ public void AllAddresses_AreValidRioAddresses()
+ {
+ foreach (PanelButton b in CockpitPanel.Buttons())
+ Assert.InRange(b.Address, 0, RioAddress.MaxAddress);
+ }
+}
diff --git a/tests/RioJoy.Core.Tests/Mapping/ButtonBindingTests.cs b/tests/RioJoy.Core.Tests/Mapping/ButtonBindingTests.cs
index 5d67faa..51b14b7 100644
--- a/tests/RioJoy.Core.Tests/Mapping/ButtonBindingTests.cs
+++ b/tests/RioJoy.Core.Tests/Mapping/ButtonBindingTests.cs
@@ -81,4 +81,17 @@ public class ButtonBindingTests
Assert.True(new ButtonBinding().IsUnmapped);
Assert.False(new ButtonBinding { Value = 0x41 }.IsUnmapped);
}
+
+ [Fact]
+ public void Describe_FormatsEachKind()
+ {
+ Assert.Equal("", new ButtonBinding().Describe());
+ Assert.Equal("K", new ButtonBinding { Value = 0x4B }.Describe());
+ Assert.Equal("CTL-N", new ButtonBinding { Value = 0x4E, Ctrl = true }.Describe());
+ Assert.Equal("CTL-SFT-K", new ButtonBinding { Value = 0x4B, Ctrl = true, Shift = true }.Describe());
+ Assert.Equal("JOY1", new ButtonBinding { Kind = RioRouteKind.Joystick, Value = 1 }.Describe());
+ Assert.Equal("POV-U", new ButtonBinding { Kind = RioRouteKind.Hat, Value = (byte)(int)RioHat.Up }.Describe());
+ Assert.Equal("Mse LB", new ButtonBinding { Kind = RioRouteKind.Mouse, Value = (byte)MouseAction.LeftClick }.Describe());
+ Assert.Equal("ResetThrottle", new ButtonBinding { Kind = RioRouteKind.RioCommand, Value = (byte)RioCommandCode.ResetThrottle }.Describe());
+ }
}