Phase 7: cockpit overlay generator, region extraction, and profile editor

Overlay generator (pure, tested) — RioJoy.Core/Overlay:
- FontFitter ports the legacy calc-fontsize auto-fit search (validated against a
  brute-force oracle); OverlayLayoutEngine ports create-data-layer's fit + h/v
  justification and adds per-region 90 deg CCW rotation; OverlayTemplate/Region +
  OverlayTemplateStore hold cell geometry/colour/rotation (regions.json).
- GoobieDataImporter parses the legacy .data label sheet into label rows.
- src/RioJoy.Overlay: SkiaSharp rasterizer (SkiaTextMeasurer shared with the engine
  so measured layout == drawn output; SkiaOverlayRenderer -> PNG;
  ProfileWallpaperGenerator). Verified end-to-end on the real cockpit art
  (regions.json + riojoy.png + TEST.data) by OverlayRenderIntegrationTests.
- RioJoy.Tray/WallpaperApplier applies via SystemParametersInfo; RioCoordinator
  generates+applies on profile activation (opt-in via AppConfig.OverlayTemplatePath).

Region geometry — tools/XcfRegionExtract parses riojoy.xcf (a GIMP-format binary
reader, no GIMP needed) into the 119-cell regions.json: per-layer offsets/size/
font/colour, with 90 deg CCW rotation on a-00 + b-10..b-1F. Base image riojoy.png.
NOTE: the wallpaper positions are a VGA chroma-split display artifact (6 displays),
not the cockpit's logical layout.

Mapping editor (first cut) — RioJoy.Tray/Editor/ProfileEditorForm renders the
config sheet's logical grid (SheetLayout parses the sheet CSV export). The iRIO
word is edited via ButtonBinding <-> RioMapEntry.Create (no hex bit-twiddling), with
a context-sensitive picker: keyboard keys by name (KeyCatalog VK names), joystick
Button N, hat direction, mouse/RIO-command enum names; modifiers only for keyboard.
Opened from the tray ("Edit profile..."). The sheet-grid layout is a starting point
that needs rework from a better-formatted source.

~220 xUnit tests green. Docs (PLAN.md, README) updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-06-27 17:13:48 -05:00
co-authored by Claude Opus 4.8
parent 2deeb374ae
commit 8d2d0b71aa
47 changed files with 3866 additions and 21 deletions
+120
View File
@@ -0,0 +1,120 @@
using System.Globalization;
using System.Text;
namespace RioJoy.Core.Editing;
/// <summary>Section of the authoring sheet a cell belongs to (drives editor colour).</summary>
public enum SheetSection
{
None,
Contacts, // 0x000x0F
Secondary, // 0x100x17
Screen, // 0x180x1F
Letters, // 0x200x37
Throttle, // 0x380x3F
Joystick, // 0x400x47
Keypad, // 0x500x5F
ExtKeypad, // 0x600x6F
}
/// <summary>
/// One cell of the cockpit authoring spreadsheet: its grid <see cref="Row"/>/<see cref="Col"/>,
/// the cell <see cref="Text"/>, and — when the text is a RIO address (two hex
/// digits, 0x000x6F) — the <see cref="Address"/> it edits and its
/// <see cref="Section"/>.
/// </summary>
public sealed record SheetCell(int Row, int Col, string Text, int? Address, SheetSection Section);
/// <summary>
/// Parses the legacy authoring spreadsheet (exported CSV, see
/// docs/reference/customBackground/config-sheet.csv) into a grid of
/// <see cref="SheetCell"/>s. This is the cockpit's <em>logical</em> layout — the
/// shape the editor mirrors — as opposed to the wallpaper's pixel positions, which
/// are a VGA chroma-split display artifact. Pure, so it is unit-tested.
/// </summary>
public static class SheetLayout
{
public static IReadOnlyList<SheetCell> Load(string path) => Parse(File.ReadAllText(path));
/// <summary>
/// The RIO address a cell's text encodes (two hex digits in 0x000x6F), or null.
/// </summary>
public static int? AddressFromText(string text)
{
string t = text.Trim();
if (t.Length != 2) return null;
if (!int.TryParse(t, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out int a)) return null;
return a is >= 0 and <= 0x6F ? a : null;
}
/// <summary>Classify an address into a sheet section.</summary>
public static SheetSection SectionForAddress(int address) => address switch
{
>= 0x00 and <= 0x0F => SheetSection.Contacts,
>= 0x10 and <= 0x17 => SheetSection.Secondary,
>= 0x18 and <= 0x1F => SheetSection.Screen,
>= 0x20 and <= 0x37 => SheetSection.Letters,
>= 0x38 and <= 0x3F => SheetSection.Throttle,
>= 0x40 and <= 0x47 => SheetSection.Joystick,
>= 0x50 and <= 0x5F => SheetSection.Keypad,
>= 0x60 and <= 0x6F => SheetSection.ExtKeypad,
_ => SheetSection.None,
};
/// <summary>
/// Parse the CSV (one spreadsheet row per line) into non-empty cells. Cell text
/// longer than <paramref name="maxTextLength"/> is dropped as a merged-cell
/// artifact; the clean export has none.
/// </summary>
public static IReadOnlyList<SheetCell> Parse(string csv, int maxTextLength = 40)
{
ArgumentNullException.ThrowIfNull(csv);
string[] lines = csv.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
var cells = new List<SheetCell>();
for (int row = 0; row < lines.Length; row++)
{
if (lines[row].Length == 0) continue;
List<string> fields = ParseLine(lines[row]);
for (int col = 0; col < fields.Count; col++)
{
string text = fields[col].Trim();
if (text.Length == 0 || text.Length > maxTextLength) continue;
int? address = AddressFromText(text);
SheetSection section = address is { } a ? SectionForAddress(a) : SheetSection.None;
cells.Add(new SheetCell(row, col, text, address, section));
}
}
return cells;
}
// Split one CSV line into fields, honouring "quoted" fields and "" escapes.
private static List<string> ParseLine(string line)
{
var fields = new List<string>();
var sb = new StringBuilder();
bool inQuotes = false;
for (int i = 0; i < line.Length; i++)
{
char c = line[i];
if (inQuotes)
{
if (c == '"')
{
if (i + 1 < line.Length && line[i + 1] == '"') { sb.Append('"'); i++; }
else inQuotes = false;
}
else sb.Append(c);
}
else if (c == '"') inQuotes = true;
else if (c == ',') { fields.Add(sb.ToString()); sb.Clear(); }
else sb.Append(c);
}
fields.Add(sb.ToString());
return fields;
}
}
+56
View File
@@ -0,0 +1,56 @@
namespace RioJoy.Core.Mapping;
/// <summary>
/// A mutable, editor-friendly view of one <c>iRIO</c> map word: the routing
/// <see cref="Kind"/>, payload <see cref="Value"/>, lamp flag, and keyboard
/// modifiers. Two-way binds cleanly to UI controls and converts to/from the packed
/// 16-bit word via <see cref="ToWord"/> / <see cref="FromWord"/> (which round-trip
/// through <see cref="RioMapEntry"/>). The button's display label is stored
/// separately (the profile's overlay labels), not here.
/// </summary>
public sealed class ButtonBinding
{
/// <summary>How the input is routed (keyboard / joystick / hat / mouse / RIO command).</summary>
public RioRouteKind Kind { get; set; } = RioRouteKind.Keyboard;
/// <summary>Payload byte: VK code, joystick button #, hat direction, or mouse action.</summary>
public byte Value { get; set; }
/// <summary>Lighted button (lamp feedback on press/release).</summary>
public bool IsLit { get; set; }
/// <summary>Keyboard modifier: SHIFT.</summary>
public bool Shift { get; set; }
/// <summary>Keyboard modifier: CTRL.</summary>
public bool Ctrl { get; set; }
/// <summary>Keyboard modifier: ALT.</summary>
public bool Alt { get; set; }
/// <summary>Apply <c>KEYEVENTF_EXTENDEDKEY</c> to the key.</summary>
public bool Extended { get; set; }
/// <summary>True when this binding does nothing (an empty slot).</summary>
public bool IsUnmapped => ToWord() == 0;
/// <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;
/// <summary>Build an editable binding from a packed map word.</summary>
public static ButtonBinding FromWord(ushort raw)
{
var e = new RioMapEntry(raw);
return new ButtonBinding
{
Kind = e.Kind,
Value = e.Value,
IsLit = e.HasLamp,
Shift = e.Shift,
Ctrl = e.Ctrl,
Alt = e.Alt,
Extended = e.Extended,
};
}
}
+83
View File
@@ -0,0 +1,83 @@
namespace RioJoy.Core.Mapping;
/// <summary>A named keyboard key and its Windows virtual-key code.</summary>
public readonly record struct KeyName(string Name, byte Value);
/// <summary>
/// Friendly Windows virtual-key names ↔ VK codes, for the profile editor's key
/// picker so a keyboard action is chosen by name instead of a raw hex byte. The
/// keyboard <c>iRIO</c> payload is a VK code (see <see cref="InputRouter"/> /
/// <c>SendInputSink</c>); modifiers are separate flags, so they are not listed here
/// as keys.
/// </summary>
public static class KeyCatalog
{
/// <summary>All catalogued keys, in menu order (letters, digits, F-keys, …).</summary>
public static IReadOnlyList<KeyName> Entries { get; } = Build();
private static readonly Dictionary<byte, string> ByValue =
Entries.GroupBy(e => e.Value).ToDictionary(g => g.Key, g => g.First().Name);
private static readonly Dictionary<string, byte> ByName =
Entries.ToDictionary(e => e.Name, e => e.Value, StringComparer.OrdinalIgnoreCase);
/// <summary>Friendly name for a VK code, or <c>"0xHH"</c> when uncatalogued.</summary>
public static string NameFor(byte vk) => ByValue.TryGetValue(vk, out string? n) ? n : $"0x{vk:X2}";
/// <summary>Look up a VK code by friendly name.</summary>
public static bool TryGetValue(string name, out byte vk) => ByName.TryGetValue(name, out vk);
private static List<KeyName> Build()
{
var list = new List<KeyName>();
void Add(string name, int vk) => list.Add(new KeyName(name, (byte)vk));
for (char c = 'A'; c <= 'Z'; c++) Add(c.ToString(), c); // 0x410x5A
for (int d = 0; d <= 9; d++) Add(d.ToString(), 0x30 + d); // 0x300x39
for (int f = 1; f <= 12; f++) Add($"F{f}", 0x6F + f); // F1F12 = 0x700x7B
Add("Space", 0x20);
Add("Enter", 0x0D);
Add("Escape", 0x1B);
Add("Tab", 0x09);
Add("Backspace", 0x08);
Add("Delete", 0x2E);
Add("Insert", 0x2D);
Add("Home", 0x24);
Add("End", 0x23);
Add("Page Up", 0x21);
Add("Page Down", 0x22);
Add("Left", 0x25);
Add("Up", 0x26);
Add("Right", 0x27);
Add("Down", 0x28);
for (int n = 0; n <= 9; n++) Add($"NumPad {n}", 0x60 + n); // 0x600x69
Add("NumPad *", 0x6A);
Add("NumPad +", 0x6B);
Add("NumPad -", 0x6D);
Add("NumPad .", 0x6E);
Add("NumPad /", 0x6F);
Add("; :", 0xBA);
Add("= +", 0xBB);
Add(", <", 0xBC);
Add("- _", 0xBD);
Add(". >", 0xBE);
Add("/ ?", 0xBF);
Add("` ~", 0xC0);
Add("[ {", 0xDB);
Add("\\ |", 0xDC);
Add("] }", 0xDD);
Add("' \"", 0xDE);
Add("Caps Lock", 0x14);
Add("Print Screen", 0x2C);
Add("Scroll Lock", 0x91);
Add("Pause", 0x13);
Add("Num Lock", 0x90);
return list;
}
}
+36
View File
@@ -47,6 +47,42 @@ public readonly struct RioMapEntry
public RioMapEntry(ushort raw) => Raw = raw;
/// <summary>
/// Build a map word from decoded components (the inverse of the decode
/// properties) — used by the profile editor to write the <c>iRIO</c> table.
/// <paramref name="kind"/> sets the routing flags; modifiers/lamp/extended set
/// their high-byte bits; <paramref name="value"/> is the low byte.
/// </summary>
public static RioMapEntry Create(
RioRouteKind kind,
byte value,
bool lit = false,
bool shift = false,
bool ctrl = false,
bool alt = false,
bool extended = false)
{
ushort raw = value;
raw |= kind switch
{
RioRouteKind.Keyboard => 0,
RioRouteKind.Joystick => FlagJoy,
RioRouteKind.Hat => FlagHat,
RioRouteKind.Mouse => FlagMouse,
RioRouteKind.RioCommand => RioCommandMask,
_ => 0,
};
if (lit) raw |= FlagHasLamp;
if (extended) raw |= FlagExtended;
if (alt) raw |= FlagAlt;
if (ctrl) raw |= FlagCtrl;
if (shift) raw |= FlagShift;
return new RioMapEntry(raw);
}
/// <summary>This input drives a lighted button (lamp feedback on press/release).</summary>
public bool HasLamp => (Raw & FlagHasLamp) != 0;
+64
View File
@@ -0,0 +1,64 @@
namespace RioJoy.Core.Overlay;
/// <summary>
/// Finds the largest font size whose text fits a cell — a faithful port of the
/// legacy <c>calc-fontsize</c> Script-Fu routine
/// (docs/reference/customBackground/sg-goobie-MFD.scm, lines 5067).
///
/// <para>The original is an expand-then-narrow search: grow the size by a factor
/// (<c>adjust</c>, starting at 2) while the text fits; on the first overflow, halve
/// the growth factor's excess-over-1 and retry from the last size that fit. It
/// converges when the (truncated) size stops changing or the measured extents
/// repeat, and never returns below <see cref="MinFontSize"/>.</para>
/// </summary>
public static class FontFitter
{
/// <summary>Minimum font size the legacy routine will return (px).</summary>
public const double MinFontSize = 6;
// Guard against a pathological measurer that never converges.
private const int MaxIterations = 1000;
/// <summary>
/// Largest font size (px) at which <paramref name="text"/> fits within
/// <paramref name="width"/> × <paramref name="height"/>, measured by
/// <paramref name="measurer"/>.
/// </summary>
public static double BestFitSize(
string text, string fontFamily, double width, double height, ITextMeasurer measurer)
{
ArgumentNullException.ThrowIfNull(measurer);
// State mirrors the Scheme named-let: (fontsize last-extents last-fontsize adjust).
double fontsize = 6; // minimum possible fontsize (the loop's seed)
double lastFontsize = 3;
double adjust = 2;
TextExtent? lastExtents = null;
for (int i = 0; i < MaxIterations; i++)
{
TextExtent extents = measurer.Measure(text, fontFamily, fontsize);
// Converged: size stopped moving, or the extents repeated.
if (lastFontsize == fontsize || (lastExtents is { } prev && prev == extents))
return Math.Max(fontsize, MinFontSize);
if (extents.Width > width || extents.Height > height)
{
// Overflow: shrink the growth factor and retry from the last good size.
adjust = (adjust - 1) * 0.5 + 1;
fontsize = Math.Truncate(lastFontsize * adjust);
// lastExtents / lastFontsize unchanged — they hold the last fit.
}
else
{
// Fits: remember it and keep growing by the current factor.
lastExtents = extents;
lastFontsize = fontsize;
fontsize = Math.Truncate(fontsize * adjust);
}
}
return Math.Max(fontsize, MinFontSize);
}
}
@@ -0,0 +1,118 @@
using System.Text;
namespace RioJoy.Core.Overlay;
/// <summary>
/// Imports the legacy "Goobie" label data file — the Google-Sheet export consumed
/// by sg-goobie-MFD.scm (see docs/reference/customBackground/TEST.data). The format
/// is Lisp-ish: <c>;</c> starts a comment to end of line, a parenthesized list of
/// bare field names (matching the template layer/region names) comes first, then
/// one parenthesized list of quoted strings per output image (row). Field 0 is the
/// row's file tag (output name); the rest are the per-region label text.
///
/// <para>Each row maps directly to an <see cref="OverlayLayoutEngine"/> label set
/// (region name → text), so this is the bridge from the old sheet to a profile's
/// <see cref="Profiles.RioProfile.OverlayLabels"/>.</para>
/// </summary>
public static class GoobieDataImporter
{
/// <summary>A parsed data file: the field names and one label map per row.</summary>
public sealed record Sheet(
IReadOnlyList<string> Fields,
IReadOnlyList<IReadOnlyDictionary<string, string>> Rows);
public static Sheet Load(string path) => Parse(File.ReadAllText(path));
/// <summary>Parse the data file text into fields + rows.</summary>
public static Sheet Parse(string text)
{
ArgumentNullException.ThrowIfNull(text);
List<List<string>> lists = ReadLists(text);
if (lists.Count == 0)
throw new FormatException("No lists found; expected a header list of field names.");
List<string> fields = lists[0];
var rows = new List<IReadOnlyDictionary<string, string>>();
for (int i = 1; i < lists.Count; i++)
{
List<string> values = lists[i];
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (int f = 0; f < fields.Count && f < values.Count; f++)
map[fields[f]] = values[f];
rows.Add(map);
}
return new Sheet(fields, rows);
}
/// <summary>
/// Convenience: the label map for a single row (default the first), with empty
/// values dropped — ready to hand to <see cref="OverlayLayoutEngine.Layout"/>.
/// </summary>
public static IReadOnlyDictionary<string, string> LabelsForRow(Sheet sheet, int rowIndex = 0)
{
ArgumentNullException.ThrowIfNull(sheet);
if (rowIndex < 0 || rowIndex >= sheet.Rows.Count)
throw new ArgumentOutOfRangeException(nameof(rowIndex));
return sheet.Rows[rowIndex]
.Where(kv => !string.IsNullOrEmpty(kv.Value))
.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase);
}
// Tokenize into parenthesized lists. Barewords and "quoted strings" are both
// returned as their text (quotes stripped); ';' comments run to end of line.
private static List<List<string>> ReadLists(string s)
{
var lists = new List<List<string>>();
List<string>? current = null;
int i = 0;
while (i < s.Length)
{
char c = s[i];
if (c == ';')
{
while (i < s.Length && s[i] != '\n') i++;
}
else if (char.IsWhiteSpace(c))
{
i++;
}
else if (c == '(')
{
current = new List<string>();
i++;
}
else if (c == ')')
{
if (current is not null) { lists.Add(current); current = null; }
i++;
}
else if (c == '"')
{
i++;
var sb = new StringBuilder();
while (i < s.Length && s[i] != '"')
{
if (s[i] == '\\' && i + 1 < s.Length) i++; // unescape \" and \\
sb.Append(s[i++]);
}
i++; // closing quote
current?.Add(sb.ToString());
}
else
{
int start = i;
while (i < s.Length && !char.IsWhiteSpace(s[i]) && s[i] is not ('(' or ')' or '"' or ';'))
i++;
current?.Add(s[start..i]);
}
}
return lists;
}
}
+17
View File
@@ -0,0 +1,17 @@
namespace RioJoy.Core.Overlay;
/// <summary>The pixel size of a rendered string (its bounding box).</summary>
public readonly record struct TextExtent(double Width, double Height);
/// <summary>
/// Measures rendered text. Abstracts the rasterizer (SkiaSharp, GDI, …) so the
/// overlay layout math (<see cref="FontFitter"/>, <see cref="OverlayLayoutEngine"/>)
/// stays pure and unit-testable against a fake measurer — the same split the rest
/// of the codebase uses for output sinks. The legacy pipeline measured with GIMP's
/// <c>gimp-text-get-extents-fontname</c> (see docs/reference/customBackground/sg-goobie-MFD.scm).
/// </summary>
public interface ITextMeasurer
{
/// <summary>Bounding-box size of <paramref name="text"/> at the given font/size, in pixels.</summary>
TextExtent Measure(string text, string fontFamily, double fontSizePx);
}
@@ -0,0 +1,97 @@
namespace RioJoy.Core.Overlay;
/// <summary>
/// Lays out per-region label text over a cockpit template into positioned
/// <see cref="PlacedLabel"/>s ready for a rasterizer. Pure: all measurement goes
/// through an <see cref="ITextMeasurer"/>, so the placement/auto-fit math is
/// unit-tested without a real font. Ports the per-field logic of
/// <c>create-data-layer</c> (sg-goobie-MFD.scm, lines 69113): optional auto-fit
/// when the text overflows, then vertical (and here also horizontal) justification.
/// </summary>
public sealed class OverlayLayoutEngine
{
private readonly ITextMeasurer _measurer;
public OverlayLayoutEngine(ITextMeasurer measurer) =>
_measurer = measurer ?? throw new ArgumentNullException(nameof(measurer));
/// <summary>
/// Lay out every region that has non-empty label text. Regions without an entry
/// in <paramref name="labels"/> (or with empty text) are skipped — matching the
/// legacy behavior where a blank field produced no visible text.
/// </summary>
public IReadOnlyList<PlacedLabel> Layout(
OverlayTemplate template,
IReadOnlyDictionary<string, string> labels,
OverlayLayoutOptions? options = null)
{
ArgumentNullException.ThrowIfNull(template);
ArgumentNullException.ThrowIfNull(labels);
options ??= new OverlayLayoutOptions();
var placed = new List<PlacedLabel>(template.Regions.Count);
foreach (OverlayRegion region in template.Regions)
{
if (labels.TryGetValue(region.Name, out string? text) && !string.IsNullOrEmpty(text))
placed.Add(Place(region, text, options));
}
return placed;
}
/// <summary>Lay out a single label within a region.</summary>
public PlacedLabel Place(OverlayRegion region, string text, OverlayLayoutOptions? options = null)
{
ArgumentNullException.ThrowIfNull(region);
ArgumentException.ThrowIfNullOrEmpty(text);
options ??= new OverlayLayoutOptions();
double rotation = region.RotationDegrees ?? 0;
// A quarter turn swaps the axes the text is fitted against: a 90°/270° label
// runs its length along the cell's height and its line-height along the width.
bool quarterTurn = (int)Math.Abs(rotation) % 180 == 90;
double fitWidth = quarterTurn ? region.Height : region.Width;
double fitHeight = quarterTurn ? region.Width : region.Height;
double size = region.FontSizePx;
TextExtent ext = _measurer.Measure(text, region.FontFamily, size);
// Auto-fit only in Fit mode and only when the base size actually overflows
// (legacy: the calc-fontsize call is guarded by size-handling == 0 && overflow).
if (options.SizeMode == OverlaySizeMode.Fit && (ext.Width > fitWidth || ext.Height > fitHeight))
{
size = FontFitter.BestFitSize(text, region.FontFamily, fitWidth, fitHeight, _measurer);
ext = _measurer.Measure(text, region.FontFamily, size);
}
if (rotation != 0)
{
// Center the (unrotated) text box in the cell; the renderer turns it about
// the cell center, so a centered box rotates in place. Justification N/A.
double cxLeft = region.X + (region.Width - ext.Width) / 2;
double cyTop = region.Y + (region.Height - ext.Height) / 2;
return new PlacedLabel(region.Name, text, cxLeft, cyTop, ext.Width, ext.Height, region.FontFamily, size, rotation);
}
OverlayHJust hjust = region.HJust ?? options.HJust;
OverlayVJust vjust = region.VJust ?? options.VJust;
// Vertical placement is a direct port of the script; horizontal is the
// generalization of its left-anchored behavior (its header comment intends
// "centered within the bounds", so Center is the engine default).
double x = hjust switch
{
OverlayHJust.Center => region.X + (region.Width - ext.Width) / 2,
OverlayHJust.Right => region.X + (region.Width - ext.Width),
_ => region.X,
};
double y = vjust switch
{
OverlayVJust.Center => region.Y + (region.Height - ext.Height) / 2,
OverlayVJust.Bottom => region.Y + region.Height - ext.Height,
_ => region.Y,
};
return new PlacedLabel(region.Name, text, x, y, ext.Width, ext.Height, region.FontFamily, size);
}
}
+115
View File
@@ -0,0 +1,115 @@
namespace RioJoy.Core.Overlay;
/// <summary>Horizontal placement of a label within its region.</summary>
public enum OverlayHJust { Left, Center, Right }
/// <summary>Vertical placement of a label within its region (legacy "Vertical justification").</summary>
public enum OverlayVJust { Top, Center, Bottom }
/// <summary>
/// What to do when a label's text does not fit its region at the base font size.
/// Mirrors the legacy script's "Font sizing (if too large)" option
/// (sg-goobie-MFD.scm): <see cref="Fit"/> shrinks the font to fit (auto-fit),
/// <see cref="Crop"/> keeps the size and clips to the region, <see cref="Overflow"/>
/// keeps the size and lets the text spill outside the region.
/// </summary>
public enum OverlaySizeMode { Fit, Crop, Overflow }
/// <summary>
/// One labelled cell on a cockpit template: the rectangle (and font) a label is
/// laid out within. Equivalent to a named text layer in the legacy GIMP template
/// (layer name = <see cref="Name"/>, e.g. a RIO address tag like <c>b-00</c>); the
/// layer's offsets/size/font gave the cell geometry. Geometry is shared by all
/// profiles for a given cockpit image; only the label <em>text</em> is per-profile.
/// </summary>
public sealed class OverlayRegion
{
/// <summary>Region key — matches the template layer name and the profile's label key.</summary>
public string Name { get; set; } = "";
/// <summary>Cell left edge, in template pixels.</summary>
public double X { get; set; }
/// <summary>Cell top edge, in template pixels.</summary>
public double Y { get; set; }
/// <summary>Cell width, in template pixels.</summary>
public double Width { get; set; }
/// <summary>Cell height, in template pixels.</summary>
public double Height { get; set; }
/// <summary>Font family for this cell's text.</summary>
public string FontFamily { get; set; } = "Sans";
/// <summary>Base font size (px) from the template; the starting point before any auto-fit.</summary>
public double FontSizePx { get; set; } = 12;
/// <summary>Text color as <c>#RRGGBB</c>/<c>#AARRGGBB</c>; null = renderer default.</summary>
public string? ColorHex { get; set; }
/// <summary>Per-region horizontal justification override; null = use the engine default.</summary>
public OverlayHJust? HJust { get; set; }
/// <summary>Per-region vertical justification override; null = use the engine default.</summary>
public OverlayVJust? VJust { get; set; }
/// <summary>
/// Counter-clockwise rotation of the label in degrees; null/0 = upright. Used
/// for cockpit cells whose labels read sideways (e.g. the vertically-oriented
/// button column). Rotated labels are centered in the cell (justification N/A).
/// </summary>
public double? RotationDegrees { get; set; }
}
/// <summary>
/// A cockpit overlay template: the base image plus the labelled regions, extracted
/// once from the source artwork (docs/reference/customBackground/riojoy.xcf) into a
/// <c>regions.json</c>. Persisted by <see cref="OverlayTemplateStore"/>.
/// </summary>
public sealed class OverlayTemplate
{
/// <summary>Path to the base cockpit image the labels are drawn over.</summary>
public string? BaseImagePath { get; set; }
/// <summary>Base image width in pixels (the output wallpaper size).</summary>
public int Width { get; set; }
/// <summary>Base image height in pixels.</summary>
public int Height { get; set; }
/// <summary>The labelled cells.</summary>
public List<OverlayRegion> Regions { get; set; } = new();
/// <summary>Find a region by name (case-insensitive), or null.</summary>
public OverlayRegion? FindRegion(string name) =>
Regions.FirstOrDefault(r => string.Equals(r.Name, name, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// A laid-out label ready to rasterize: the resolved text, top-left position,
/// measured size, and the (possibly auto-fitted) font. The renderer just draws it.
/// </summary>
public readonly record struct PlacedLabel(
string RegionName,
string Text,
double X,
double Y,
double Width,
double Height,
string FontFamily,
double FontSizePx,
double RotationDegrees = 0);
/// <summary>Engine-wide defaults for a layout pass; per-region overrides win.</summary>
public sealed class OverlayLayoutOptions
{
/// <summary>How to handle text that overflows its region at the base size.</summary>
public OverlaySizeMode SizeMode { get; set; } = OverlaySizeMode.Fit;
/// <summary>Default horizontal justification (regions may override).</summary>
public OverlayHJust HJust { get; set; } = OverlayHJust.Center;
/// <summary>Default vertical justification (regions may override).</summary>
public OverlayVJust VJust { get; set; } = OverlayVJust.Center;
}
@@ -0,0 +1,48 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace RioJoy.Core.Overlay;
/// <summary>
/// Loads and saves an <see cref="OverlayTemplate"/> as a <c>regions.json</c> — the
/// cockpit cell geometry extracted from the source artwork
/// (docs/reference/customBackground/riojoy.xcf). Same JSON conventions as
/// <see cref="Profiles.ConfigStore"/> (indented, enums as strings, null-skipping).
/// </summary>
public static class OverlayTemplateStore
{
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
};
public static string Serialize(OverlayTemplate template)
{
ArgumentNullException.ThrowIfNull(template);
return JsonSerializer.Serialize(template, Options);
}
public static OverlayTemplate Deserialize(string json)
{
ArgumentException.ThrowIfNullOrWhiteSpace(json);
return JsonSerializer.Deserialize<OverlayTemplate>(json, Options)
?? throw new JsonException("Overlay template JSON deserialized to null.");
}
public static void Save(OverlayTemplate template, string path)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
string? dir = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
File.WriteAllText(path, Serialize(template));
}
public static OverlayTemplate Load(string path)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
return Deserialize(File.ReadAllText(path));
}
}
+7
View File
@@ -32,6 +32,13 @@ public sealed class AppConfig
/// <summary>Start RIOJoy with Windows.</summary>
public bool AutoStart { get; set; }
/// <summary>
/// Path to the cockpit overlay template (<c>regions.json</c>) used to generate
/// per-profile wallpapers (Phase 7). Null disables wallpaper generation. The
/// template's base image is resolved relative to this file's folder.
/// </summary>
public string? OverlayTemplatePath { get; set; }
/// <summary>Find a profile by name (case-insensitive), or null.</summary>
public RioProfile? FindProfile(string? name) =>
name is null
+8
View File
@@ -35,6 +35,14 @@ public sealed class RioProfile
/// <summary>Cockpit wallpaper image path (generated in Phase 7).</summary>
public string? WallpaperPath { get; set; }
/// <summary>
/// Overlay label text per template region (region/layer name → display text,
/// e.g. <c>"b-00" → "FIRE"</c>). Drives the Phase 7 wallpaper generator
/// (<see cref="Overlay.OverlayLayoutEngine"/>); empty/absent entries render
/// nothing. The region geometry itself lives in the shared overlay template.
/// </summary>
public Dictionary<string, string> OverlayLabels { get; set; } = new();
/// <summary>
/// Executable names that select this profile when they are the foreground app
/// (matched case-insensitively, with or without ".exe").