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:
+23
-4
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user