Editor: rework to the original Win32 RIO control-panel layout

Reworked the profile/mapping editor to mirror the original unfinished Win32
RIO driver's control-panel design (docs/Win32RIO, by FASA / Michel Lowrance)
instead of the flat config-sheet grid. The wallpaper region positions are a
VGA chroma-split display artifact, not the cockpit's logical shape.

- RioJoy.Core.Editing.CockpitPanel: the functional layout — five MFD clusters,
  four board columns (Throttle/Secondary/Screen/Joystick-Hat), an encoder-gauge
  strip, and the two later-added 4x4 keypads (Internal/External). Places every
  address 0x00-0x47 / 0x50-0x6F exactly once (unit-tested).
- Tray PanelView/PanelCanvas render the panel; ProfileEditorForm drives it.
  Buttons show label + assigned function (ButtonBinding.Describe -> CTL-N, JOY1,
  POV-U, Mse LB, RIO command names; unit-tested). Lamp shade is driven by the
  IsLit flag (not by whether a function is assigned); keypads are neutral blue
  with no Lit checkbox. Added an Unassign button next to Apply.
- Removed the superseded sheet-grid UI (SheetView/SheetCanvas); SheetLayout +
  config-sheet.csv stay as reference data.

docs/Win32RIO: the original driver (tasgame.sys, a 32-bit kernel HID minidriver
that opened the serial port, set baud/8N1/DTR, and spoke the *identical* RIO
protocol -- AnalogRequest/Reply, Button Pressed/Released, Check/Lamp/Reset/
Version -- validating our protocol port), oemsetup.inf, RemoteDriver.doc, and the
extracted control-panel / game-controllers mockups.

~236 xUnit tests green. PLAN.md updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-06-27 19:48:14 -05:00
co-authored by Claude Opus 4.8
parent 8d2d0b71aa
commit 094637b0a4
17 changed files with 625 additions and 212 deletions
+16 -15
View File
@@ -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 0x000x47 / 0x500x6F 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.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

+133
View File
@@ -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"
Binary file not shown.
Binary file not shown.
+109
View File
@@ -0,0 +1,109 @@
namespace RioJoy.Core.Editing;
/// <summary>Shape of a panel group.</summary>
public enum PanelGroupKind
{
/// <summary>A 4×2 MFD button cluster (lamp buttons).</summary>
Mfd,
/// <summary>A vertical 1×8 board column (lamp buttons).</summary>
Column,
/// <summary>A 4×4 keypad (added later; no lamps).</summary>
Keypad,
}
/// <summary>
/// One functional group on the cockpit control panel: a titled block of address
/// buttons laid out in <see cref="Rows"/> × <see cref="Cols"/> (row-major, null =
/// empty slot). <see cref="OriginCol"/>/<see cref="OriginRow"/> place the block on
/// the panel in button-cell units (the renderer scales cells to pixels).
/// </summary>
public sealed record PanelGroup(
string Title,
PanelGroupKind Kind,
int Cols,
int Rows,
bool LampCapable,
int OriginCol,
int OriginRow,
IReadOnlyList<int?> Addresses);
/// <summary>One address button placed on the panel (absolute cell coordinates).</summary>
public sealed record PanelButton(int Address, PanelGroup Group, int Col, int Row, bool LampCapable);
/// <summary>
/// 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 0x000x47 / 0x500x6F is placed exactly once. Pure data so the mapping
/// is unit-tested; the WinForms editor renders these groups and binds each button.
/// </summary>
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;
}
/// <summary>All panel groups, positioned for rendering.</summary>
public static IReadOnlyList<PanelGroup> 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)),
};
/// <summary>Flatten the groups to positioned <see cref="PanelButton"/>s (absolute cells).</summary>
public static IReadOnlyList<PanelButton> Buttons()
{
var buttons = new List<PanelButton>();
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;
}
}
+33
View File
@@ -34,6 +34,39 @@ public sealed class ButtonBinding
/// <summary>True when this binding does nothing (an empty slot).</summary>
public bool IsUnmapped => ToWord() == 0;
/// <summary>
/// A short human-readable description of the assigned function — e.g.
/// <c>CTL-N</c>, <c>JOY1</c>, <c>POV-U</c>, <c>Mse LB</c>, or a RIO command name.
/// Empty when unmapped. Used as the editor cell caption.
/// </summary>
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,
};
/// <summary>Pack to the 16-bit <c>iRIO</c> map word.</summary>
public ushort ToWord() =>
RioMapEntry.Create(Kind, Value, IsLit, Shift, Ctrl, Alt, Extended).Raw;
+67
View File
@@ -0,0 +1,67 @@
using System.Runtime.Versioning;
using RioJoy.Core.Editing;
namespace RioJoy.Tray.Editor;
/// <summary>
/// Interactive cockpit control-panel map: draws the functional groups (via
/// <see cref="PanelView"/>) and raises <see cref="AddressSelected"/> when a button
/// is clicked. Sized to the full panel so the host scrolls it.
/// </summary>
[SupportedOSPlatform("windows")]
internal sealed class PanelCanvas : Control
{
private static readonly IReadOnlyList<PanelButton> AllButtons = CockpitPanel.Buttons();
public PanelCanvas()
{
DoubleBuffered = true;
BackColor = Color.FromArgb(28, 28, 28);
Size = PanelView.GridSize(AllButtons);
}
/// <summary>Resolves the display label for an address (from the profile).</summary>
public Func<int, string?>? LabelProvider { get; set; }
/// <summary>Resolves the assigned-function caption for an address (empty = unmapped).</summary>
public Func<int, string?>? FunctionProvider { get; set; }
/// <summary>Whether an address's lamp is lit (the binding's IsLit flag) — drives the lamp shade.</summary>
public Func<int, bool>? LitProvider { get; set; }
public int? SelectedAddress { get; private set; }
public event Action<int>? 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();
}
}
}
+135
View File
@@ -0,0 +1,135 @@
using System.Runtime.Versioning;
using RioJoy.Core.Editing;
namespace RioJoy.Tray.Editor;
/// <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>
[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<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;
}
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<PanelButton> buttons,
IReadOnlyList<PanelGroup> groups,
Func<int, string?> label,
Func<int, string?> function,
Func<int, bool> 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<PanelButton> buttons, Point p)
{
foreach (PanelButton b in buttons)
if (Cell(b.Col, b.Row).Contains(p))
return b;
return null;
}
}
+47 -28
View File
@@ -7,9 +7,10 @@ using RioJoy.Core.Profiles;
namespace RioJoy.Tray.Editor;
/// <summary>
/// Profile editor: shows the cockpit authoring sheet (its logical layout, from
/// <see cref="SheetLayout"/>) 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 <see cref="CockpitPanel"/>) as a clickable map, and edits the
/// selected button's label and action/modifiers/lamp. Writes back to the
/// <see cref="RioProfile"/>'s <see cref="RioProfile.OverlayLabels"/> (keyed by the
/// wallpaper region <c>b-XX</c>) and <see cref="RioProfile.Buttons"/> (the iRIO
/// word); <c>Save</c> raises <see cref="Saved"/>.
@@ -17,9 +18,14 @@ namespace RioJoy.Tray.Editor;
[SupportedOSPlatform("windows")]
public sealed class ProfileEditorForm : Form
{
private static readonly Dictionary<int, string> GroupOf =
CockpitPanel.Buttons().ToDictionary(b => b.Address, b => b.Group.Title);
private static readonly Dictionary<int, bool> LampCapableOf =
CockpitPanel.Buttons().ToDictionary(b => b.Address, b => b.LampCapable);
private readonly RioProfile _profile;
private readonly IReadOnlyList<SheetCell> _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
/// <summary>Raised when the user saves; the argument is the edited profile.</summary>
public event Action<RioProfile>? Saved;
public ProfileEditorForm(IReadOnlyList<SheetCell> 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<RioRouteKind>());
_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.";
}
/// <summary>The selected RIO address (for tests / offline rendering).</summary>
@@ -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();
}
@@ -244,12 +267,8 @@ public sealed class ProfileEditorForm : Form
/// <summary>Select an address programmatically (for tests / offline preview).</summary>
public void SelectAddress(int address)
{
SheetCell? cell = _cells.FirstOrDefault(c => c.Address == address);
if (cell is not null)
{
_canvas.Select(address);
OnAddressSelected(cell);
}
OnAddressSelected(address);
}
}
-57
View File
@@ -1,57 +0,0 @@
using System.Runtime.Versioning;
using RioJoy.Core.Editing;
namespace RioJoy.Tray.Editor;
/// <summary>
/// Scrollable cockpit-sheet grid: draws the cells (via <see cref="SheetView"/>) at a
/// fixed cell size and raises <see cref="AddressSelected"/> when an address cell is
/// clicked. Sized to the full grid so the host panel scrolls it.
/// </summary>
[SupportedOSPlatform("windows")]
internal sealed class SheetCanvas : Control
{
private const int CellW = 62;
private const int CellH = 22;
private IReadOnlyList<SheetCell> _cells = Array.Empty<SheetCell>();
public SheetCanvas()
{
DoubleBuffered = true;
BackColor = Color.FromArgb(28, 28, 28);
}
/// <summary>The RIO address currently highlighted, or null.</summary>
public int? SelectedAddress { get; private set; }
public event Action<SheetCell>? AddressSelected;
public void SetCells(IReadOnlyList<SheetCell> 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();
}
}
}
-83
View File
@@ -1,83 +0,0 @@
using System.Runtime.Versioning;
using RioJoy.Core.Editing;
namespace RioJoy.Tray.Editor;
/// <summary>
/// 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 <see cref="SheetCanvas"/> and offline bitmap rendering.
/// </summary>
[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<SheetCell> 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<SheetCell> 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);
}
}
}
/// <summary>The address cell at a pixel point, or null.</summary>
public static SheetCell? HitTest(IReadOnlyList<SheetCell> 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);
}
}
+3 -28
View File
@@ -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<SheetCell> 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();
}
@@ -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<int> addrs = CockpitPanel.Buttons().Select(b => b.Address).ToList();
// 0x000x47 buttons + 0x500x6F keypads = 104 valid addresses; 0x480x4F 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<PanelButton> 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);
}
}
@@ -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());
}
}