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:
+7
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,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()
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user