mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-15 14:41:39 -04:00
feat(desktop): add Cancel to Expand Spacing
Expand Spacing ran its search on the UI thread, so a complex layout froze the app for minutes with no way out. Run the search on a worker; when it lasts past 0.3 s, show a small modal window with elapsed time and Cancel. Cancel, Escape and the close box request cancellation through the Expander token and the window stays open until the worker stops, so the layout is never touched afterward. A cancelled run moves no part and reports it in the status bar instead of the "select two parts" notice.
This commit is contained in:
1 parent
f9e3c361b4
commit
b7779904f6
5 files changed
+433
-6
No files matched your search
@@ -0,0 +1,208 @@
|
||||
using System.Diagnostics;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Controls;
|
||||
using OpenNest.Forms;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.WinForms.Tests.Forms;
|
||||
|
||||
public class OperationProgressFormTests
|
||||
{
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(15);
|
||||
|
||||
[Fact]
|
||||
public void CancelWaitsForTheWorkerBeforeClosing() => StaTestThread.Run(() =>
|
||||
{
|
||||
using var worker = new HeldWorker();
|
||||
using var form = new OperationProgressForm("Expand Spacing", "Working", worker.Task, worker.Source);
|
||||
try
|
||||
{
|
||||
form.Show();
|
||||
worker.WaitForEntry();
|
||||
|
||||
// Escape clicks the same CancelButton.
|
||||
Assert.NotNull(form.CancelButton);
|
||||
form.CancelButton.PerformClick();
|
||||
|
||||
Assert.True(form.CancelRequested);
|
||||
Assert.False(form.CancelEnabled);
|
||||
Assert.StartsWith("Cancelling", form.StatusText);
|
||||
PumpFor(TimeSpan.FromMilliseconds(200));
|
||||
Assert.False(form.IsDisposed, "Cancel closed the window while the worker was still running.");
|
||||
Assert.True(form.Visible);
|
||||
|
||||
worker.Release();
|
||||
PumpUntil(() => form.IsDisposed, "The window did not close after the worker stopped.");
|
||||
Assert.True(Completed(worker.Task), "The worker did not observe cancellation.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
worker.Release();
|
||||
}
|
||||
}, TimeSpan.FromMinutes(1), "The STA test did not complete.");
|
||||
|
||||
[Fact]
|
||||
public void ClosingWhileRunningRequestsCancellationAndWaits() => StaTestThread.Run(() =>
|
||||
{
|
||||
using var worker = new HeldWorker();
|
||||
using var form = new OperationProgressForm("Expand Spacing", "Working", worker.Task, worker.Source);
|
||||
try
|
||||
{
|
||||
form.Show();
|
||||
worker.WaitForEntry();
|
||||
|
||||
form.Close();
|
||||
|
||||
Assert.True(form.CancelRequested);
|
||||
Assert.False(form.IsDisposed, "Closing did not wait for the worker.");
|
||||
Assert.True(form.Visible);
|
||||
|
||||
worker.Release();
|
||||
PumpUntil(() => form.IsDisposed, "The window did not close after the worker stopped.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
worker.Release();
|
||||
}
|
||||
}, TimeSpan.FromMinutes(1), "The STA test did not complete.");
|
||||
|
||||
[Fact]
|
||||
public void RunReturnsAQuickResultAndRethrowsItsFailure() => StaTestThread.Run(() =>
|
||||
{
|
||||
// A long show delay keeps both runs below it, so no window can open.
|
||||
var delay = TimeSpan.FromSeconds(30);
|
||||
|
||||
Assert.Equal(42, OperationProgressForm.Run(null, "Title", "Working", _ => 42, delay));
|
||||
|
||||
var error = Assert.Throws<InvalidOperationException>(() =>
|
||||
OperationProgressForm.Run<int>(
|
||||
null,
|
||||
"Title",
|
||||
"Working",
|
||||
_ => throw new InvalidOperationException("search failed"),
|
||||
delay
|
||||
)
|
||||
);
|
||||
Assert.Equal("search failed", error.Message);
|
||||
}, TimeSpan.FromMinutes(1), "The STA test did not complete.");
|
||||
|
||||
[Fact]
|
||||
public void CancelledExpandMovesNoPartAndReportsIt() => StaTestThread.Run(() =>
|
||||
{
|
||||
using var view = TwoPartView();
|
||||
var before = view.Plate.Parts.Select(p => p.Location).ToArray();
|
||||
|
||||
var handled = view.ExpandSelected(work =>
|
||||
{
|
||||
using var cancelled = new CancellationTokenSource();
|
||||
cancelled.Cancel();
|
||||
return work(cancelled.Token);
|
||||
});
|
||||
|
||||
Assert.True(handled);
|
||||
Assert.Equal(before, view.Plate.Parts.Select(p => p.Location).ToArray());
|
||||
Assert.Contains("cancelled", view.Status);
|
||||
}, TimeSpan.FromMinutes(1), "The STA test did not complete.");
|
||||
|
||||
[Fact]
|
||||
public void ExpandRunsItsSearchThroughTheSuppliedRunner() => StaTestThread.Run(() =>
|
||||
{
|
||||
using var view = TwoPartView();
|
||||
var before = view.Plate.Parts.Select(p => p.Location).ToArray();
|
||||
var runs = 0;
|
||||
|
||||
var handled = view.ExpandSelected(work =>
|
||||
{
|
||||
runs++;
|
||||
return work(CancellationToken.None);
|
||||
});
|
||||
|
||||
Assert.True(handled);
|
||||
Assert.Equal(1, runs);
|
||||
Assert.NotEqual(before, view.Plate.Parts.Select(p => p.Location).ToArray());
|
||||
Assert.StartsWith("Expanded spacing to", view.Status);
|
||||
}, TimeSpan.FromMinutes(1), "The STA test did not complete.");
|
||||
|
||||
private static PlateView TwoPartView()
|
||||
{
|
||||
var plate = new Plate(100, 100);
|
||||
plate.Parts.Add(new Part(new Drawing("left", Square()), new Vector(1, 1)));
|
||||
plate.Parts.Add(new Part(new Drawing("right", Square()), new Vector(12, 1)));
|
||||
var view = new PlateView { Plate = plate };
|
||||
view.SelectAll();
|
||||
return view;
|
||||
}
|
||||
|
||||
private static CNC.Program Square()
|
||||
{
|
||||
var program = new CNC.Program();
|
||||
program.MoveTo(0, 0);
|
||||
program.LineTo(0, 10);
|
||||
program.LineTo(10, 10);
|
||||
program.LineTo(10, 0);
|
||||
program.LineTo(0, 0);
|
||||
return program;
|
||||
}
|
||||
|
||||
private static void PumpFor(TimeSpan duration)
|
||||
{
|
||||
var watch = Stopwatch.StartNew();
|
||||
while (watch.Elapsed < duration)
|
||||
{
|
||||
Application.DoEvents();
|
||||
Thread.Sleep(10);
|
||||
}
|
||||
}
|
||||
|
||||
private static void PumpUntil(Func<bool> condition, string message)
|
||||
{
|
||||
var watch = Stopwatch.StartNew();
|
||||
while (!condition())
|
||||
{
|
||||
Assert.True(watch.Elapsed < Timeout, message);
|
||||
Application.DoEvents();
|
||||
Thread.Sleep(10);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Completed(Task<bool> task)
|
||||
{
|
||||
Assert.True(task.Wait(Timeout), "The worker did not finish.");
|
||||
return task.Result;
|
||||
}
|
||||
|
||||
/// <summary>A worker held until released; its result says whether it saw cancellation.</summary>
|
||||
private sealed class HeldWorker : IDisposable
|
||||
{
|
||||
private readonly ManualResetEventSlim entered = new();
|
||||
private readonly ManualResetEventSlim release = new();
|
||||
|
||||
public HeldWorker()
|
||||
{
|
||||
var token = Source.Token;
|
||||
Task = System.Threading.Tasks.Task.Run(() =>
|
||||
{
|
||||
entered.Set();
|
||||
release.Wait(Timeout);
|
||||
return token.IsCancellationRequested;
|
||||
});
|
||||
}
|
||||
|
||||
public CancellationTokenSource Source { get; } = new();
|
||||
|
||||
public Task<bool> Task { get; }
|
||||
|
||||
public void WaitForEntry() => Assert.True(entered.Wait(Timeout), "Worker did not start.");
|
||||
|
||||
public void Release() => release.Set();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
release.Set();
|
||||
Task.Wait(Timeout);
|
||||
Source.Dispose();
|
||||
entered.Dispose();
|
||||
release.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1048,7 +1048,9 @@ namespace OpenNest.Controls
|
||||
|
||||
public void PushSelected(PushDirection direction) => selection.PushSelected(direction);
|
||||
|
||||
public bool ExpandSelected() => selection.ExpandSelected();
|
||||
public bool ExpandSelected(
|
||||
Func<Func<CancellationToken, Expander.Result>, Expander.Result> run = null
|
||||
) => selection.ExpandSelected(run);
|
||||
|
||||
public void RotateSelectedParts(double angle) => selection.RotateSelectedParts(angle);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
@@ -167,19 +168,31 @@ namespace OpenNest.Controls
|
||||
|
||||
/// <summary>
|
||||
/// Grows the part-to-part spacing of the selection as far as the plate
|
||||
/// allows (Expander). Returns false when nothing could be done.
|
||||
/// allows (Expander). <paramref name="run"/> executes the search and may
|
||||
/// cancel it through its token (the desktop shows a Cancel window); by
|
||||
/// default it runs synchronously. A cancelled run moves no part. Returns
|
||||
/// false only when fewer than two parts are selected.
|
||||
/// </summary>
|
||||
public bool ExpandSelected()
|
||||
public bool ExpandSelected(
|
||||
Func<Func<CancellationToken, Expander.Result>, Expander.Result> run = null
|
||||
)
|
||||
{
|
||||
var movingParts = selectedParts.Select(p => p.BasePart).ToList();
|
||||
|
||||
if (movingParts.Count < 2)
|
||||
return false;
|
||||
|
||||
var result = Expander.Expand(movingParts, view.Plate);
|
||||
var plate = view.Plate;
|
||||
Expander.Result Work(CancellationToken token) =>
|
||||
Expander.Expand(movingParts, plate, null, token);
|
||||
|
||||
var result = run != null ? run(Work) : Work(CancellationToken.None);
|
||||
|
||||
if (result.Cancelled)
|
||||
return false;
|
||||
{
|
||||
view.Status = "Expand Spacing cancelled - no parts were moved";
|
||||
return true;
|
||||
}
|
||||
|
||||
selectedParts.ForEach(p => p.IsDirty = true);
|
||||
|
||||
|
||||
@@ -1302,7 +1302,17 @@ namespace OpenNest.Forms
|
||||
if (activeForm == null)
|
||||
return;
|
||||
|
||||
if (!activeForm.PlateView.ExpandSelected())
|
||||
// Complex layouts can search for minutes; run behind a Cancel window.
|
||||
var expanded = activeForm.PlateView.ExpandSelected(work =>
|
||||
OperationProgressForm.Run(
|
||||
this,
|
||||
"Expand Spacing",
|
||||
"Expanding spacing between the selected parts...",
|
||||
work
|
||||
)
|
||||
);
|
||||
|
||||
if (!expanded)
|
||||
MessageBox.Show(
|
||||
"Select at least two parts on the plate to expand.",
|
||||
"Expand Spacing",
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace OpenNest.Forms;
|
||||
|
||||
/// <summary>
|
||||
/// Small "working" window with a Cancel button for a long plate operation that
|
||||
/// runs on a worker thread. Cancel, Escape and the close box only request
|
||||
/// cancellation: the window stays open showing "Cancelling..." until the worker
|
||||
/// has actually stopped, so the caller never continues while it is still running.
|
||||
/// </summary>
|
||||
internal sealed class OperationProgressForm : Form
|
||||
{
|
||||
/// <summary>Runs that finish within this delay never show the window.</summary>
|
||||
internal static readonly TimeSpan DefaultShowDelay = TimeSpan.FromMilliseconds(300);
|
||||
|
||||
private readonly Task task;
|
||||
private readonly CancellationTokenSource cts;
|
||||
private readonly SynchronizationContext uiContext;
|
||||
private readonly Stopwatch stopwatch = Stopwatch.StartNew();
|
||||
private readonly Label statusLabel;
|
||||
private readonly Button cancelButton;
|
||||
private readonly System.Windows.Forms.Timer elapsedTimer;
|
||||
private bool finished;
|
||||
|
||||
internal OperationProgressForm(string title, string message, Task task, CancellationTokenSource cts)
|
||||
{
|
||||
this.task = task ?? throw new ArgumentNullException(nameof(task));
|
||||
this.cts = cts ?? throw new ArgumentNullException(nameof(cts));
|
||||
|
||||
Text = title;
|
||||
StartPosition = FormStartPosition.CenterParent;
|
||||
ClientSize = new Size(380, 128);
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
MinimizeBox = false;
|
||||
MaximizeBox = false;
|
||||
ShowInTaskbar = false;
|
||||
|
||||
var messageLabel = new Label
|
||||
{
|
||||
Text = message,
|
||||
AutoEllipsis = true,
|
||||
Location = new Point(12, 12),
|
||||
Size = new Size(356, 20),
|
||||
};
|
||||
var bar = new ProgressBar
|
||||
{
|
||||
Style = ProgressBarStyle.Marquee,
|
||||
MarqueeAnimationSpeed = 30,
|
||||
Location = new Point(12, 38),
|
||||
Size = new Size(356, 18),
|
||||
};
|
||||
statusLabel = new Label
|
||||
{
|
||||
Location = new Point(12, 64),
|
||||
Size = new Size(356, 20),
|
||||
};
|
||||
cancelButton = new Button
|
||||
{
|
||||
Text = "Cancel",
|
||||
Location = new Point(293, 92),
|
||||
Size = new Size(75, 26),
|
||||
};
|
||||
cancelButton.Click += (_, _) => RequestCancel();
|
||||
|
||||
Controls.Add(messageLabel);
|
||||
Controls.Add(bar);
|
||||
Controls.Add(statusLabel);
|
||||
Controls.Add(cancelButton);
|
||||
|
||||
// Escape clicks Cancel. Assigning CancelButton gives the button
|
||||
// DialogResult.Cancel, which would close the window at once, so clear it:
|
||||
// closing waits for the worker.
|
||||
CancelButton = cancelButton;
|
||||
cancelButton.DialogResult = DialogResult.None;
|
||||
|
||||
elapsedTimer = new System.Windows.Forms.Timer { Interval = 500 };
|
||||
elapsedTimer.Tick += (_, _) => UpdateStatus();
|
||||
UpdateStatus();
|
||||
|
||||
uiContext = SynchronizationContext.Current ?? new WindowsFormsSynchronizationContext();
|
||||
}
|
||||
|
||||
internal bool CancelRequested => cts.IsCancellationRequested;
|
||||
|
||||
internal string StatusText => statusLabel.Text;
|
||||
|
||||
internal bool CancelEnabled => cancelButton.Enabled;
|
||||
|
||||
/// <summary>
|
||||
/// Runs <paramref name="work"/> on a worker thread and returns its result,
|
||||
/// rethrowing its exception. A run still going after
|
||||
/// <paramref name="showDelay"/> shows a modal window whose Cancel requests
|
||||
/// cancellation through the token passed to <paramref name="work"/>; this
|
||||
/// returns only once the worker has stopped, whether or not it honored it.
|
||||
/// </summary>
|
||||
internal static T Run<T>(
|
||||
IWin32Window owner,
|
||||
string title,
|
||||
string message,
|
||||
Func<CancellationToken, T> work,
|
||||
TimeSpan? showDelay = null
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(work);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
var token = cts.Token;
|
||||
var task = Task.Run(() => work(token));
|
||||
|
||||
// A wait handle, unlike Task.Wait, does not throw for a faulted run.
|
||||
if (!((IAsyncResult)task).AsyncWaitHandle.WaitOne(showDelay ?? DefaultShowDelay))
|
||||
{
|
||||
using var form = new OperationProgressForm(title, message, task, cts);
|
||||
form.ShowDialog(owner);
|
||||
}
|
||||
|
||||
// The window closes only after the worker stops. Should it ever return
|
||||
// early, stop the worker rather than continue beside it.
|
||||
if (!task.IsCompleted)
|
||||
cts.Cancel();
|
||||
|
||||
return task.GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
protected override void OnShown(EventArgs e)
|
||||
{
|
||||
base.OnShown(e);
|
||||
elapsedTimer.Start();
|
||||
task.ContinueWith(
|
||||
_ => uiContext.Post(_ => Finish(), null),
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.None,
|
||||
TaskScheduler.Default
|
||||
);
|
||||
}
|
||||
|
||||
protected override void OnFormClosing(FormClosingEventArgs e)
|
||||
{
|
||||
if (!finished)
|
||||
{
|
||||
e.Cancel = true;
|
||||
RequestCancel();
|
||||
}
|
||||
|
||||
base.OnFormClosing(e);
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
// The caller owns the token source; only ask a still-running worker to stop.
|
||||
if (!finished && !task.IsCompleted)
|
||||
cts.Cancel();
|
||||
elapsedTimer.Dispose();
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
private void RequestCancel()
|
||||
{
|
||||
if (finished || cts.IsCancellationRequested)
|
||||
return;
|
||||
|
||||
cts.Cancel();
|
||||
cancelButton.Enabled = false;
|
||||
UpdateStatus();
|
||||
}
|
||||
|
||||
private void Finish()
|
||||
{
|
||||
if (finished || IsDisposed)
|
||||
return;
|
||||
|
||||
finished = true;
|
||||
elapsedTimer.Stop();
|
||||
Close();
|
||||
}
|
||||
|
||||
private void UpdateStatus()
|
||||
{
|
||||
var elapsed = stopwatch.Elapsed;
|
||||
var time = $"{(int)elapsed.TotalMinutes}:{elapsed.Seconds:00}";
|
||||
statusLabel.Text = cts.IsCancellationRequested
|
||||
? $"Cancelling... waiting for the current step to stop ({time})"
|
||||
: $"Elapsed {time}";
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user