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>
This commit is contained in:
Cyd
2026-07-04 01:14:36 -05:00
co-authored by Claude Fable 5
parent 0623891f74
commit d1a938dd43
6 changed files with 471 additions and 18 deletions
+7 -2
View File
@@ -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
@@ -0,0 +1,97 @@
namespace RioJoy.Core.Overlay;
/// <summary>Which part of a region's box a drag grabs.</summary>
public enum OverlayBoxHandle
{
None,
Move,
North, South, East, West,
NorthWest, NorthEast, SouthWest, SouthEast,
}
/// <summary>
/// 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.
/// </summary>
public static class OverlayRegionEdit
{
/// <summary>
/// The handle at a template-space point: corners win over edges, edges over
/// <see cref="OverlayBoxHandle.Move"/> (anywhere else inside the box), all
/// within <paramref name="tolerance"/> template pixels of the border.
/// </summary>
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;
}
/// <summary>
/// The rectangle after dragging <paramref name="handle"/> by (dx, dy) template
/// pixels from the drag-start rectangle. Resizes clamp so width/height never
/// drop below <paramref name="minSize"/> (the opposite edge stays put).
/// </summary>
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);
}
}
+137 -1
View File
@@ -13,11 +13,25 @@ namespace RioJoy.Tray.Editor;
/// </summary>
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(); }
}
/// <summary>
/// Box-editing mode: the selected cell grows resize handles and can be dragged;
/// clicks (without a drag) still select/cycle cells as usual.
/// </summary>
public bool EditMode
{
get => _editMode;
set
{
_editMode = value;
if (!value) Cursor = Cursors.Default;
Invalidate();
}
}
/// <summary>Raised with template-space coordinates when the wallpaper is clicked.</summary>
public event Action<double, double>? TemplateClicked;
/// <summary>Raised when the topmost cell under the mouse changes (null = none).</summary>
public event Action<OverlayRegion?>? HoverChanged;
/// <summary>Raised while a box drag mutates the selected region's geometry.</summary>
public event Action? SelectedGeometryChanged;
/// <summary>Raised when a box drag ends (time to re-render the labels).</summary>
public event Action? GeometryCommitted;
/// <summary>Swap in a freshly rendered wallpaper; the canvas owns (and disposes) it.</summary>
public void SetImage(Bitmap? image)
{
@@ -92,13 +127,50 @@ 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)
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);
}
@@ -106,6 +178,29 @@ internal sealed class WallpaperCanvas : Control
{
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()
{
+159 -13
View File
@@ -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<byte>();
private bool _loading;
@@ -51,10 +60,16 @@ public sealed class WallpaperMakerForm : Form
/// Directory the template's relative <see cref="OverlayTemplate.BaseImagePath"/>
/// is resolved against (normally the folder holding the regions.json).
/// </param>
public WallpaperMakerForm(RioProfile profile, OverlayTemplate template, string templateDirectory)
/// <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.");
@@ -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)
+1 -1
View File
@@ -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();
}
@@ -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);
}
}