diff --git a/docs/PLAN.md b/docs/PLAN.md
index 1d6bbc9..f726593 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -239,11 +239,16 @@ Replaces the legacy Google-Sheet → `.data` → GIMP → Script-Fu pipeline
exercised end-to-end on the real cockpit art (`regions.json` + `riojoy.png` +
`TEST.data`) by `OverlayRenderIntegrationTests`. `RioJoy.Tray/WallpaperApplier`
applies the result via `SystemParametersInfo`.
-- **Region authoring — extraction done ✅.** `tools/XcfRegionExtract` parses the
+- **Region authoring — done ✅.** `tools/XcfRegionExtract` parses the
GIMP source (`riojoy.xcf`) and writes the 119-cell
`docs/reference/customBackground/regions.json` (per-layer offsets/size/font/color,
`BaseImagePath` → exported `riojoy.png`), anchored by `CockpitRegionsTests`.
- ⏳ Still to do: an in-app box editor for tweaks.
+ The **in-app box editor** lives in the wallpaper maker ("Edit cell boxes"): the
+ selected cell grows resize handles — drag to move/resize (plain clicks still
+ cycle stacked cells), or type exact X/Y/W/H — with the move/resize math in
+ `RioJoy.Core/Overlay/OverlayRegionEdit` (unit-tested, min-size clamped).
+ "Save template" persists the shared geometry back to the regions.json;
+ "Reload template" discards unsaved box edits.
- **Runtime wiring — done ✅ (opt-in).** `RioJoy.Overlay/ProfileWallpaperGenerator`
renders a profile's labels onto the template base image; `RioCoordinator`
generates + applies the wallpaper on profile activation when
diff --git a/src/RioJoy.Core/Overlay/OverlayRegionEdit.cs b/src/RioJoy.Core/Overlay/OverlayRegionEdit.cs
new file mode 100644
index 0000000..e2ea37e
--- /dev/null
+++ b/src/RioJoy.Core/Overlay/OverlayRegionEdit.cs
@@ -0,0 +1,97 @@
+namespace RioJoy.Core.Overlay;
+
+/// Which part of a region's box a drag grabs.
+public enum OverlayBoxHandle
+{
+ None,
+ Move,
+ North, South, East, West,
+ NorthWest, NorthEast, SouthWest, SouthEast,
+}
+
+///
+/// The pure math behind the wallpaper maker's box editor: which handle a
+/// template-space point grabs on a region, and the region's new rectangle after
+/// dragging that handle. Kept UI-free so it is unit-testable; the canvas only
+/// converts mouse pixels to template space and applies the result.
+///
+public static class OverlayRegionEdit
+{
+ ///
+ /// The handle at a template-space point: corners win over edges, edges over
+ /// (anywhere else inside the box), all
+ /// within template pixels of the border.
+ ///
+ public static OverlayBoxHandle HitHandle(OverlayRegion region, double x, double y, double tolerance)
+ {
+ if (region is null) throw new ArgumentNullException(nameof(region));
+
+ double l = region.X, t = region.Y, r = region.X + region.Width, b = region.Y + region.Height;
+ if (x < l - tolerance || x > r + tolerance || y < t - tolerance || y > b + tolerance)
+ return OverlayBoxHandle.None;
+
+ bool west = Math.Abs(x - l) <= tolerance;
+ bool east = Math.Abs(x - r) <= tolerance;
+ bool north = Math.Abs(y - t) <= tolerance;
+ bool south = Math.Abs(y - b) <= tolerance;
+
+ if (north && west) return OverlayBoxHandle.NorthWest;
+ if (north && east) return OverlayBoxHandle.NorthEast;
+ if (south && west) return OverlayBoxHandle.SouthWest;
+ if (south && east) return OverlayBoxHandle.SouthEast;
+ if (north) return OverlayBoxHandle.North;
+ if (south) return OverlayBoxHandle.South;
+ if (west) return OverlayBoxHandle.West;
+ if (east) return OverlayBoxHandle.East;
+
+ return x >= l && x < r && y >= t && y < b ? OverlayBoxHandle.Move : OverlayBoxHandle.None;
+ }
+
+ ///
+ /// The rectangle after dragging by (dx, dy) template
+ /// pixels from the drag-start rectangle. Resizes clamp so width/height never
+ /// drop below (the opposite edge stays put).
+ ///
+ public static (double X, double Y, double Width, double Height) Apply(
+ double x, double y, double width, double height,
+ OverlayBoxHandle handle, double dx, double dy, double minSize = 4)
+ {
+ double l = x, t = y, r = x + width, b = y + height;
+
+ switch (handle)
+ {
+ case OverlayBoxHandle.Move:
+ return (x + dx, y + dy, width, height);
+ case OverlayBoxHandle.West:
+ case OverlayBoxHandle.NorthWest:
+ case OverlayBoxHandle.SouthWest:
+ l = Math.Min(l + dx, r - minSize);
+ break;
+ }
+
+ switch (handle)
+ {
+ case OverlayBoxHandle.East:
+ case OverlayBoxHandle.NorthEast:
+ case OverlayBoxHandle.SouthEast:
+ r = Math.Max(r + dx, l + minSize);
+ break;
+ }
+
+ switch (handle)
+ {
+ case OverlayBoxHandle.North:
+ case OverlayBoxHandle.NorthWest:
+ case OverlayBoxHandle.NorthEast:
+ t = Math.Min(t + dy, b - minSize);
+ break;
+ case OverlayBoxHandle.South:
+ case OverlayBoxHandle.SouthWest:
+ case OverlayBoxHandle.SouthEast:
+ b = Math.Max(b + dy, t + minSize);
+ break;
+ }
+
+ return (l, t, r - l, b - t);
+ }
+}
diff --git a/src/RioJoy.Tray/Editor/WallpaperCanvas.cs b/src/RioJoy.Tray/Editor/WallpaperCanvas.cs
index bab1c6d..4adb357 100644
--- a/src/RioJoy.Tray/Editor/WallpaperCanvas.cs
+++ b/src/RioJoy.Tray/Editor/WallpaperCanvas.cs
@@ -13,11 +13,25 @@ namespace RioJoy.Tray.Editor;
///
internal sealed class WallpaperCanvas : Control
{
+ // Grabbing a handle within this many control pixels counts as a hit; a press
+ // that moves less than the drag threshold is treated as a click (cell cycling).
+ private const float HandleGrabPx = 5f;
+ private const int DragThresholdPx = 3;
+
private Bitmap? _image;
private string? _selectedRegionName;
private bool _showOutlines = true;
+ private bool _editMode;
private OverlayRegion? _hover;
+ // In-flight box drag: the grabbed handle, the mouse-down point (control px),
+ // the region's rectangle at mouse-down (template px), and whether the press
+ // has moved far enough to count as a drag rather than a click.
+ private OverlayBoxHandle _dragHandle = OverlayBoxHandle.None;
+ private Point _downControl;
+ private (double X, double Y, double W, double H) _downRect;
+ private bool _dragging;
+
public WallpaperCanvas()
{
DoubleBuffered = true;
@@ -42,12 +56,33 @@ internal sealed class WallpaperCanvas : Control
set { _showOutlines = value; Invalidate(); }
}
+ ///
+ /// Box-editing mode: the selected cell grows resize handles and can be dragged;
+ /// clicks (without a drag) still select/cycle cells as usual.
+ ///
+ public bool EditMode
+ {
+ get => _editMode;
+ set
+ {
+ _editMode = value;
+ if (!value) Cursor = Cursors.Default;
+ 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;
+ /// Raised while a box drag mutates the selected region's geometry.
+ public event Action? SelectedGeometryChanged;
+
+ /// Raised when a box drag ends (time to re-render the labels).
+ public event Action? GeometryCommitted;
+
/// Swap in a freshly rendered wallpaper; the canvas owns (and disposes) it.
public void SetImage(Bitmap? image)
{
@@ -92,20 +127,80 @@ internal sealed class WallpaperCanvas : Control
using var pen = new Pen(Color.Gold, 2f);
RectangleF rc = ToControl(selected, fit);
g.DrawRectangle(pen, rc.X, rc.Y, rc.Width, rc.Height);
+
+ if (_editMode)
+ {
+ using var fill = new SolidBrush(Color.Gold);
+ foreach (PointF p in HandlePoints(rc))
+ g.FillRectangle(fill, p.X - 3f, p.Y - 3f, 6f, 6f);
+ }
}
}
+ // Corner + edge-midpoint handle positions for the selected box.
+ private static PointF[] HandlePoints(RectangleF rc)
+ {
+ float mx = rc.X + rc.Width / 2f, my = rc.Y + rc.Height / 2f;
+ return new[]
+ {
+ new PointF(rc.Left, rc.Top), new PointF(mx, rc.Top), new PointF(rc.Right, rc.Top),
+ new PointF(rc.Left, my), new PointF(rc.Right, my),
+ new PointF(rc.Left, rc.Bottom), new PointF(mx, rc.Bottom), new PointF(rc.Right, rc.Bottom),
+ };
+ }
+
protected override void OnMouseDown(MouseEventArgs e)
{
base.OnMouseDown(e);
- if (ToTemplate(e.Location) is { } pt)
- TemplateClicked?.Invoke(pt.X, pt.Y);
+ if (e.Button != MouseButtons.Left || ToTemplate(e.Location) is not { } pt)
+ return;
+
+ // In edit mode a press on the selected box starts a (potential) drag; the
+ // click-vs-drag call is made on mouse-up so stacked-cell cycling still works.
+ if (_editMode && SelectedRegion() is { } region && Fit() is { } fit)
+ {
+ OverlayBoxHandle handle = OverlayRegionEdit.HitHandle(region, pt.X, pt.Y, HandleGrabPx / fit.Scale);
+ if (handle != OverlayBoxHandle.None)
+ {
+ _dragHandle = handle;
+ _downControl = e.Location;
+ _downRect = (region.X, region.Y, region.Width, region.Height);
+ _dragging = false;
+ Capture = true;
+ return;
+ }
+ }
+
+ TemplateClicked?.Invoke(pt.X, pt.Y);
}
protected override void OnMouseMove(MouseEventArgs e)
{
base.OnMouseMove(e);
+ if (_dragHandle != OverlayBoxHandle.None && Fit() is { } fit)
+ {
+ if (!_dragging &&
+ Math.Abs(e.X - _downControl.X) < DragThresholdPx &&
+ Math.Abs(e.Y - _downControl.Y) < DragThresholdPx)
+ return;
+ _dragging = true;
+
+ if (SelectedRegion() is { } region)
+ {
+ (region.X, region.Y, region.Width, region.Height) = OverlayRegionEdit.Apply(
+ _downRect.X, _downRect.Y, _downRect.W, _downRect.H,
+ _dragHandle,
+ (e.X - _downControl.X) / fit.Scale,
+ (e.Y - _downControl.Y) / fit.Scale);
+ SelectedGeometryChanged?.Invoke();
+ Invalidate();
+ }
+ return;
+ }
+
+ UpdateEditCursor(e.Location);
+
OverlayRegion? hover = null;
if (Template is not null && ToTemplate(e.Location) is { } pt)
hover = OverlayHitTester.RegionsAt(Template, pt.X, pt.Y).FirstOrDefault();
@@ -117,6 +212,47 @@ internal sealed class WallpaperCanvas : Control
}
}
+ protected override void OnMouseUp(MouseEventArgs e)
+ {
+ base.OnMouseUp(e);
+ if (_dragHandle == OverlayBoxHandle.None)
+ return;
+
+ bool dragged = _dragging;
+ _dragHandle = OverlayBoxHandle.None;
+ _dragging = false;
+ Capture = false;
+
+ if (dragged)
+ GeometryCommitted?.Invoke();
+ else if (ToTemplate(e.Location) is { } pt)
+ TemplateClicked?.Invoke(pt.X, pt.Y); // a plain click: select/cycle as usual
+ }
+
+ private OverlayRegion? SelectedRegion() =>
+ _selectedRegionName is { } name ? Template?.FindRegion(name) : null;
+
+ // Resize/move cursor feedback while hovering the selected box in edit mode.
+ private void UpdateEditCursor(Point location)
+ {
+ if (!_editMode)
+ return;
+
+ OverlayBoxHandle handle = OverlayBoxHandle.None;
+ if (SelectedRegion() is { } region && Fit() is { } fit && ToTemplate(location) is { } pt)
+ handle = OverlayRegionEdit.HitHandle(region, pt.X, pt.Y, HandleGrabPx / fit.Scale);
+
+ Cursor = handle switch
+ {
+ OverlayBoxHandle.Move => Cursors.SizeAll,
+ OverlayBoxHandle.North or OverlayBoxHandle.South => Cursors.SizeNS,
+ OverlayBoxHandle.East or OverlayBoxHandle.West => Cursors.SizeWE,
+ OverlayBoxHandle.NorthWest or OverlayBoxHandle.SouthEast => Cursors.SizeNWSE,
+ OverlayBoxHandle.NorthEast or OverlayBoxHandle.SouthWest => Cursors.SizeNESW,
+ _ => Cursors.Default,
+ };
+ }
+
// Scale-to-fit mapping between template pixels and control pixels (letterboxed).
private (float Scale, float OffsetX, float OffsetY)? Fit()
{
diff --git a/src/RioJoy.Tray/Editor/WallpaperMakerForm.cs b/src/RioJoy.Tray/Editor/WallpaperMakerForm.cs
index 567e927..67961cd 100644
--- a/src/RioJoy.Tray/Editor/WallpaperMakerForm.cs
+++ b/src/RioJoy.Tray/Editor/WallpaperMakerForm.cs
@@ -20,6 +20,7 @@ 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;
@@ -27,7 +28,14 @@ public sealed class WallpaperMakerForm : Form
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 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 };
@@ -38,8 +46,9 @@ public sealed class WallpaperMakerForm : Form
// 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.
+ // 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();
private bool _loading;
@@ -51,10 +60,16 @@ public sealed class WallpaperMakerForm : Form
/// Directory the template's relative
/// is resolved against (normally the folder holding the regions.json).
///
- public WallpaperMakerForm(RioProfile profile, OverlayTemplate template, string templateDirectory)
+ ///
+ /// 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.
+ ///
+ 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.");
@@ -73,6 +88,25 @@ public sealed class WallpaperMakerForm : Form
_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;
@@ -90,15 +124,24 @@ public sealed class WallpaperMakerForm : Form
_saveFlash.Tick += (_, _) =>
{
_saveFlash.Stop();
- _save.Text = "Save profile";
- _save.ForeColor = SystemColors.ControlText;
+ _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.AddRange(new Control[] { _labelBox, _outlines, _list, _hoverInfo, _import, _export, _applyWallpaper, _save, _close });
+ 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);
@@ -126,8 +169,116 @@ public sealed class WallpaperMakerForm : Form
_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;
+ 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.
@@ -323,18 +474,13 @@ public sealed class WallpaperMakerForm : Form
try
{
Saved?.Invoke(_profile);
- _save.Text = "Saved ✓";
- _save.ForeColor = Color.ForestGreen;
+ Flash(_save, "Saved ✓");
}
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)
diff --git a/src/RioJoy.Tray/TrayApplicationContext.cs b/src/RioJoy.Tray/TrayApplicationContext.cs
index 4b39a15..c018aad 100644
--- a/src/RioJoy.Tray/TrayApplicationContext.cs
+++ b/src/RioJoy.Tray/TrayApplicationContext.cs
@@ -195,7 +195,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
OverlayTemplate template = OverlayTemplateStore.Load(path!);
string templateDir = Path.GetDirectoryName(Path.GetFullPath(path!)) ?? ".";
- var maker = new WallpaperMakerForm(profile, template, templateDir);
+ var maker = new WallpaperMakerForm(profile, template, templateDir, path);
maker.Saved += _ => ConfigStore.Save(_config, ConfigPath);
maker.Show();
}
diff --git a/tests/RioJoy.Core.Tests/Overlay/OverlayRegionEditTests.cs b/tests/RioJoy.Core.Tests/Overlay/OverlayRegionEditTests.cs
new file mode 100644
index 0000000..162e0dd
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Overlay/OverlayRegionEditTests.cs
@@ -0,0 +1,69 @@
+using RioJoy.Core.Overlay;
+using Xunit;
+
+namespace RioJoy.Core.Tests.Overlay;
+
+public class OverlayRegionEditTests
+{
+ private static OverlayRegion Box() => new() { Name = "r", X = 100, Y = 50, Width = 200, Height = 80 };
+
+ [Theory]
+ [InlineData(100, 50, OverlayBoxHandle.NorthWest)]
+ [InlineData(300, 50, OverlayBoxHandle.NorthEast)]
+ [InlineData(100, 130, OverlayBoxHandle.SouthWest)]
+ [InlineData(300, 130, OverlayBoxHandle.SouthEast)]
+ [InlineData(200, 50, OverlayBoxHandle.North)]
+ [InlineData(200, 130, OverlayBoxHandle.South)]
+ [InlineData(100, 90, OverlayBoxHandle.West)]
+ [InlineData(300, 90, OverlayBoxHandle.East)]
+ [InlineData(200, 90, OverlayBoxHandle.Move)]
+ [InlineData(0, 0, OverlayBoxHandle.None)]
+ public void HitHandle_ResolvesEachZone(double x, double y, OverlayBoxHandle expected)
+ {
+ Assert.Equal(expected, OverlayRegionEdit.HitHandle(Box(), x, y, tolerance: 4));
+ }
+
+ [Fact]
+ public void HitHandle_ToleranceExtendsOutsideTheBox()
+ {
+ Assert.Equal(OverlayBoxHandle.NorthWest, OverlayRegionEdit.HitHandle(Box(), 97, 47, 4));
+ Assert.Equal(OverlayBoxHandle.None, OverlayRegionEdit.HitHandle(Box(), 94, 44, 4));
+ }
+
+ [Fact]
+ public void Apply_MoveShiftsWithoutResizing()
+ {
+ var r = OverlayRegionEdit.Apply(100, 50, 200, 80, OverlayBoxHandle.Move, 15, -10);
+
+ Assert.Equal((115d, 40d, 200d, 80d), r);
+ }
+
+ [Fact]
+ public void Apply_EastGrowsWidthOnly()
+ {
+ var r = OverlayRegionEdit.Apply(100, 50, 200, 80, OverlayBoxHandle.East, 30, 99);
+
+ Assert.Equal((100d, 50d, 230d, 80d), r);
+ }
+
+ [Fact]
+ public void Apply_NorthWestMovesOriginAndResizes()
+ {
+ var r = OverlayRegionEdit.Apply(100, 50, 200, 80, OverlayBoxHandle.NorthWest, 10, 20);
+
+ Assert.Equal((110d, 70d, 190d, 60d), r);
+ }
+
+ [Fact]
+ public void Apply_ClampsToMinSize_OppositeEdgeStaysPut()
+ {
+ var r = OverlayRegionEdit.Apply(100, 50, 200, 80, OverlayBoxHandle.West, 500, 0, minSize: 4);
+
+ Assert.Equal(296, r.X, 6); // right edge (300) minus min width
+ Assert.Equal(4, r.Width, 6);
+
+ var s = OverlayRegionEdit.Apply(100, 50, 200, 80, OverlayBoxHandle.South, 0, -500, minSize: 4);
+ Assert.Equal(50, s.Y, 6);
+ Assert.Equal(4, s.Height, 6);
+ }
+}