diff --git a/docs/PLAN.md b/docs/PLAN.md index d0fe8db..1d6bbc9 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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-.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 diff --git a/src/RioJoy.Core/Overlay/OverlayHitTester.cs b/src/RioJoy.Core/Overlay/OverlayHitTester.cs new file mode 100644 index 0000000..f40209d --- /dev/null +++ b/src/RioJoy.Core/Overlay/OverlayHitTester.cs @@ -0,0 +1,52 @@ +namespace RioJoy.Core.Overlay; + +/// +/// 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 different buttons legitimately sit at the same pixels, so a click +/// resolves to a stack of candidate cells rather than one; repeated clicks +/// cycle through the stack via . +/// +public static class OverlayHitTester +{ + /// + /// 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. + /// + public static IReadOnlyList 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(); + } + + /// + /// The region a click at the point should select: the first hit when + /// 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. + /// + public static OverlayRegion? NextAt(OverlayTemplate template, double x, double y, string? currentName) + { + IReadOnlyList 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; +} diff --git a/src/RioJoy.Tray/Editor/WallpaperCanvas.cs b/src/RioJoy.Tray/Editor/WallpaperCanvas.cs new file mode 100644 index 0000000..bab1c6d --- /dev/null +++ b/src/RioJoy.Tray/Editor/WallpaperCanvas.cs @@ -0,0 +1,150 @@ +using System.Drawing.Drawing2D; +using RioJoy.Core.Overlay; + +namespace RioJoy.Tray.Editor; + +/// +/// 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 +/// . +/// +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); + } + + /// The template whose cells are outlined and hit-tested. + public OverlayTemplate? Template { get; set; } + + /// The highlighted cell (the one being edited); null = none. + public string? SelectedRegionName + { + get => _selectedRegionName; + set { _selectedRegionName = value; Invalidate(); } + } + + /// Outline every cell (helps find unlabelled ones). + public bool ShowOutlines + { + get => _showOutlines; + set { _showOutlines = value; Invalidate(); } + } + + /// Raised with template-space coordinates when the wallpaper is clicked. + public event Action? TemplateClicked; + + /// Raised when the topmost cell under the mouse changes (null = none). + public event Action? HoverChanged; + + /// Swap in a freshly rendered wallpaper; the canvas owns (and disposes) it. + 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; + } +} diff --git a/src/RioJoy.Tray/Editor/WallpaperMakerForm.cs b/src/RioJoy.Tray/Editor/WallpaperMakerForm.cs new file mode 100644 index 0000000..567e927 --- /dev/null +++ b/src/RioJoy.Tray/Editor/WallpaperMakerForm.cs @@ -0,0 +1,346 @@ +using RioJoy.Core.Overlay; +using RioJoy.Core.Profiles; +using SkiaSharp; + +namespace RioJoy.Tray.Editor; + +/// +/// Wallpaper maker: the interactive replacement for the legacy Google-Sheet → +/// GIMP/Script-Fu pipeline. Shows the profile's cockpit wallpaper rendered live +/// (via 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 ; Save raises +/// . Also imports the legacy .data label sheet, exports +/// the PNG, and applies the wallpaper on demand. +/// +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(); + private bool _loading; + + /// Raised when the user saves; the argument is the edited profile. + public event Action? Saved; + + /// + /// Directory the template's relative + /// is resolved against (normally the folder holding the regions.json). + /// + 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(); + } + + /// The selected region name (for tests / host inspection). + public string? SelectedRegionName => _list.SelectedItem as string; + + /// Select a region programmatically (e.g. jump from the button editor). + 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 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; + } +} diff --git a/src/RioJoy.Tray/TrayApplicationContext.cs b/src/RioJoy.Tray/TrayApplicationContext.cs index 869a452..4b39a15 100644 --- a/src/RioJoy.Tray/TrayApplicationContext.cs +++ b/src/RioJoy.Tray/TrayApplicationContext.cs @@ -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() { diff --git a/tests/RioJoy.Core.Tests/Overlay/OverlayHitTesterTests.cs b/tests/RioJoy.Core.Tests/Overlay/OverlayHitTesterTests.cs new file mode 100644 index 0000000..4f2d287 --- /dev/null +++ b/tests/RioJoy.Core.Tests/Overlay/OverlayHitTesterTests.cs @@ -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 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")); + } +}