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:
@@ -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, // 0x00–0x0F
|
||||
Secondary, // 0x10–0x17
|
||||
Screen, // 0x18–0x1F
|
||||
Letters, // 0x20–0x37
|
||||
Throttle, // 0x38–0x3F
|
||||
Joystick, // 0x40–0x47
|
||||
Keypad, // 0x50–0x5F
|
||||
ExtKeypad, // 0x60–0x6F
|
||||
}
|
||||
|
||||
/// <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, 0x00–0x6F) — 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 0x00–0x6F), 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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); // 0x41–0x5A
|
||||
for (int d = 0; d <= 9; d++) Add(d.ToString(), 0x30 + d); // 0x30–0x39
|
||||
for (int f = 1; f <= 12; f++) Add($"F{f}", 0x6F + f); // F1–F12 = 0x70–0x7B
|
||||
|
||||
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); // 0x60–0x69
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 50–67).
|
||||
///
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
@@ -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 69–113): 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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using RioJoy.Core.Overlay;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace RioJoy.Overlay;
|
||||
|
||||
/// <summary>
|
||||
/// Generates a profile's cockpit wallpaper: renders a label set onto the overlay
|
||||
/// template's base image and writes a PNG. Resolves the template's
|
||||
/// <see cref="OverlayTemplate.BaseImagePath"/> relative to the template file's
|
||||
/// directory (so a <c>regions.json</c> + sibling <c>riojoy.png</c> work as a unit).
|
||||
/// The runtime calls this on profile activation; an editor can call it for preview.
|
||||
/// </summary>
|
||||
public sealed class ProfileWallpaperGenerator
|
||||
{
|
||||
private readonly SkiaOverlayRenderer _renderer;
|
||||
|
||||
public ProfileWallpaperGenerator(SkiaOverlayRenderer? renderer = null) =>
|
||||
_renderer = renderer ?? new SkiaOverlayRenderer();
|
||||
|
||||
/// <summary>
|
||||
/// Render <paramref name="labels"/> onto the template's base image and write a
|
||||
/// PNG to <paramref name="outputPath"/>; returns the output path.
|
||||
/// </summary>
|
||||
/// <param name="templateDirectory">
|
||||
/// Directory the template's relative <see cref="OverlayTemplate.BaseImagePath"/>
|
||||
/// is resolved against (normally the folder holding the regions.json).
|
||||
/// </param>
|
||||
public string Generate(
|
||||
OverlayTemplate template,
|
||||
string templateDirectory,
|
||||
IReadOnlyDictionary<string, string> labels,
|
||||
string outputPath,
|
||||
OverlayLayoutOptions? options = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(template);
|
||||
ArgumentNullException.ThrowIfNull(labels);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
|
||||
if (string.IsNullOrWhiteSpace(template.BaseImagePath))
|
||||
throw new InvalidOperationException("OverlayTemplate.BaseImagePath is not set.");
|
||||
|
||||
string basePath = Path.IsPathRooted(template.BaseImagePath)
|
||||
? template.BaseImagePath
|
||||
: Path.Combine(templateDirectory, template.BaseImagePath);
|
||||
|
||||
using SKBitmap baseImage = SKBitmap.Decode(basePath)
|
||||
?? throw new InvalidOperationException($"Could not decode base image: {basePath}");
|
||||
|
||||
byte[] png = _renderer.RenderToPng(template, labels, baseImage, options);
|
||||
|
||||
string? dir = Path.GetDirectoryName(Path.GetFullPath(outputPath));
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
File.WriteAllBytes(outputPath, png);
|
||||
return outputPath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<Platforms>x64</Platforms>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- SkiaSharp: cross-platform 2D rasterizer used to render the wallpaper. -->
|
||||
<PackageReference Include="SkiaSharp" Version="2.88.8" />
|
||||
<PackageReference Include="SkiaSharp.NativeAssets.Win32" Version="2.88.8" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\RioJoy.Core\RioJoy.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,132 @@
|
||||
using RioJoy.Core.Overlay;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace RioJoy.Overlay;
|
||||
|
||||
/// <summary>
|
||||
/// Renders a cockpit wallpaper: lays out per-region label text with
|
||||
/// <see cref="OverlayLayoutEngine"/>, then draws each label onto the base image
|
||||
/// with SkiaSharp. The modern replacement for the legacy GIMP/Script-Fu pipeline
|
||||
/// (docs/reference/customBackground/sg-goobie-MFD.scm). Because it shares its
|
||||
/// <see cref="SkiaTextMeasurer"/> with the layout engine, drawn positions match the
|
||||
/// measured extents exactly.
|
||||
/// </summary>
|
||||
public sealed class SkiaOverlayRenderer
|
||||
{
|
||||
private readonly SkiaTextMeasurer _measurer;
|
||||
private readonly OverlayLayoutEngine _engine;
|
||||
private readonly SKColor _defaultColor;
|
||||
|
||||
/// <param name="defaultColorHex">Label color when a region sets none (default white).</param>
|
||||
public SkiaOverlayRenderer(string defaultColorHex = "#FFFFFF")
|
||||
{
|
||||
_measurer = new SkiaTextMeasurer();
|
||||
_engine = new OverlayLayoutEngine(_measurer);
|
||||
_defaultColor = SKColor.Parse(defaultColorHex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render labels onto <paramref name="baseImage"/> and return the result as a
|
||||
/// new bitmap (the input is not modified). Caller owns the returned bitmap.
|
||||
/// </summary>
|
||||
public SKBitmap Render(
|
||||
OverlayTemplate template,
|
||||
IReadOnlyDictionary<string, string> labels,
|
||||
SKBitmap baseImage,
|
||||
OverlayLayoutOptions? options = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(template);
|
||||
ArgumentNullException.ThrowIfNull(labels);
|
||||
ArgumentNullException.ThrowIfNull(baseImage);
|
||||
options ??= new OverlayLayoutOptions();
|
||||
|
||||
var output = baseImage.Copy();
|
||||
using var canvas = new SKCanvas(output);
|
||||
|
||||
foreach (PlacedLabel label in _engine.Layout(template, labels, options))
|
||||
{
|
||||
OverlayRegion? region = template.FindRegion(label.RegionName);
|
||||
SKColor color = region?.ColorHex is { } hex ? SKColor.Parse(hex) : _defaultColor;
|
||||
|
||||
using var paint = new SKPaint
|
||||
{
|
||||
Typeface = _measurer.Typeface(label.FontFamily),
|
||||
TextSize = (float)label.FontSizePx,
|
||||
Color = color,
|
||||
IsAntialias = true,
|
||||
};
|
||||
|
||||
// PlacedLabel.Y is the top of the text box; Skia draws from the baseline.
|
||||
float baseline = (float)label.Y - paint.FontMetrics.Ascent; // ascent is negative
|
||||
|
||||
// Rotated labels: turn the canvas about the cell center (the engine
|
||||
// centered the text box there). RotationDegrees is counter-clockwise;
|
||||
// Skia's RotateDegrees is clockwise-positive, so negate.
|
||||
if (label.RotationDegrees != 0 && region is not null)
|
||||
{
|
||||
float cx = (float)(region.X + region.Width / 2);
|
||||
float cy = (float)(region.Y + region.Height / 2);
|
||||
canvas.Save();
|
||||
canvas.RotateDegrees(-(float)label.RotationDegrees, cx, cy);
|
||||
canvas.DrawText(label.Text, (float)label.X, baseline, paint);
|
||||
canvas.Restore();
|
||||
continue;
|
||||
}
|
||||
|
||||
bool clip = options.SizeMode == OverlaySizeMode.Crop && region is not null;
|
||||
if (clip)
|
||||
{
|
||||
canvas.Save();
|
||||
canvas.ClipRect(new SKRect(
|
||||
(float)region!.X, (float)region.Y,
|
||||
(float)(region.X + region.Width), (float)(region.Y + region.Height)));
|
||||
}
|
||||
|
||||
canvas.DrawText(label.Text, (float)label.X, baseline, paint);
|
||||
|
||||
if (clip)
|
||||
canvas.Restore();
|
||||
}
|
||||
|
||||
canvas.Flush();
|
||||
return output;
|
||||
}
|
||||
|
||||
/// <summary>Render and encode to PNG bytes.</summary>
|
||||
public byte[] RenderToPng(
|
||||
OverlayTemplate template,
|
||||
IReadOnlyDictionary<string, string> labels,
|
||||
SKBitmap baseImage,
|
||||
OverlayLayoutOptions? options = null)
|
||||
{
|
||||
using SKBitmap bmp = Render(template, labels, baseImage, options);
|
||||
using SKData data = bmp.Encode(SKEncodedImageFormat.Png, 100);
|
||||
return data.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render using the template's <see cref="OverlayTemplate.BaseImagePath"/> and
|
||||
/// write a PNG to <paramref name="outputPath"/>.
|
||||
/// </summary>
|
||||
public void RenderToFile(
|
||||
OverlayTemplate template,
|
||||
IReadOnlyDictionary<string, string> labels,
|
||||
string outputPath,
|
||||
OverlayLayoutOptions? options = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(template);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
|
||||
if (string.IsNullOrWhiteSpace(template.BaseImagePath))
|
||||
throw new InvalidOperationException("OverlayTemplate.BaseImagePath is not set.");
|
||||
|
||||
using SKBitmap baseImage = SKBitmap.Decode(template.BaseImagePath)
|
||||
?? throw new InvalidOperationException($"Could not decode base image: {template.BaseImagePath}");
|
||||
|
||||
byte[] png = RenderToPng(template, labels, baseImage, options);
|
||||
|
||||
string? dir = Path.GetDirectoryName(Path.GetFullPath(outputPath));
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
File.WriteAllBytes(outputPath, png);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Collections.Concurrent;
|
||||
using RioJoy.Core.Overlay;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace RioJoy.Overlay;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ITextMeasurer"/> backed by SkiaSharp — the real metrics the
|
||||
/// <see cref="SkiaOverlayRenderer"/> draws with, so measured layout and rendered
|
||||
/// output agree. Width is the glyph advance; height is the logical line height
|
||||
/// (descent − ascent), which keeps vertical centering stable across labels
|
||||
/// regardless of which glyphs each one happens to contain.
|
||||
/// </summary>
|
||||
public sealed class SkiaTextMeasurer : ITextMeasurer
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, SKTypeface> _typefaces = new();
|
||||
|
||||
internal SKTypeface Typeface(string fontFamily) =>
|
||||
_typefaces.GetOrAdd(fontFamily, f => SKTypeface.FromFamilyName(f) ?? SKTypeface.Default);
|
||||
|
||||
public TextExtent Measure(string text, string fontFamily, double fontSizePx)
|
||||
{
|
||||
using var paint = new SKPaint
|
||||
{
|
||||
Typeface = Typeface(fontFamily),
|
||||
TextSize = (float)fontSizePx,
|
||||
IsAntialias = true,
|
||||
};
|
||||
|
||||
double width = paint.MeasureText(text);
|
||||
SKFontMetrics m = paint.FontMetrics;
|
||||
double height = m.Descent - m.Ascent; // ascent is negative
|
||||
return new TextExtent(width, height);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
using System.Runtime.Versioning;
|
||||
using RioJoy.Core.Editing;
|
||||
using RioJoy.Core.Hid;
|
||||
using RioJoy.Core.Mapping;
|
||||
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
|
||||
/// <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"/>.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class ProfileEditorForm : Form
|
||||
{
|
||||
private readonly RioProfile _profile;
|
||||
private readonly IReadOnlyList<SheetCell> _cells;
|
||||
private readonly SheetCanvas _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 };
|
||||
private readonly ComboBox _kindBox = new() { Location = new Point(70, 78), Width = 150, DropDownStyle = ComboBoxStyle.DropDownList };
|
||||
private readonly Label _valueLabel = new() { Text = "Key:", Location = new Point(12, 115), AutoSize = true };
|
||||
private readonly ComboBox _valueCombo = new() { Location = new Point(70, 112), Width = 230, DropDownStyle = ComboBoxStyle.DropDownList };
|
||||
private readonly CheckBox _shift = new() { Text = "Shift", Location = new Point(70, 144), AutoSize = true };
|
||||
private readonly CheckBox _ctrl = new() { Text = "Ctrl", Location = new Point(140, 144), AutoSize = true };
|
||||
private readonly CheckBox _alt = new() { Text = "Alt", Location = new Point(200, 144), AutoSize = true };
|
||||
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 _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 };
|
||||
|
||||
private int? _address;
|
||||
private bool _loading;
|
||||
|
||||
/// <summary>A pick-list entry: what the user sees and the byte it maps to.</summary>
|
||||
private sealed record ValueItem(string Display, byte Value)
|
||||
{
|
||||
public override string ToString() => Display;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
_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);
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
MinimumSize = new Size(900, 500);
|
||||
|
||||
_kindBox.Items.AddRange(Enum.GetNames<RioRouteKind>());
|
||||
_kindBox.SelectedIndexChanged += OnKindChanged;
|
||||
_canvas.SetCells(cells);
|
||||
_canvas.AddressSelected += OnAddressSelected;
|
||||
|
||||
var scroller = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Color.FromArgb(28, 28, 28) };
|
||||
scroller.Controls.Add(_canvas);
|
||||
|
||||
Panel editPanel = BuildEditPanel();
|
||||
|
||||
Controls.Add(scroller);
|
||||
Controls.Add(editPanel);
|
||||
|
||||
_apply.Click += (_, _) => ApplyToCell();
|
||||
_save.Click += (_, _) => { ApplyToCell(); Saved?.Invoke(_profile); };
|
||||
_close.Click += (_, _) => Close();
|
||||
|
||||
SetEditingEnabled(false);
|
||||
_info.Text = "Click a coloured address cell to edit it.";
|
||||
}
|
||||
|
||||
/// <summary>The selected RIO address (for tests / offline rendering).</summary>
|
||||
public int? SelectedAddress => _address;
|
||||
|
||||
private Panel BuildEditPanel()
|
||||
{
|
||||
var panel = new Panel { Dock = DockStyle.Right, Width = 330, Padding = new Padding(8), BorderStyle = BorderStyle.FixedSingle };
|
||||
|
||||
panel.Controls.Add(_info);
|
||||
panel.Controls.Add(new Label { Text = "Label:", Location = new Point(12, 47), AutoSize = true });
|
||||
panel.Controls.Add(_labelBox);
|
||||
panel.Controls.Add(new Label { Text = "Action:", Location = new Point(12, 81), AutoSize = true });
|
||||
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 });
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
private void OnAddressSelected(SheetCell cell)
|
||||
{
|
||||
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;
|
||||
|
||||
ButtonBinding b = ButtonBinding.FromWord(
|
||||
_profile.Buttons.TryGetValue(addr, out int w) ? (ushort)w : (ushort)0);
|
||||
|
||||
_loading = true;
|
||||
_kindBox.SelectedItem = b.Kind.ToString();
|
||||
PopulateValues(b.Kind);
|
||||
SelectValue(b.Value);
|
||||
_shift.Checked = b.Shift;
|
||||
_ctrl.Checked = b.Ctrl;
|
||||
_alt.Checked = b.Alt;
|
||||
_ext.Checked = b.Extended;
|
||||
_lit.Checked = b.IsLit;
|
||||
_loading = false;
|
||||
|
||||
SetEditingEnabled(true);
|
||||
}
|
||||
|
||||
private void OnKindChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (_loading) return;
|
||||
PopulateValues(SelectedKind());
|
||||
if (_valueCombo.Items.Count > 0) _valueCombo.SelectedIndex = 0;
|
||||
UpdateModifierAvailability();
|
||||
}
|
||||
|
||||
// Fill the value picker with friendly choices for the chosen routing kind.
|
||||
private void PopulateValues(RioRouteKind kind)
|
||||
{
|
||||
_valueLabel.Text = kind switch
|
||||
{
|
||||
RioRouteKind.Keyboard => "Key:",
|
||||
RioRouteKind.Joystick => "Button:",
|
||||
RioRouteKind.Hat => "Direction:",
|
||||
RioRouteKind.Mouse => "Action:",
|
||||
RioRouteKind.RioCommand => "Command:",
|
||||
_ => "Value:",
|
||||
};
|
||||
|
||||
_valueCombo.BeginUpdate();
|
||||
_valueCombo.Items.Clear();
|
||||
|
||||
switch (kind)
|
||||
{
|
||||
case RioRouteKind.Keyboard:
|
||||
foreach (KeyName k in KeyCatalog.Entries)
|
||||
_valueCombo.Items.Add(new ValueItem(k.Name, k.Value));
|
||||
break;
|
||||
case RioRouteKind.Joystick:
|
||||
for (int btn = 1; btn <= RioHidReport.ButtonCount; btn++)
|
||||
_valueCombo.Items.Add(new ValueItem($"Button {btn}", (byte)btn));
|
||||
break;
|
||||
case RioRouteKind.Hat:
|
||||
foreach (RioHat h in new[] { RioHat.Up, RioHat.Right, RioHat.Down, RioHat.Left })
|
||||
_valueCombo.Items.Add(new ValueItem(h.ToString(), (byte)(int)h));
|
||||
break;
|
||||
case RioRouteKind.Mouse:
|
||||
foreach (MouseAction m in Enum.GetValues<MouseAction>())
|
||||
_valueCombo.Items.Add(new ValueItem(m.ToString(), (byte)m));
|
||||
break;
|
||||
case RioRouteKind.RioCommand:
|
||||
foreach (RioCommandCode c in Enum.GetValues<RioCommandCode>())
|
||||
_valueCombo.Items.Add(new ValueItem(c.ToString(), (byte)c));
|
||||
break;
|
||||
}
|
||||
|
||||
_valueCombo.EndUpdate();
|
||||
UpdateModifierAvailability();
|
||||
}
|
||||
|
||||
private void SelectValue(byte value)
|
||||
{
|
||||
for (int i = 0; i < _valueCombo.Items.Count; i++)
|
||||
{
|
||||
if (_valueCombo.Items[i] is ValueItem v && v.Value == value)
|
||||
{
|
||||
_valueCombo.SelectedIndex = i;
|
||||
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;
|
||||
foreach (Control c in new Control[] { _shift, _ctrl, _alt, _ext })
|
||||
c.Enabled = keyboard;
|
||||
}
|
||||
|
||||
private RioRouteKind SelectedKind() =>
|
||||
Enum.TryParse(_kindBox.SelectedItem?.ToString(), out RioRouteKind k) ? k : RioRouteKind.Keyboard;
|
||||
|
||||
private void ApplyToCell()
|
||||
{
|
||||
if (_address is not { } addr) return;
|
||||
|
||||
string region = RegionFor(addr);
|
||||
string label = _labelBox.Text.Trim();
|
||||
if (string.IsNullOrEmpty(label))
|
||||
_profile.OverlayLabels.Remove(region);
|
||||
else
|
||||
_profile.OverlayLabels[region] = label;
|
||||
|
||||
RioRouteKind kind = SelectedKind();
|
||||
bool keyboard = kind == RioRouteKind.Keyboard;
|
||||
var binding = new ButtonBinding
|
||||
{
|
||||
Kind = kind,
|
||||
Value = _valueCombo.SelectedItem is ValueItem vi ? vi.Value : (byte)0,
|
||||
Shift = keyboard && _shift.Checked,
|
||||
Ctrl = keyboard && _ctrl.Checked,
|
||||
Alt = keyboard && _alt.Checked,
|
||||
Extended = keyboard && _ext.Checked,
|
||||
IsLit = _lit.Checked,
|
||||
};
|
||||
|
||||
ushort word = binding.ToWord();
|
||||
if (word == 0)
|
||||
_profile.Buttons.Remove(addr);
|
||||
else
|
||||
_profile.Buttons[addr] = word;
|
||||
}
|
||||
|
||||
private void SetEditingEnabled(bool enabled)
|
||||
{
|
||||
foreach (Control c in new Control[] { _labelBox, _kindBox, _valueCombo, _shift, _ctrl, _alt, _ext, _lit, _apply })
|
||||
c.Enabled = enabled;
|
||||
if (enabled) UpdateModifierAvailability();
|
||||
}
|
||||
|
||||
private static string RegionFor(int address) => $"b-{address:X2}";
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Runtime.Versioning;
|
||||
using RioJoy.Core.Editing;
|
||||
|
||||
namespace RioJoy.Tray.Editor;
|
||||
|
||||
/// <summary>
|
||||
/// Draws the cockpit authoring sheet as a grid of cells at their spreadsheet
|
||||
/// (row, col) positions — the cockpit's logical layout. Address cells are filled by
|
||||
/// section and clickable; scaffolding/label cells are drawn as context text. Shared
|
||||
/// by <see cref="SheetCanvas"/> and offline bitmap rendering.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public static class SheetView
|
||||
{
|
||||
public static Color SectionColor(SheetSection section) => section switch
|
||||
{
|
||||
SheetSection.Contacts or SheetSection.Letters => Color.FromArgb(232, 150, 150), // red
|
||||
SheetSection.Secondary or SheetSection.Screen => Color.FromArgb(245, 224, 150), // yellow
|
||||
SheetSection.Throttle or SheetSection.Joystick => Color.FromArgb(150, 182, 226), // blue
|
||||
SheetSection.Keypad or SheetSection.ExtKeypad => Color.FromArgb(165, 212, 165), // green
|
||||
_ => Color.Gainsboro,
|
||||
};
|
||||
|
||||
public static Size GridSize(IReadOnlyList<SheetCell> cells, int cellW, int cellH)
|
||||
{
|
||||
int maxCol = 0, maxRow = 0;
|
||||
foreach (SheetCell c in cells)
|
||||
{
|
||||
if (c.Col > maxCol) maxCol = c.Col;
|
||||
if (c.Row > maxRow) maxRow = c.Row;
|
||||
}
|
||||
return new Size((maxCol + 1) * cellW, (maxRow + 1) * cellH);
|
||||
}
|
||||
|
||||
public static void Paint(
|
||||
Graphics g,
|
||||
IReadOnlyList<SheetCell> cells,
|
||||
int cellW,
|
||||
int cellH,
|
||||
int? selectedAddress)
|
||||
{
|
||||
g.Clear(Color.FromArgb(28, 28, 28));
|
||||
|
||||
using var addrFont = new Font("Segoe UI", 7.5f, FontStyle.Bold);
|
||||
using var textFont = new Font("Segoe UI", 7f);
|
||||
using var border = new Pen(Color.FromArgb(70, 70, 70));
|
||||
|
||||
foreach (SheetCell cell in cells)
|
||||
{
|
||||
var r = new Rectangle(cell.Col * cellW, cell.Row * cellH, cellW, cellH);
|
||||
|
||||
if (cell.Address is { } addr)
|
||||
{
|
||||
using var fill = new SolidBrush(SectionColor(cell.Section));
|
||||
g.FillRectangle(fill, r);
|
||||
g.DrawRectangle(border, r);
|
||||
TextRenderer.DrawText(g, cell.Text, addrFont, r, Color.Black,
|
||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPrefix);
|
||||
|
||||
if (addr == selectedAddress)
|
||||
{
|
||||
using var hi = new Pen(Color.Yellow, 2f);
|
||||
g.DrawRectangle(hi, r.X + 1, r.Y + 1, r.Width - 2, r.Height - 2);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Scaffolding / KEY / LABEL / section headers — context only.
|
||||
TextRenderer.DrawText(g, cell.Text, textFont, r, Color.Silver,
|
||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter |
|
||||
TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The address cell at a pixel point, or null.</summary>
|
||||
public static SheetCell? HitTest(IReadOnlyList<SheetCell> cells, Point p, int cellW, int cellH)
|
||||
{
|
||||
int col = p.X / cellW;
|
||||
int row = p.Y / cellH;
|
||||
return cells.FirstOrDefault(c => c.Row == row && c.Col == col && c.Address is not null);
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,10 @@ using RioJoy.Core;
|
||||
using RioJoy.Core.Calibration;
|
||||
using RioJoy.Core.Mapping;
|
||||
using RioJoy.Core.Output;
|
||||
using RioJoy.Core.Overlay;
|
||||
using RioJoy.Core.Profiles;
|
||||
using RioJoy.Core.Serial;
|
||||
using RioJoy.Overlay;
|
||||
|
||||
namespace RioJoy.Tray;
|
||||
|
||||
@@ -141,7 +143,48 @@ public sealed class RioCoordinator : IDisposable
|
||||
{
|
||||
Teardown();
|
||||
SetStatus($"Error opening {port}: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyWallpaper(profile);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate the profile's cockpit wallpaper from the configured overlay template
|
||||
/// and apply it. Best-effort and opt-in (only when <see cref="AppConfig.OverlayTemplatePath"/>
|
||||
/// is set), so a missing template or render failure never breaks activation.
|
||||
/// </summary>
|
||||
private void ApplyWallpaper(RioProfile profile)
|
||||
{
|
||||
string? templatePath = _config().OverlayTemplatePath;
|
||||
if (string.IsNullOrWhiteSpace(templatePath) || !File.Exists(templatePath))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
OverlayTemplate template = OverlayTemplateStore.Load(templatePath);
|
||||
string templateDir = Path.GetDirectoryName(Path.GetFullPath(templatePath)) ?? ".";
|
||||
|
||||
string outDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"RIOJoy", "wallpapers");
|
||||
string outPath = Path.Combine(outDir, SafeFileName(profile.Name) + ".png");
|
||||
|
||||
new ProfileWallpaperGenerator().Generate(template, templateDir, profile.OverlayLabels, outPath);
|
||||
profile.WallpaperPath = outPath;
|
||||
WallpaperApplier.Apply(outPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus($"{Status} [wallpaper: {ex.Message}]");
|
||||
}
|
||||
}
|
||||
|
||||
private static string SafeFileName(string name)
|
||||
{
|
||||
foreach (char c in Path.GetInvalidFileNameChars())
|
||||
name = name.Replace(c, '_');
|
||||
return name;
|
||||
}
|
||||
|
||||
private void GoDormant(string status)
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\RioJoy.Core\RioJoy.Core.csproj" />
|
||||
<ProjectReference Include="..\RioJoy.Overlay\RioJoy.Overlay.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Runtime.Versioning;
|
||||
using RioJoy.Core.Editing;
|
||||
using RioJoy.Core.Mapping;
|
||||
using RioJoy.Core.Profiles;
|
||||
using RioJoy.Tray.Editor;
|
||||
using RioJoy.Tray.Os;
|
||||
|
||||
namespace RioJoy.Tray;
|
||||
@@ -67,6 +69,8 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
RebuildProfileMenu(profileMenu);
|
||||
menu.Items.Add(profileMenu);
|
||||
|
||||
menu.Items.Add("Edit profile…", null, (_, _) => OpenEditor());
|
||||
|
||||
// RIO commands mirroring the legacy console menu.
|
||||
var commands = new ToolStripMenuItem("RIO commands");
|
||||
AddCommand(commands, "Reset all analog axes", RioCommandCode.ResetAllAxes);
|
||||
@@ -129,6 +133,48 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
profileMenu.DropDownItems.Add(dormant);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
RioProfile profile = _config.Profiles.FirstOrDefault() ?? AddBlankProfile();
|
||||
|
||||
var editor = new ProfileEditorForm(cells, profile);
|
||||
editor.Saved += _ => ConfigStore.Save(_config, ConfigPath);
|
||||
editor.Show();
|
||||
}
|
||||
|
||||
private RioProfile AddBlankProfile()
|
||||
{
|
||||
var profile = new RioProfile { Name = "New profile" };
|
||||
_config.Profiles.Add(profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
private void AddCommand(ToolStripMenuItem parent, string text, RioCommandCode code)
|
||||
{
|
||||
var item = new ToolStripMenuItem(text);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
namespace RioJoy.Tray;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the Windows desktop wallpaper to a generated cockpit overlay
|
||||
/// (Phase 7 — the modern replacement for manually setting the GIMP-exported
|
||||
/// background). Wraps <c>SystemParametersInfo(SPI_SETDESKWALLPAPER)</c>.
|
||||
///
|
||||
/// <para>Windows requires a <strong>BMP</strong> file path for reliable results on
|
||||
/// all versions; PNGs are accepted on modern Windows but not guaranteed, so callers
|
||||
/// should hand this a path the overlay renderer wrote. Applying a wallpaper changes
|
||||
/// a user-visible system setting, so it is only invoked on an explicit profile
|
||||
/// activation, never speculatively.</para>
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public static class WallpaperApplier
|
||||
{
|
||||
private const int SPI_SETDESKWALLPAPER = 0x0014;
|
||||
private const int SPIF_UPDATEINIFILE = 0x01; // persist across logon
|
||||
private const int SPIF_SENDCHANGE = 0x02; // broadcast WM_SETTINGCHANGE
|
||||
|
||||
/// <summary>
|
||||
/// Apply <paramref name="imagePath"/> as the desktop wallpaper. Returns true on
|
||||
/// success. Throws if the file does not exist.
|
||||
/// </summary>
|
||||
public static bool Apply(string imagePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(imagePath);
|
||||
string full = Path.GetFullPath(imagePath);
|
||||
if (!File.Exists(full))
|
||||
throw new FileNotFoundException("Wallpaper image not found.", full);
|
||||
|
||||
return SystemParametersInfo(
|
||||
SPI_SETDESKWALLPAPER, 0, full, SPIF_UPDATEINIFILE | SPIF_SENDCHANGE);
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool SystemParametersInfo(int uAction, int uParam, string lpvParam, int fuWinIni);
|
||||
}
|
||||
Reference in New Issue
Block a user