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); + } +}