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:
+16
-15
@@ -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.
|
||||
|
||||
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 |
@@ -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.
@@ -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 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.
|
||||
/// </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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -245,11 +268,7 @@ 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);
|
||||
}
|
||||
_canvas.Select(address);
|
||||
OnAddressSelected(address);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
// 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<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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user