Phase 7: wallpaper maker (live preview, click-to-edit labels, .data import)

The interactive replacement for the legacy Sheet -> GIMP/Script-Fu pipeline:
WallpaperMakerForm (tray -> "Wallpaper maker") renders the profile wallpaper
live via SkiaOverlayRenderer, outlines all 119 template cells, and lets any
cell -- including the heading/banner cells the button editor cannot reach --
be clicked and retitled with debounced re-render. Clicks on stacked cells
cycle through the regions at that pixel (OverlayHitTester, unit-tested);
stacking is legitimate chroma-split display encoding. Imports a legacy .data
sheet row (with a game picker for multi-row sheets), exports the PNG, and can
apply the desktop wallpaper immediately to the same per-profile path the
runtime uses. Prompts for and remembers AppConfig.OverlayTemplatePath on
first use. PLAN.md: record this + the completed Phase 6 deploy package.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-04 00:54:54 -05:00
co-authored by Claude Fable 5
parent b275892451
commit 0623891f74
6 changed files with 701 additions and 4 deletions
+23 -4
View File
@@ -209,11 +209,19 @@ Core logic in `src/RioJoy.Core/Profiles` + `RioRuntime`; UI/OS in `src/RioJoy.Tr
-**Remaining:** full on-cabinet verification of the auto-switch +
acquire/release lifecycle against real RIO hardware.
### Phase 6 — Packaging / signing / deploy
- Driver test-signing + `pnputil` install already scripted (`driver/sign.ps1`,
### Phase 6 — Packaging / signing / deploy — done ✅
- Driver test-signing + `pnputil` install scripted (`driver/sign.ps1`,
`driver/install.ps1`, `driver/uninstall.ps1`, [`driver/README.md`](../driver/README.md));
proven on the cabinet. ⏳ Still to do: app installer, a single bundled deploy,
and a cabinet deployment doc. (Production option: attestation signing.)
proven on the cabinet.
- **Deployment package** ([`deploy/`](../deploy/)): `build-package.ps1` produces
`dist/RIOJoy-<version>.zip` (framework-dependent net48 app + `postinstall.bat` /
`install-rio.ps1` / `pre-uninstall.bat` / `uninstall-rio.ps1`, all idempotent) with
the cabinet doc [`README-DEPLOY.txt`](../deploy/README-DEPLOY.txt). Deployed builds
use the **signed ViGEmBus** virtual controller (Xbox 360 layout, 11 buttons) so no
test signing / Secure Boot change / reboot is needed; the custom RioGamepad driver
remains the full-fidelity option for owned cabinets. App lifecycle is owned by the
TeslaConsole launcher (no auto-start). ⏳ Verify remaining: first real deploy on a
cabinet via TeslaConsole.
### Phase 7 — Profile/mapping editor + cockpit overlay generator — in progress
Replaces the legacy Google-Sheet → `.data` → GIMP → Script-Fu pipeline
@@ -243,6 +251,17 @@ Replaces the legacy Google-Sheet → `.data` → GIMP → Script-Fu pipeline
activation). The live `SystemParametersInfo` apply changes a user setting, so it
is gated behind config and not exercised by tests. ⏳ Optional: restore the prior
wallpaper when going dormant.
- **Wallpaper maker — done ✅.** `RioJoy.Tray/Editor/WallpaperMakerForm` (tray →
"Wallpaper maker") is the interactive replacement for the Sheet → GIMP pipeline:
it renders the profile's wallpaper live on the template base image, outlines all
119 cells, and lets the user click any cell — including the heading/banner cells
the button editor can't reach — to edit its text with debounced re-render.
Clicking a stacked cell cycles through the regions at that pixel
(`RioJoy.Core/Overlay/OverlayHitTester`, unit-tested — stacking is legitimate:
the image feeds six chroma-split displays). Imports a legacy `.data` sheet row
(game picker for multi-row sheets), exports the PNG, and can apply the desktop
wallpaper immediately (same per-profile path the runtime uses). Prompts for and
remembers `AppConfig.OverlayTemplatePath` on first use.
- **Mapping editor — done ✅ (cockpit control-panel layout).**
`RioJoy.Tray/Editor/ProfileEditorForm` shows the cockpit as a clickable control
panel matching the **original Win32 RIO design** (docs/Win32RIO/, by FASA/Michel
@@ -0,0 +1,52 @@
namespace RioJoy.Core.Overlay;
/// <summary>
/// Point → region hit-testing over a template, for the wallpaper maker's clickable
/// preview. Because the cockpit wallpaper feeds six chroma-split displays, regions
/// for <em>different</em> buttons legitimately sit at the same pixels, so a click
/// resolves to a <em>stack</em> of candidate cells rather than one; repeated clicks
/// cycle through the stack via <see cref="NextAt"/>.
/// </summary>
public static class OverlayHitTester
{
/// <summary>
/// The regions containing the template-space point, smallest area first (the
/// most specific cell wins), ties in template order. Empty when nothing is hit.
/// A region's rectangle is its stored cell geometry; label rotation does not
/// affect hit-testing.
/// </summary>
public static IReadOnlyList<OverlayRegion> RegionsAt(OverlayTemplate template, double x, double y)
{
if (template is null) throw new ArgumentNullException(nameof(template));
return template.Regions
.Where(r => Contains(r, x, y))
.OrderBy(r => r.Width * r.Height)
.ToList();
}
/// <summary>
/// The region a click at the point should select: the first hit when
/// <paramref name="currentName"/> is not among the hits, otherwise the hit
/// after it (wrapping) — so clicking a stacked cell repeatedly cycles through
/// every region at that spot. Null when nothing is hit.
/// </summary>
public static OverlayRegion? NextAt(OverlayTemplate template, double x, double y, string? currentName)
{
IReadOnlyList<OverlayRegion> hits = RegionsAt(template, x, y);
if (hits.Count == 0)
return null;
for (int i = 0; i < hits.Count; i++)
{
if (string.Equals(hits[i].Name, currentName, StringComparison.OrdinalIgnoreCase))
return hits[(i + 1) % hits.Count];
}
return hits[0];
}
private static bool Contains(OverlayRegion r, double x, double y) =>
x >= r.X && x < r.X + r.Width &&
y >= r.Y && y < r.Y + r.Height;
}
+150
View File
@@ -0,0 +1,150 @@
using System.Drawing.Drawing2D;
using RioJoy.Core.Overlay;
namespace RioJoy.Tray.Editor;
/// <summary>
/// Interactive wallpaper preview: draws the rendered cockpit wallpaper scaled to
/// fit, outlines the template's label cells, and reports clicks in template-space
/// coordinates so the host can select (and cycle through stacked) regions. The
/// wallpaper legitimately stacks cells at the same pixels — the image feeds six
/// chroma-split displays — so selection cycling is the host's job via
/// <see cref="OverlayHitTester"/>.
/// </summary>
internal sealed class WallpaperCanvas : Control
{
private Bitmap? _image;
private string? _selectedRegionName;
private bool _showOutlines = true;
private OverlayRegion? _hover;
public WallpaperCanvas()
{
DoubleBuffered = true;
ResizeRedraw = true;
BackColor = Color.FromArgb(28, 28, 28);
}
/// <summary>The template whose cells are outlined and hit-tested.</summary>
public OverlayTemplate? Template { get; set; }
/// <summary>The highlighted cell (the one being edited); null = none.</summary>
public string? SelectedRegionName
{
get => _selectedRegionName;
set { _selectedRegionName = value; Invalidate(); }
}
/// <summary>Outline every cell (helps find unlabelled ones).</summary>
public bool ShowOutlines
{
get => _showOutlines;
set { _showOutlines = value; Invalidate(); }
}
/// <summary>Raised with template-space coordinates when the wallpaper is clicked.</summary>
public event Action<double, double>? TemplateClicked;
/// <summary>Raised when the topmost cell under the mouse changes (null = none).</summary>
public event Action<OverlayRegion?>? HoverChanged;
/// <summary>Swap in a freshly rendered wallpaper; the canvas owns (and disposes) it.</summary>
public void SetImage(Bitmap? image)
{
_image?.Dispose();
_image = image;
Invalidate();
}
protected override void Dispose(bool disposing)
{
if (disposing)
_image?.Dispose();
base.Dispose(disposing);
}
protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
if (_image is null || Fit() is not { } fit)
return;
Graphics g = e.Graphics;
g.InterpolationMode = InterpolationMode.HighQualityBilinear;
g.PixelOffsetMode = PixelOffsetMode.Half; // keep the scaled image and outlines aligned
g.DrawImage(_image, new RectangleF(fit.OffsetX, fit.OffsetY, _image.Width * fit.Scale, _image.Height * fit.Scale));
if (Template is null)
return;
if (_showOutlines)
{
using var pen = new Pen(Color.FromArgb(90, 200, 200, 200));
foreach (OverlayRegion r in Template.Regions)
{
RectangleF rc = ToControl(r, fit);
g.DrawRectangle(pen, rc.X, rc.Y, rc.Width, rc.Height);
}
}
if (_selectedRegionName is { } name && Template.FindRegion(name) is { } selected)
{
using var pen = new Pen(Color.Gold, 2f);
RectangleF rc = ToControl(selected, fit);
g.DrawRectangle(pen, rc.X, rc.Y, rc.Width, rc.Height);
}
}
protected override void OnMouseDown(MouseEventArgs e)
{
base.OnMouseDown(e);
if (ToTemplate(e.Location) is { } pt)
TemplateClicked?.Invoke(pt.X, pt.Y);
}
protected override void OnMouseMove(MouseEventArgs e)
{
base.OnMouseMove(e);
OverlayRegion? hover = null;
if (Template is not null && ToTemplate(e.Location) is { } pt)
hover = OverlayHitTester.RegionsAt(Template, pt.X, pt.Y).FirstOrDefault();
if (!ReferenceEquals(hover, _hover))
{
_hover = hover;
HoverChanged?.Invoke(hover);
}
}
// Scale-to-fit mapping between template pixels and control pixels (letterboxed).
private (float Scale, float OffsetX, float OffsetY)? Fit()
{
int iw = _image?.Width ?? Template?.Width ?? 0;
int ih = _image?.Height ?? Template?.Height ?? 0;
if (iw <= 0 || ih <= 0 || Width <= 0 || Height <= 0)
return null;
float scale = Math.Min((float)Width / iw, (float)Height / ih);
return (scale, (Width - iw * scale) / 2f, (Height - ih * scale) / 2f);
}
private static RectangleF ToControl(OverlayRegion r, (float Scale, float OffsetX, float OffsetY) fit) =>
new(
(float)(fit.OffsetX + r.X * fit.Scale),
(float)(fit.OffsetY + r.Y * fit.Scale),
(float)(r.Width * fit.Scale),
(float)(r.Height * fit.Scale));
private (double X, double Y)? ToTemplate(Point p)
{
if (Fit() is not { } fit)
return null;
double x = (p.X - fit.OffsetX) / fit.Scale;
double y = (p.Y - fit.OffsetY) / fit.Scale;
int iw = _image?.Width ?? Template?.Width ?? 0;
int ih = _image?.Height ?? Template?.Height ?? 0;
return x >= 0 && x < iw && y >= 0 && y < ih ? (x, y) : null;
}
}
@@ -0,0 +1,346 @@
using RioJoy.Core.Overlay;
using RioJoy.Core.Profiles;
using SkiaSharp;
namespace RioJoy.Tray.Editor;
/// <summary>
/// Wallpaper maker: the interactive replacement for the legacy Google-Sheet →
/// GIMP/Script-Fu pipeline. Shows the profile's cockpit wallpaper rendered live
/// (via <see cref="RioJoy.Overlay.SkiaOverlayRenderer"/> on the overlay template),
/// lets the user click any of the template's cells — including the heading/banner
/// cells the button editor can't reach — and edit its text with immediate
/// re-render. Clicking a stacked cell repeatedly cycles through the regions at
/// that spot (the wallpaper feeds six chroma-split displays, so cells overlap).
/// Labels write back to <see cref="RioProfile.OverlayLabels"/>; <c>Save</c> raises
/// <see cref="Saved"/>. Also imports the legacy <c>.data</c> label sheet, exports
/// the PNG, and applies the wallpaper on demand.
/// </summary>
public sealed class WallpaperMakerForm : Form
{
private readonly RioProfile _profile;
private readonly OverlayTemplate _template;
private readonly RioJoy.Overlay.SkiaOverlayRenderer _renderer = new();
private readonly SKBitmap _baseImage;
private readonly WallpaperCanvas _canvas = new() { Dock = DockStyle.Fill };
private readonly Label _cellInfo = new() { AutoSize = true, Location = new Point(12, 40), MaximumSize = new Size(310, 0), Text = "Click a cell to edit it." };
private readonly TextBox _labelBox = new() { Location = new Point(70, 74), Width = 230, Enabled = false };
private readonly CheckBox _outlines = new() { Text = "Show cell outlines", Location = new Point(12, 104), AutoSize = true, Checked = true };
private readonly ListBox _list = new() { Location = new Point(12, 132), Size = new Size(306, 316), IntegralHeight = false, DrawMode = DrawMode.OwnerDrawFixed };
private readonly Label _hoverInfo = new() { AutoSize = true, Location = new Point(12, 456), ForeColor = SystemColors.GrayText };
private readonly Button _import = new() { Text = "Import labels (.data)…", Location = new Point(12, 484), Width = 306 };
private readonly Button _export = new() { Text = "Export PNG…", Location = new Point(12, 514), Width = 306 };
private readonly Button _applyWallpaper = new() { Text = "Apply as desktop wallpaper", Location = new Point(12, 544), Width = 306 };
private readonly Button _save = new() { Text = "Save profile", Location = new Point(12, 582), Width = 148 };
private readonly Button _close = new() { Text = "Close", Location = new Point(170, 582), Width = 148 };
// Re-render shortly after typing stops rather than per keystroke.
private readonly System.Windows.Forms.Timer _renderTimer = new() { Interval = 300 };
// Briefly flips the Save button to a confirmation, then reverts.
private readonly System.Windows.Forms.Timer _saveFlash = new() { Interval = 1600 };
private byte[] _lastPng = Array.Empty<byte>();
private bool _loading;
/// <summary>Raised when the user saves; the argument is the edited profile.</summary>
public event Action<RioProfile>? Saved;
/// <param name="templateDirectory">
/// Directory the template's relative <see cref="OverlayTemplate.BaseImagePath"/>
/// is resolved against (normally the folder holding the regions.json).
/// </param>
public WallpaperMakerForm(RioProfile profile, OverlayTemplate template, string templateDirectory)
{
_profile = profile ?? throw new ArgumentNullException(nameof(profile));
_template = template ?? throw new ArgumentNullException(nameof(template));
if (string.IsNullOrWhiteSpace(template.BaseImagePath))
throw new InvalidOperationException("OverlayTemplate.BaseImagePath is not set.");
string imagePath = template.BaseImagePath!;
string basePath = Path.IsPathRooted(imagePath)
? imagePath
: Path.Combine(templateDirectory, imagePath);
_baseImage = SKBitmap.Decode(basePath)
?? throw new InvalidOperationException($"Could not decode base image: {basePath}");
Text = $"RIOJoy — Wallpaper: {profile.Name}";
ClientSize = new Size(1360, 640);
MinimumSize = new Size(960, 560);
StartPosition = FormStartPosition.CenterScreen;
_canvas.Template = template;
_canvas.TemplateClicked += OnCanvasClicked;
_canvas.HoverChanged += r => _hoverInfo.Text = r is null ? "" : Describe(r);
_list.Items.AddRange(template.Regions.Select(r => (object)r.Name).ToArray());
_list.DrawItem += DrawListItem;
_list.SelectedIndexChanged += (_, _) => { if (_list.SelectedItem is string name) SelectRegion(name); };
_labelBox.TextChanged += OnLabelChanged;
_outlines.CheckedChanged += (_, _) => _canvas.ShowOutlines = _outlines.Checked;
_import.Click += (_, _) => ImportData();
_export.Click += (_, _) => ExportPng();
_applyWallpaper.Click += (_, _) => ApplyAsWallpaper();
_save.Click += (_, _) => SaveProfile();
_close.Click += (_, _) => Close();
_renderTimer.Tick += (_, _) => { _renderTimer.Stop(); RenderPreview(); };
_saveFlash.Tick += (_, _) =>
{
_saveFlash.Stop();
_save.Text = "Save profile";
_save.ForeColor = SystemColors.ControlText;
};
var side = new Panel { Dock = DockStyle.Right, Width = 330, Padding = new Padding(8), BorderStyle = BorderStyle.FixedSingle, AutoScroll = true };
side.Controls.Add(new Label { Text = "Cells:", Location = new Point(12, 12), AutoSize = true });
side.Controls.Add(_cellInfo);
side.Controls.Add(new Label { Text = "Label:", Location = new Point(12, 77), AutoSize = true });
side.Controls.AddRange(new Control[] { _labelBox, _outlines, _list, _hoverInfo, _import, _export, _applyWallpaper, _save, _close });
Controls.Add(_canvas);
Controls.Add(side);
FormClosed += (_, _) =>
{
_renderTimer.Dispose();
_saveFlash.Dispose();
_baseImage.Dispose();
};
RenderPreview();
}
/// <summary>The selected region name (for tests / host inspection).</summary>
public string? SelectedRegionName => _list.SelectedItem as string;
/// <summary>Select a region programmatically (e.g. jump from the button editor).</summary>
public void SelectRegion(string name)
{
_loading = true;
int index = _list.Items.IndexOf(name);
if (index >= 0 && _list.SelectedIndex != index)
_list.SelectedIndex = index;
_labelBox.Text = _profile.OverlayLabels.TryGetValue(name, out string? text) ? text : string.Empty;
_labelBox.Enabled = true;
_canvas.SelectedRegionName = name;
_cellInfo.Text = _template.FindRegion(name) is { } r ? Describe(r) : name;
_loading = false;
}
// A click on the preview: select the cell there, cycling through stacked cells.
private void OnCanvasClicked(double x, double y)
{
if (OverlayHitTester.NextAt(_template, x, y, _canvas.SelectedRegionName) is { } hit)
SelectRegion(hit.Name);
}
private void OnLabelChanged(object? sender, EventArgs e)
{
if (_loading || _list.SelectedItem is not string name)
return;
string text = _labelBox.Text.Trim();
if (text.Length == 0)
_profile.OverlayLabels.Remove(name);
else
_profile.OverlayLabels[name] = text;
_renderTimer.Stop();
_renderTimer.Start();
_list.Invalidate();
}
// Render the profile's labels onto the base image and show it (kept as PNG
// bytes so Export/Apply write exactly what is previewed).
private void RenderPreview()
{
_lastPng = _renderer.RenderToPng(_template, _profile.OverlayLabels, _baseImage);
// Copy so the Bitmap does not depend on the stream staying open (GDI+ rule).
using var ms = new MemoryStream(_lastPng);
using var streamed = new Bitmap(ms);
_canvas.SetImage(new Bitmap(streamed));
}
// "name · 197×54 @ (810,332)" with the label text when set.
private string Describe(OverlayRegion r)
{
string text = _profile.OverlayLabels.TryGetValue(r.Name, out string? t) ? $" “{t}”" : "";
return $"{r.Name} — {r.Width:0}×{r.Height:0} @ ({r.X:0},{r.Y:0}){text}";
}
// Owner-drawn so label edits show up in the list immediately on Invalidate.
private void DrawListItem(object? sender, DrawItemEventArgs e)
{
e.DrawBackground();
if (e.Index >= 0 && _list.Items[e.Index] is string name)
{
string text = _profile.OverlayLabels.TryGetValue(name, out string? t) ? t : "";
bool selected = (e.State & DrawItemState.Selected) != 0;
using var nameBrush = new SolidBrush(selected ? SystemColors.HighlightText : SystemColors.WindowText);
using var textBrush = new SolidBrush(selected ? SystemColors.HighlightText : SystemColors.GrayText);
e.Graphics.DrawString(name, e.Font!, nameBrush, e.Bounds.X + 2, e.Bounds.Y + 1);
e.Graphics.DrawString(text, e.Font!, textBrush, e.Bounds.X + 90, e.Bounds.Y + 1);
}
e.DrawFocusRectangle();
}
// Import a legacy Goobie .data label sheet (one row per game) into the profile.
private void ImportData()
{
using var dialog = new OpenFileDialog
{
Title = "Import Goobie label data",
Filter = "Goobie label data (*.data)|*.data|All files (*.*)|*.*",
};
if (dialog.ShowDialog(this) != DialogResult.OK)
return;
try
{
GoobieDataImporter.Sheet sheet = GoobieDataImporter.Load(dialog.FileName);
if (sheet.Rows.Count == 0)
{
MessageBox.Show(this, "The file contains no label rows.", "RIOJoy",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
int row = sheet.Rows.Count == 1 ? 0 : PickRow(sheet);
if (row < 0)
return;
// Field 0 is the row's output-file tag, not a label.
string tagField = sheet.Fields.Count > 0 ? sheet.Fields[0] : "";
var labels = GoobieDataImporter.LabelsForRow(sheet, row)
.Where(kv => !string.Equals(kv.Key, tagField, StringComparison.OrdinalIgnoreCase))
.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase);
if (_profile.OverlayLabels.Count > 0 &&
MessageBox.Show(this,
$"Replace the profile's {_profile.OverlayLabels.Count} existing label(s) with the imported row?",
"RIOJoy — Import labels", MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes)
return;
_profile.OverlayLabels.Clear();
foreach (KeyValuePair<string, string> kv in labels)
_profile.OverlayLabels[kv.Key] = kv.Value;
if (_list.SelectedItem is string current)
SelectRegion(current); // refresh the label box from the new set
_list.Invalidate();
RenderPreview();
}
catch (Exception ex)
{
MessageBox.Show(this, $"Could not import the label data:\n{ex.Message}",
"RIOJoy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
// Let the user pick which sheet row (game) to import; -1 = cancelled.
private int PickRow(GoobieDataImporter.Sheet sheet)
{
using var dlg = new Form
{
Text = "RIOJoy — Pick a label row",
ClientSize = new Size(320, 360),
FormBorderStyle = FormBorderStyle.FixedDialog,
MinimizeBox = false,
MaximizeBox = false,
StartPosition = FormStartPosition.CenterParent,
};
var list = new ListBox { Location = new Point(12, 12), Size = new Size(296, 296), IntegralHeight = false };
string tagField = sheet.Fields.Count > 0 ? sheet.Fields[0] : "";
for (int i = 0; i < sheet.Rows.Count; i++)
{
list.Items.Add(sheet.Rows[i].TryGetValue(tagField, out string? tag) && !string.IsNullOrEmpty(tag)
? tag
: $"Row {i + 1}");
}
list.SelectedIndex = 0;
list.DoubleClick += (_, _) => dlg.DialogResult = DialogResult.OK;
var ok = new Button { Text = "Import", Location = new Point(152, 320), Width = 75, DialogResult = DialogResult.OK };
var cancel = new Button { Text = "Cancel", Location = new Point(233, 320), Width = 75, DialogResult = DialogResult.Cancel };
dlg.Controls.AddRange(new Control[] { list, ok, cancel });
dlg.AcceptButton = ok;
dlg.CancelButton = cancel;
return dlg.ShowDialog(this) == DialogResult.OK ? list.SelectedIndex : -1;
}
private void ExportPng()
{
using var dialog = new SaveFileDialog
{
Title = "Export wallpaper PNG",
Filter = "PNG image (*.png)|*.png",
FileName = _profile.Name + ".png",
};
if (dialog.ShowDialog(this) != DialogResult.OK)
return;
try
{
File.WriteAllBytes(dialog.FileName, _lastPng);
}
catch (Exception ex)
{
MessageBox.Show(this, $"Could not write the PNG:\n{ex.Message}",
"RIOJoy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
// Write the previewed PNG to the same per-profile path the runtime uses on
// profile activation, and set it as the desktop wallpaper now.
private void ApplyAsWallpaper()
{
try
{
string outDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"RIOJoy", "wallpapers");
Directory.CreateDirectory(outDir);
string outPath = Path.Combine(outDir, SafeFileName(_profile.Name) + ".png");
File.WriteAllBytes(outPath, _lastPng);
_profile.WallpaperPath = outPath;
WallpaperApplier.Apply(outPath);
}
catch (Exception ex)
{
MessageBox.Show(this, $"Could not apply the wallpaper:\n{ex.Message}",
"RIOJoy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
private void SaveProfile()
{
try
{
Saved?.Invoke(_profile);
_save.Text = "Saved ✓";
_save.ForeColor = Color.ForestGreen;
}
catch (Exception ex)
{
_save.Text = "Save failed";
_save.ForeColor = Color.Firebrick;
MessageBox.Show(this, $"Could not save the profile:\n{ex.Message}",
"RIOJoy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
_saveFlash.Stop();
_saveFlash.Start();
}
private static string SafeFileName(string name)
{
foreach (char c in Path.GetInvalidFileNameChars())
name = name.Replace(c, '_');
return name;
}
}
+57
View File
@@ -1,5 +1,6 @@
using RioJoy.Core;
using RioJoy.Core.Mapping;
using RioJoy.Core.Overlay;
using RioJoy.Core.Profiles;
using RioJoy.Tray.Editor;
using RioJoy.Tray.Os;
@@ -73,6 +74,11 @@ internal sealed class TrayApplicationContext : ApplicationContext
RebuildEditMenu(editMenu);
menu.Items.Add(editMenu);
var wallpaperMenu = new ToolStripMenuItem("Wallpaper maker");
wallpaperMenu.DropDownOpening += (_, _) => RebuildWallpaperMenu(wallpaperMenu);
RebuildWallpaperMenu(wallpaperMenu);
menu.Items.Add(wallpaperMenu);
menu.Items.Add("Import .ini…", null, (_, _) => ImportIniProfiles());
// RIO commands mirroring the legacy console menu.
@@ -149,6 +155,57 @@ internal sealed class TrayApplicationContext : ApplicationContext
editMenu.DropDownItems.Add(new ToolStripMenuItem("New profile…", null, (_, _) => OpenEditor(NewProfile())));
}
// Rebuild the "Wallpaper maker" submenu: one entry per profile.
private void RebuildWallpaperMenu(ToolStripMenuItem wallpaperMenu)
{
wallpaperMenu.DropDownItems.Clear();
foreach (RioProfile profile in _config.Profiles)
{
RioProfile captured = profile;
wallpaperMenu.DropDownItems.Add(new ToolStripMenuItem(profile.Name, null, (_, _) => OpenWallpaperMaker(captured)));
}
if (_config.Profiles.Count == 0)
wallpaperMenu.DropDownItems.Add(new ToolStripMenuItem("(no profiles configured)") { Enabled = false });
}
// Open the wallpaper maker for a profile. Purely offline (no serial session):
// it renders the profile's labels onto the overlay template, prompting for the
// template (regions.json) the first time and remembering it in config.
private void OpenWallpaperMaker(RioProfile profile)
{
string? path = _config.OverlayTemplatePath;
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
using var dialog = new OpenFileDialog
{
Title = "Pick the cockpit overlay template (regions.json)",
Filter = "Overlay template (regions.json)|*.json|All files (*.*)|*.*",
};
if (dialog.ShowDialog() != DialogResult.OK)
return;
path = dialog.FileName;
_config.OverlayTemplatePath = path;
ConfigStore.Save(_config, ConfigPath);
}
try
{
OverlayTemplate template = OverlayTemplateStore.Load(path!);
string templateDir = Path.GetDirectoryName(Path.GetFullPath(path!)) ?? ".";
var maker = new WallpaperMakerForm(profile, template, templateDir);
maker.Saved += _ => ConfigStore.Save(_config, ConfigPath);
maker.Show();
}
catch (Exception ex)
{
MessageBox.Show($"Could not open the wallpaper maker:\n{ex.Message}",
"RIOJoy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
// Create, register and persist a fresh empty profile with a unique name.
private RioProfile NewProfile()
{
@@ -0,0 +1,73 @@
using RioJoy.Core.Overlay;
using Xunit;
namespace RioJoy.Core.Tests.Overlay;
public class OverlayHitTesterTests
{
// Two stacked cells (the chroma-split overlap case) + one disjoint cell.
private static OverlayTemplate Template() => new()
{
Width = 1000,
Height = 400,
Regions =
{
new OverlayRegion { Name = "big", X = 0, Y = 0, Width = 200, Height = 100 },
new OverlayRegion { Name = "small", X = 50, Y = 25, Width = 100, Height = 50 },
new OverlayRegion { Name = "solo", X = 500, Y = 200, Width = 80, Height = 40 },
},
};
[Fact]
public void RegionsAt_ReturnsSmallestFirst()
{
IReadOnlyList<OverlayRegion> hits = OverlayHitTester.RegionsAt(Template(), 100, 50);
Assert.Equal(new[] { "small", "big" }, hits.Select(r => r.Name));
}
[Fact]
public void RegionsAt_MissReturnsEmpty()
{
Assert.Empty(OverlayHitTester.RegionsAt(Template(), 999, 399));
}
[Fact]
public void RegionsAt_EdgesAreInclusiveLeftTop_ExclusiveRightBottom()
{
OverlayTemplate t = Template();
Assert.Contains(OverlayHitTester.RegionsAt(t, 500, 200), r => r.Name == "solo");
Assert.Empty(OverlayHitTester.RegionsAt(t, 580, 240)); // right/bottom edge excluded
}
[Fact]
public void NextAt_FirstClickSelectsSmallest()
{
OverlayRegion? hit = OverlayHitTester.NextAt(Template(), 100, 50, currentName: null);
Assert.Equal("small", hit?.Name);
}
[Fact]
public void NextAt_CyclesThroughStackAndWraps()
{
OverlayTemplate t = Template();
Assert.Equal("big", OverlayHitTester.NextAt(t, 100, 50, "small")?.Name);
Assert.Equal("small", OverlayHitTester.NextAt(t, 100, 50, "big")?.Name); // wrap
Assert.Equal("small", OverlayHitTester.NextAt(t, 100, 50, "SOLO")?.Name); // current not in stack
}
[Fact]
public void NextAt_CurrentNameIsCaseInsensitive()
{
Assert.Equal("big", OverlayHitTester.NextAt(Template(), 100, 50, "SMALL")?.Name);
}
[Fact]
public void NextAt_MissReturnsNull()
{
Assert.Null(OverlayHitTester.NextAt(Template(), 5, 395, "small"));
}
}