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