Files
riojoy/src/RioJoy.Tray/Editor/WallpaperMakerForm.cs
T
CydandClaude Fable 5 d1a938dd43 Phase 7: region box editor in the wallpaper maker
"Edit cell boxes" mode: the selected cell grows resize handles on the
preview -- drag to move/resize with live outline tracking and a re-render
on drop, or type exact X/Y/W/H in the new geometry fields. Plain clicks
still select and cycle stacked cells (click-vs-drag resolved by a movement
threshold on mouse-up). The move/resize math is pure and unit-tested
(OverlayRegionEdit: corner/edge/move handle hit-testing with tolerance,
min-size clamped resizing that pins the opposite edge). "Save template"
persists the shared cell geometry back to the regions.json; "Reload
template" re-reads it to discard unsaved box edits. Cursor feedback per
handle; template save/reload disabled when no template path was supplied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 01:14:36 -05:00

493 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 string? _templatePath;
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 CheckBox _editBoxes = new() { Text = "Edit cell boxes", Location = new Point(150, 104), AutoSize = true };
private readonly NumericUpDown _numX = new() { Location = new Point(34, 130), Width = 66, Enabled = false };
private readonly NumericUpDown _numY = new() { Location = new Point(132, 130), Width = 66, Enabled = false };
private readonly NumericUpDown _numW = new() { Location = new Point(34, 156), Width = 66, Minimum = 1, Enabled = false };
private readonly NumericUpDown _numH = new() { Location = new Point(132, 156), Width = 66, Minimum = 1, Enabled = false };
private readonly Button _saveTemplate = new() { Text = "Save template", Location = new Point(204, 130), Width = 114, Enabled = false };
private readonly Button _reloadTemplate = new() { Text = "Reload template", Location = new Point(204, 156), Width = 114, Enabled = false };
private readonly ListBox _list = new() { Location = new Point(12, 188), Size = new Size(306, 260), 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 a button to a confirmation, then reverts (one at a time).
private readonly System.Windows.Forms.Timer _saveFlash = new() { Interval = 1600 };
private Action? _saveFlashReset;
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>
/// <param name="templatePath">
/// The template file itself; enables the box editor's "Save template" /
/// "Reload template" (cell geometry is shared by every profile). Null = the
/// geometry can still be edited for this session but not persisted.
/// </param>
public WallpaperMakerForm(RioProfile profile, OverlayTemplate template, string templateDirectory, string? templatePath = null)
{
_profile = profile ?? throw new ArgumentNullException(nameof(profile));
_template = template ?? throw new ArgumentNullException(nameof(template));
_templatePath = templatePath;
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);
_canvas.SelectedGeometryChanged += () => LoadGeometryFields(SelectedRegion());
_canvas.GeometryCommitted += () =>
{
LoadGeometryFields(SelectedRegion());
RenderPreview();
};
_numX.Maximum = _numW.Maximum = Math.Max(1, template.Width);
_numY.Maximum = _numH.Maximum = Math.Max(1, template.Height);
foreach (NumericUpDown n in new[] { _numX, _numY, _numW, _numH })
n.ValueChanged += OnGeometryFieldChanged;
_editBoxes.CheckedChanged += (_, _) =>
{
_canvas.EditMode = _editBoxes.Checked;
UpdateGeometryEnabled();
};
_saveTemplate.Click += (_, _) => SaveTemplate();
_reloadTemplate.Click += (_, _) => ReloadTemplate();
_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();
_saveFlashReset?.Invoke();
_saveFlashReset = null;
};
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.Add(new Label { Text = "X:", Location = new Point(12, 133), AutoSize = true });
side.Controls.Add(new Label { Text = "Y:", Location = new Point(110, 133), AutoSize = true });
side.Controls.Add(new Label { Text = "W:", Location = new Point(12, 159), AutoSize = true });
side.Controls.Add(new Label { Text = "H:", Location = new Point(110, 159), AutoSize = true });
side.Controls.AddRange(new Control[]
{
_labelBox, _outlines, _editBoxes,
_numX, _numY, _numW, _numH, _saveTemplate, _reloadTemplate,
_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;
OverlayRegion? region = _template.FindRegion(name);
_cellInfo.Text = region is { } r ? Describe(r) : name;
_loading = false;
LoadGeometryFields(region);
UpdateGeometryEnabled();
}
private OverlayRegion? SelectedRegion() =>
_list.SelectedItem is string name ? _template.FindRegion(name) : null;
// Reflect a region's box into the X/Y/W/H fields without triggering edits.
private void LoadGeometryFields(OverlayRegion? region)
{
if (region is not null)
_cellInfo.Text = Describe(region);
_loading = true;
_numX.Value = Clamp(region?.X ?? 0, _numX);
_numY.Value = Clamp(region?.Y ?? 0, _numY);
_numW.Value = Clamp(region?.Width ?? 1, _numW);
_numH.Value = Clamp(region?.Height ?? 1, _numH);
_loading = false;
static decimal Clamp(double value, NumericUpDown n) =>
Math.Min(n.Maximum, Math.Max(n.Minimum, (decimal)Math.Round(value)));
}
private void UpdateGeometryEnabled()
{
bool editing = _editBoxes.Checked;
bool hasRegion = SelectedRegion() is not null;
foreach (NumericUpDown n in new[] { _numX, _numY, _numW, _numH })
n.Enabled = editing && hasRegion;
_saveTemplate.Enabled = editing && _templatePath is not null;
_reloadTemplate.Enabled = editing && _templatePath is not null;
}
// Typed box geometry: write through to the region and re-render (debounced).
private void OnGeometryFieldChanged(object? sender, EventArgs e)
{
if (_loading || SelectedRegion() is not { } region)
return;
region.X = (double)_numX.Value;
region.Y = (double)_numY.Value;
region.Width = (double)_numW.Value;
region.Height = (double)_numH.Value;
_canvas.Invalidate();
_renderTimer.Stop();
_renderTimer.Start();
}
// Persist the (shared, all-profile) cell geometry back to the regions.json.
private void SaveTemplate()
{
if (_templatePath is null)
return;
try
{
OverlayTemplateStore.Save(_template, _templatePath);
Flash(_saveTemplate, "Saved ✓");
}
catch (Exception ex)
{
MessageBox.Show(this, $"Could not save the template:\n{ex.Message}",
"RIOJoy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
// Discard unsaved box edits by re-reading the cell geometry from disk (labels
// are untouched — they live in the profile, not the template).
private void ReloadTemplate()
{
if (_templatePath is null)
return;
try
{
OverlayTemplate fresh = OverlayTemplateStore.Load(_templatePath);
foreach (OverlayRegion r in _template.Regions)
{
if (fresh.FindRegion(r.Name) is { } f)
(r.X, r.Y, r.Width, r.Height) = (f.X, f.Y, f.Width, f.Height);
}
LoadGeometryFields(SelectedRegion());
RenderPreview();
}
catch (Exception ex)
{
MessageBox.Show(this, $"Could not reload the template:\n{ex.Message}",
"RIOJoy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
// Briefly show a confirmation on a button, then restore its caption.
private void Flash(Button button, string confirmation)
{
_saveFlash.Stop();
_saveFlashReset?.Invoke();
string original = button.Text;
Color originalColor = button.ForeColor;
button.Text = confirmation;
button.ForeColor = Color.ForestGreen;
_saveFlashReset = () => { button.Text = original; button.ForeColor = originalColor; };
_saveFlash.Start();
}
// 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);
Flash(_save, "Saved ✓");
}
catch (Exception ex)
{
MessageBox.Show(this, $"Could not save the profile:\n{ex.Message}",
"RIOJoy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
private static string SafeFileName(string name)
{
foreach (char c in Path.GetInvalidFileNameChars())
name = name.Replace(c, '_');
return name;
}
}