diff --git a/OpenNest.Avalonia/Controls/PlateCanvas.cs b/OpenNest.Avalonia/Controls/PlateCanvas.cs
index 2f1c5f1b..b591a02b 100644
--- a/OpenNest.Avalonia/Controls/PlateCanvas.cs
+++ b/OpenNest.Avalonia/Controls/PlateCanvas.cs
@@ -59,6 +59,20 @@ namespace OpenNest.Avalonia.Controls
}
}
+ ///
+ /// Shows , a copy of the shown plate an Undo or Redo read back,
+ /// keeping the zoom and pan, and selects its part at
+ /// (none when out of range).
+ ///
+ public void ReplacePlate(Plate value, int selectedIndex)
+ {
+ var fit = fitPending;
+ Plate = value;
+ fitPending = fit;
+ if (value != null && selectedIndex >= 0 && selectedIndex < value.Parts.Count)
+ SelectedPart = value.Parts[selectedIndex];
+ }
+
/// The colours drawn with: the application's active scheme unless set.
public ColorScheme Scheme { get; set; } = ColorScheme.Default;
diff --git a/OpenNest.Avalonia/MainWindow.Undo.cs b/OpenNest.Avalonia/MainWindow.Undo.cs
new file mode 100644
index 00000000..5d07d254
--- /dev/null
+++ b/OpenNest.Avalonia/MainWindow.Undo.cs
@@ -0,0 +1,164 @@
+using System;
+using System.Diagnostics;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+using OpenNest.Desktop;
+using OpenNest.Undo;
+using Visual = global::Avalonia.Visual;
+
+namespace OpenNest.Avalonia
+{
+ ///
+ /// Edit > Undo and Redo of the active nest, through its tab's shared
+ /// : Ctrl+Z, Ctrl+Y and Ctrl+Shift+Z unless a text box has
+ /// focus, the menu naming the next step, and nothing while Auto Nest works on the nest.
+ /// Switching tabs rebinds the menu to the active nest's history.
+ ///
+ public partial class MainWindow
+ {
+ // Each open tab's history handler, so closing the tab unhooks it.
+ private readonly System.Collections.Generic.Dictionary undoHandlers = new();
+
+ /// Follows 's history: its unsaved dot and, while active, the Edit menu.
+ private void WatchHistory(NestTab tab)
+ {
+ EventHandler handler = (_, _) => OnHistoryChanged(tab);
+ undoHandlers[tab] = handler;
+ tab.Undo.Changed += handler;
+ }
+
+ private void UnwatchHistory(NestTab tab)
+ {
+ if (undoHandlers.Remove(tab, out var handler))
+ tab.Undo.Changed -= handler;
+ }
+
+ private void OnHistoryChanged(NestTab tab)
+ {
+ RebuildTabStrip();
+ if (tab == tabs.Active)
+ UpdateUndoMenu();
+ }
+
+ ///
+ /// Handles Ctrl+Z, Ctrl+Y and Ctrl+Shift+Z for the active nest, as the WinForms main
+ /// window does. A focused text editor keeps them for its own typing.
+ ///
+ private bool ProcessUndoKeys(KeyEventArgs e)
+ {
+ var modifiers = e.KeyModifiers & ~KeyModifiers.Shift;
+ if (modifiers != KeyModifiers.Control || e.Key is not (Key.Z or Key.Y))
+ return false;
+ var shift = e.KeyModifiers.HasFlag(KeyModifiers.Shift);
+ if (e.Key == Key.Y && shift)
+ return false;
+ if (tabs.Active == null || KeepsOwnUndo(FocusManager?.GetFocusedElement() as Visual))
+ return false;
+
+ if (e.Key == Key.Z && !shift)
+ TryUndo(tabs.Active);
+ else
+ TryRedo(tabs.Active);
+ return true;
+ }
+
+ ///
+ /// True for controls that undo their own typing: a text box, including the one inside a
+ /// numeric box, an editable combo box or a grid cell being edited.
+ ///
+ internal static bool KeepsOwnUndo(Visual focused)
+ {
+ for (var visual = focused; visual != null; visual = visual.GetVisualParent())
+ {
+ if (visual is TextBox)
+ return true;
+ }
+ return false;
+ }
+
+ // Not while Auto Nest works on the nest: its result is applied to the nest it started on.
+ private bool CanUndoOrRedo(NestTab tab) => tab != null && tab != nestingTab;
+
+ private bool TryUndo(NestTab tab)
+ {
+ if (!CanUndoOrRedo(tab) || !tab.Undo.CanUndo)
+ return false;
+ return Step(tab, "undo", tab.Undo.Undo);
+ }
+
+ private bool TryRedo(NestTab tab)
+ {
+ if (!CanUndoOrRedo(tab) || !tab.Undo.CanRedo)
+ return false;
+ return Step(tab, "redo", tab.Undo.Redo);
+ }
+
+ private bool Step(NestTab tab, string what, Action step)
+ {
+ try
+ {
+ step();
+ }
+ catch (Exception ex)
+ {
+ // A copy that cannot be read back leaves the nest and the history as they were.
+ ShowNotice($"Could not {what} in {tab.Name}: {ex.Message}");
+ UpdateUndoMenu();
+ return false;
+ }
+ UpdateUndoMenu();
+ return true;
+ }
+
+ private void OnUndo(object sender, RoutedEventArgs e)
+ {
+ if (tabs.Active != null)
+ TryUndo(tabs.Active);
+ }
+
+ private void OnRedo(object sender, RoutedEventArgs e)
+ {
+ if (tabs.Active != null)
+ TryRedo(tabs.Active);
+ }
+
+ // Busy states have no change event of their own; check them whenever the menu opens.
+ private void OnEditMenuOpened(object sender, RoutedEventArgs e) => UpdateUndoMenu();
+
+ /// Shows the active nest's next Undo and Redo steps, enabled when they can run.
+ private void UpdateUndoMenu()
+ {
+ var tab = tabs.Active;
+ var available = CanUndoOrRedo(tab);
+ UndoMenuItem.IsEnabled = available && tab.Undo.CanUndo;
+ RedoMenuItem.IsEnabled = available && tab.Undo.CanRedo;
+ UndoMenuItem.Header = AccessKeyed(tab?.Undo.UndoText ?? "Undo");
+ RedoMenuItem.Header = AccessKeyed(tab?.Undo.RedoText ?? "Redo");
+ }
+
+ // "Undo Rotate" as "_Undo Rotate": U and R stay the access keys, a label's own
+ // underscores are shown as typed.
+ private static string AccessKeyed(string text) => "_" + text.Replace("_", "__");
+
+#if DEBUG
+ private DispatcherTimer unrecordedCheck;
+
+ // Every edit should open a change scope; one that does not folds into the next entry.
+ private void StartUnrecordedChangeCheck()
+ {
+ unrecordedCheck = new DispatcherTimer(TimeSpan.FromMilliseconds(500), DispatcherPriority.Background,
+ (_, _) =>
+ {
+ var tab = tabs.Active;
+ if (tab != null && tab != nestingTab && tab.Undo.TakeUnrecordedChangeWarning())
+ Debug.WriteLine($"Undo: '{tab.Name}' changed outside a change scope; the edit is not recorded yet.");
+ });
+ unrecordedCheck.Start();
+ Closed += (_, _) => unrecordedCheck.Stop();
+ }
+#endif
+ }
+}
diff --git a/OpenNest.Avalonia/MainWindow.axaml b/OpenNest.Avalonia/MainWindow.axaml
index 7ee0fa11..b57afbbe 100644
--- a/OpenNest.Avalonia/MainWindow.axaml
+++ b/OpenNest.Avalonia/MainWindow.axaml
@@ -24,6 +24,11 @@
+
diff --git a/OpenNest.Avalonia/MainWindow.axaml.cs b/OpenNest.Avalonia/MainWindow.axaml.cs
index a1ea386b..c2e10671 100644
--- a/OpenNest.Avalonia/MainWindow.axaml.cs
+++ b/OpenNest.Avalonia/MainWindow.axaml.cs
@@ -95,6 +95,9 @@ namespace OpenNest.Avalonia
AddHandler(DragDrop.DragOverEvent, OnDragOver);
AddHandler(DragDrop.DropEvent, OnDrop);
Closing += OnWindowClosing;
+#if DEBUG
+ StartUnrecordedChangeCheck();
+#endif
Opened += (_, _) =>
{
if (!snapshotMode)
@@ -179,6 +182,7 @@ namespace OpenNest.Avalonia
RestoreViewToggles(view);
view.ViewChanged += (_, _) => { if (view == ActiveView) UpdateStatus(); };
views[tab] = view;
+ WatchHistory(tab);
notice = null;
ShowActiveTab();
}
@@ -234,6 +238,8 @@ namespace OpenNest.Avalonia
: $"{view.Tab.Name} - OpenNest (Avalonia prototype)";
RebuildTabStrip();
UpdateViewToggles();
+ // The Edit menu follows the active nest's history.
+ UpdateUndoMenu();
UpdateStatus();
}
@@ -322,6 +328,7 @@ namespace OpenNest.Avalonia
if (!await ConfirmUnsavedAsync(tab))
return false;
+ UnwatchHistory(tab);
tabs.Close(tab);
if (views.Remove(tab, out var closed))
SaveViewToggles(closed);
@@ -566,6 +573,11 @@ namespace OpenNest.Avalonia
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
+ if (ProcessUndoKeys(e))
+ {
+ e.Handled = true;
+ return;
+ }
if (e.Key == Key.Tab && e.KeyModifiers.HasFlag(KeyModifiers.Control))
{
StepNest(e.KeyModifiers.HasFlag(KeyModifiers.Shift) ? -1 : 1);
@@ -675,12 +687,27 @@ namespace OpenNest.Avalonia
// Engines that ignore cancellation still have their proposal discarded.
token.ThrowIfCancellationRequested();
var firstNew = nest.Plates.Count(p => p.Parts.Count > 0);
- var kept = AutoNestJob.Keep(nest, result);
- if (kept.Nested)
+ // One undo step for the whole result, as in the WinForms app; the history change
+ // updates the tab's unsaved mark.
+ NestRenestResult kept;
+ using (var change = tab.Undo.BeginChange("Auto Nest"))
{
- tab.MarkModified();
- view.Refresh(firstNew);
- RebuildTabStrip();
+ try
+ {
+ kept = AutoNestJob.Keep(nest, result);
+ }
+ catch
+ {
+ // A commit that failed part way is rolled back.
+ change.MarkChanged();
+ throw;
+ }
+ if (kept.Nested)
+ {
+ change.MarkChanged();
+ view.Refresh(firstNew);
+ }
+ change.Complete();
}
ShowNotice(kept.Nested
? $"{tab.Name}: {engineName}: {kept.Note}"
@@ -707,6 +734,7 @@ namespace OpenNest.Avalonia
{
AutoNestButton.Content = running ? "Stop" : "Auto Nest";
AutoNestMenuItem.Header = running ? "_Stop Auto Nest" : "_Auto Nest";
+ UpdateUndoMenu();
}
/// Completes once the machines are read (see ).
diff --git a/OpenNest.Avalonia/NestView.axaml.cs b/OpenNest.Avalonia/NestView.axaml.cs
index ca2b3ed3..2d472dd7 100644
--- a/OpenNest.Avalonia/NestView.axaml.cs
+++ b/OpenNest.Avalonia/NestView.axaml.cs
@@ -25,9 +25,40 @@ namespace OpenNest.Avalonia
{
Tab = tab;
Canvas.ViewChanged += (_, _) => ViewChanged?.Invoke(this, EventArgs.Empty);
+ tab.Undo.SwapIn = SwapIn;
Refresh(0);
}
+ // Undo and Redo: puts the copy read back from the history in the document and shows it
+ // on the same plate, keeping the zoom, pan and selection (by part index while the part
+ // count is unchanged). Every list is rebuilt: each held the old nest's objects.
+ private void SwapIn(Nest restored)
+ {
+ var plateIndex = System.Math.Max(0, PlateList.SelectedIndex);
+ var shown = Canvas.Plate;
+ keptSelection = shown != null && Canvas.SelectedPart != null
+ ? (shown.Parts.IndexOf(Canvas.SelectedPart), shown.Parts.Count)
+ : (-1, -1);
+ Tab.Document.Nest = restored;
+ // Reading recolours drawings from the file; the view keeps the application's scheme.
+ // Cut-offs stay as reading regenerated them, as when the nest was opened.
+ ColorSchemeRegistry.RecolorDrawings(restored);
+ keepView = true;
+ try
+ {
+ Refresh(plateIndex);
+ }
+ finally
+ {
+ keepView = false;
+ }
+ }
+
+ // While an Undo or Redo swaps its copy in: the plate is shown without zooming to fit,
+ // and the part selected before (its index, the plate's part count then) is selected again.
+ private bool keepView;
+ private (int Index, int PartCount) keptSelection = (-1, -1);
+
/// Raised when the shown plate, zoom, hover or selection changes.
public event EventHandler ViewChanged;
@@ -77,7 +108,11 @@ namespace OpenNest.Avalonia
var nest = Tab.Document.Nest;
var index = PlateList.SelectedIndex;
var plate = index >= 0 && index < nest.Plates.Count ? nest.Plates[index] : null;
- Canvas.Plate = plate;
+ if (keepView)
+ Canvas.ReplacePlate(plate,
+ plate?.Parts.Count == keptSelection.PartCount ? keptSelection.Index : -1);
+ else
+ Canvas.Plate = plate;
PlateLabel.Text = plate == null
? "No plates"
: $"Plate {index + 1} of {nest.Plates.Count} "
diff --git a/OpenNest.Desktop.Tests/NestTabsSaveTests.cs b/OpenNest.Desktop.Tests/NestTabsSaveTests.cs
index ae315cf5..f144de43 100644
--- a/OpenNest.Desktop.Tests/NestTabsSaveTests.cs
+++ b/OpenNest.Desktop.Tests/NestTabsSaveTests.cs
@@ -36,7 +36,7 @@ public sealed class NestTabsSaveTests : IDisposable
{
var tabs = new NestTabs();
var tab = OpenSaved(tabs, "opened.nest");
- tab.MarkModified();
+ TestNests.Edit(tab);
var changed = 0;
tabs.Changed += (_, _) => changed++;
var target = InDir("Renamed Job.nest");
@@ -74,7 +74,7 @@ public sealed class NestTabsSaveTests : IDisposable
var tabs = new NestTabs();
var a = OpenSaved(tabs, "a.nest");
var b = OpenSaved(tabs, "b.nest");
- a.MarkModified();
+ TestNests.Edit(a);
var before = File.ReadAllBytes(b.Path);
var ex = Assert.Throws(() => tabs.SaveAs(a, b.Path));
@@ -86,12 +86,56 @@ public sealed class NestTabsSaveTests : IDisposable
Assert.True(a.Modified);
}
+ [Fact]
+ public void AnOpenedNest_IsUnmodifiedWithItsEmptyPlateToNestOnto()
+ {
+ var tabs = new NestTabs();
+
+ var tab = OpenSaved(tabs, "opened.nest");
+
+ // The file has one plate; the empty one kept at the end is part of the opened state.
+ Assert.Equal(2, tab.Document.Nest.Plates.Count);
+ Assert.False(tab.Modified);
+ Assert.False(tab.Undo.History.CanUndo);
+ }
+
+ [Fact]
+ public void Modified_ClearsWhenTheEditsAreUndoneBackToTheSavedState()
+ {
+ var tabs = new NestTabs();
+ var tab = OpenSaved(tabs, "undo.nest");
+ tab.Undo.RecordChange("Move", () => tab.Document.Nest.Plates[0].Parts[0].Offset(4, 0));
+ Assert.True(tab.Modified);
+
+ tab.Undo.Undo();
+
+ Assert.False(tab.Modified);
+ tab.Undo.Redo();
+ Assert.True(tab.Modified);
+ }
+
+ [Fact]
+ public void SaveAs_RecordsAnEditNoScopeRecorded_SoTheSaveCoversIt()
+ {
+ var tabs = new NestTabs();
+ var tab = OpenSaved(tabs, "unscoped.nest");
+ tab.Document.Nest.Plates[0].Parts[0].Offset(4, 0);
+ Assert.True(tab.Modified);
+
+ tabs.SaveAs(tab, InDir("saved.nest"));
+
+ Assert.False(tab.Modified);
+ Assert.Equal("Undo Change", tab.Undo.UndoText);
+ tab.Undo.Undo();
+ Assert.True(tab.Modified);
+ }
+
[Fact]
public void SaveAs_OntoItsOwnFile_IsAllowed()
{
var tabs = new NestTabs();
var tab = OpenSaved(tabs, "own.nest");
- tab.MarkModified();
+ TestNests.Edit(tab);
tabs.SaveAs(tab, tab.Path);
@@ -104,7 +148,7 @@ public sealed class NestTabsSaveTests : IDisposable
{
var tabs = new NestTabs();
var tab = OpenSaved(tabs, "kept.nest");
- tab.MarkModified();
+ TestNests.Edit(tab);
var changed = 0;
tabs.Changed += (_, _) => changed++;
@@ -123,7 +167,7 @@ public sealed class NestTabsSaveTests : IDisposable
{
var tabs = new NestTabs();
var tab = OpenSaved(tabs, "job.nest");
- tab.MarkModified();
+ TestNests.Edit(tab);
var target = InDir("copy.nest");
tabs.ExportCopy(tab, target);
diff --git a/OpenNest.Desktop.Tests/NestTabsTests.cs b/OpenNest.Desktop.Tests/NestTabsTests.cs
index 47c860ec..f7d28953 100644
--- a/OpenNest.Desktop.Tests/NestTabsTests.cs
+++ b/OpenNest.Desktop.Tests/NestTabsTests.cs
@@ -131,7 +131,7 @@ public class NestTabsTests
{
var tabs = Three(out var a, out var b, out _);
- b.MarkModified();
+ TestNests.Edit(b);
Assert.True(tabs.AnyModified);
Assert.False(a.Modified);
diff --git a/OpenNest.Desktop.Tests/TestNests.cs b/OpenNest.Desktop.Tests/TestNests.cs
index a52d120a..04a7a253 100644
--- a/OpenNest.Desktop.Tests/TestNests.cs
+++ b/OpenNest.Desktop.Tests/TestNests.cs
@@ -5,6 +5,10 @@ namespace OpenNest.Desktop.Tests;
internal static class TestNests
{
+ /// Records an edit of the tab's nest, as a desktop command does.
+ public static void Edit(NestTab tab) =>
+ tab.Undo.RecordChange("Edit", () => tab.Document.Nest.Notes += "edited");
+
/// A nest with one 48x96 plate holding one 10x10 square part.
public static Nest OneSquare()
{
diff --git a/OpenNest.Desktop.Tests/Undo/NestUndoRecorderTests.cs b/OpenNest.Desktop.Tests/Undo/NestUndoRecorderTests.cs
new file mode 100644
index 00000000..2fa5bef2
--- /dev/null
+++ b/OpenNest.Desktop.Tests/Undo/NestUndoRecorderTests.cs
@@ -0,0 +1,258 @@
+using OpenNest.Geometry;
+using OpenNest.Undo;
+
+namespace OpenNest.Desktop.Tests.Undo;
+
+public class NestUndoRecorderTests
+{
+ private static NestUndoRecorder Open(out Document document)
+ {
+ document = new Document { Nest = TestNests.OneSquare() };
+ var recorder = new NestUndoRecorder(document);
+ recorder.Reset();
+ return recorder;
+ }
+
+ private static Vector FirstPartLocation(Document document) =>
+ document.Nest.Plates[0].Parts[0].Location;
+
+ [Fact]
+ public void RecordedEdit_UndoesAndRedoesUnderItsLabel()
+ {
+ var recorder = Open(out var document);
+ var start = FirstPartLocation(document);
+
+ recorder.RecordChange("Move", () => document.Nest.Plates[0].Parts[0].Offset(5, 0));
+ var moved = FirstPartLocation(document);
+
+ Assert.Equal("Undo Move", recorder.UndoText);
+ Assert.Equal("Redo", recorder.RedoText);
+ Assert.True(recorder.CanUndo);
+
+ recorder.Undo();
+
+ Assert.Equal(start, FirstPartLocation(document));
+ Assert.Equal("Undo", recorder.UndoText);
+ Assert.Equal("Redo Move", recorder.RedoText);
+
+ recorder.Redo();
+
+ Assert.Equal(moved, FirstPartLocation(document));
+ Assert.Equal("Undo Move", recorder.UndoText);
+ }
+
+ [Fact]
+ public void Undo_SwapsTheRestoredCopyInThroughTheView_KeepingTheNestsIdentity()
+ {
+ var recorder = Open(out var document);
+ var live = document.Nest;
+ Nest swapped = null;
+ var restoringDuringSwap = false;
+ recorder.SwapIn = restored =>
+ {
+ restoringDuringSwap = recorder.IsRestoring && recorder.IsChangeInProgress;
+ // Rebinding the view is not an edit.
+ using (var change = recorder.BeginChange("Rebind"))
+ {
+ change.MarkChanged();
+ change.Complete();
+ }
+ swapped = restored;
+ document.Nest = restored;
+ };
+ var restoredEvents = 0;
+ recorder.Restored += (_, _) => restoredEvents++;
+ recorder.RecordChange("Move", () => live.Plates[0].Parts[0].Offset(5, 0));
+ live.Name = "renamed";
+ live.Status = NestStatus.HasBeenCut;
+
+ recorder.Undo();
+
+ Assert.True(restoringDuringSwap);
+ Assert.NotSame(live, document.Nest);
+ Assert.Same(swapped, document.Nest);
+ Assert.Equal("renamed", document.Nest.Name);
+ Assert.Equal(NestStatus.HasBeenCut, document.Nest.Status);
+ Assert.Equal(1, restoredEvents);
+ Assert.False(recorder.IsRestoring);
+ Assert.Equal(0, recorder.History.UndoCount);
+ Assert.Equal(1, recorder.History.RedoCount);
+ }
+
+ [Fact]
+ public void ASwapInThatRefuses_LeavesTheLiveNest()
+ {
+ var recorder = Open(out var document);
+ recorder.RecordChange("Move", () => document.Nest.Plates[0].Parts[0].Offset(5, 0));
+ var live = document.Nest;
+ recorder.SwapIn = _ => throw new InvalidOperationException("busy");
+
+ Assert.Throws(recorder.Undo);
+
+ Assert.Same(live, document.Nest);
+ Assert.False(recorder.IsChangeInProgress);
+ }
+
+ [Fact]
+ public void EditOutsideAScope_IsCommittedAsChangeBeforeUndo_SoRedoBringsItBack()
+ {
+ var recorder = Open(out var document);
+ var start = FirstPartLocation(document);
+
+ // An entry point that forgot its scope.
+ document.Nest.Plates[0].Parts[0].Offset(3, 0);
+ var moved = FirstPartLocation(document);
+
+ Assert.True(recorder.HasUnrecordedChange);
+ Assert.False(recorder.History.CanUndo);
+
+ recorder.Undo();
+
+ Assert.Equal(start, FirstPartLocation(document));
+ Assert.Equal("Redo Change", recorder.RedoText);
+ recorder.Redo();
+ Assert.Equal(moved, FirstPartLocation(document));
+ }
+
+ [Fact]
+ public void BeginChange_FirstCommitsAnEditNoScopeRecorded()
+ {
+ var recorder = Open(out var document);
+ document.Nest.Drawings.First().Quantity.Required = 7;
+
+ recorder.RecordChange("Rotate", () => document.Nest.Plates[0].Parts[0].Rotate(System.Math.PI / 2));
+
+ Assert.Equal(2, recorder.History.UndoCount);
+ Assert.Equal("Undo Rotate", recorder.UndoText);
+ recorder.Undo();
+ Assert.Equal("Undo Change", recorder.UndoText);
+ Assert.Equal(7, document.Nest.Drawings.First().Quantity.Required);
+ }
+
+ [Fact]
+ public void HasUnsavedChanges_FollowsTheSavePointAndUnrecordedEdits()
+ {
+ var recorder = Open(out var document);
+ Assert.False(recorder.HasUnsavedChanges);
+
+ document.Nest.Plates[0].Parts[0].Offset(1, 0);
+ Assert.True(recorder.HasUnsavedChanges);
+
+ recorder.Undo();
+ Assert.False(recorder.HasUnsavedChanges);
+
+ recorder.Redo();
+ Assert.True(recorder.HasUnsavedChanges);
+
+ recorder.MarkSaved();
+ Assert.False(recorder.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void MarkSaved_RecordsAnUnrecordedEditFirst_SoTheSavePointCoversIt()
+ {
+ var recorder = Open(out var document);
+ document.Nest.Plates[0].Parts[0].Offset(1, 0);
+
+ recorder.MarkSaved();
+
+ Assert.False(recorder.HasUnsavedChanges);
+ Assert.Equal("Undo Change", recorder.UndoText);
+ Assert.True(recorder.History.IsAtSavePoint);
+ }
+
+ [Fact]
+ public void AScopeLeftIncomplete_RollsTheNestBack()
+ {
+ var recorder = Open(out var document);
+ var start = FirstPartLocation(document);
+
+ using (var change = recorder.BeginChange("Push"))
+ {
+ change.MarkChanged();
+ document.Nest.Plates[0].Parts[0].Offset(9, 0);
+ // An exception or a cancellation: never completed.
+ }
+
+ Assert.Equal(start, FirstPartLocation(document));
+ Assert.False(recorder.History.CanUndo);
+ Assert.False(recorder.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void AScopeThatChangedNothing_RecordsNothing()
+ {
+ var recorder = Open(out _);
+ var changed = 0;
+ recorder.Changed += (_, _) => changed++;
+
+ using (var change = recorder.BeginChange("Fill"))
+ change.Complete();
+
+ Assert.False(recorder.History.CanUndo);
+ Assert.Equal(0, changed);
+ }
+
+ [Fact]
+ public void NestedScopes_UndoAsOneEntryUnderTheOuterLabel()
+ {
+ var recorder = Open(out var document);
+
+ using (var outer = recorder.BeginChange("Auto Nest"))
+ {
+ Assert.True(recorder.IsChangeInProgress);
+ Assert.False(recorder.CanUndo);
+ recorder.RecordChange("Add Plate", () => document.Nest.CreatePlate());
+ outer.MarkChanged();
+ document.Nest.Plates[0].Parts[0].Offset(2, 0);
+ outer.Complete();
+ }
+
+ Assert.Equal(1, recorder.History.UndoCount);
+ Assert.Equal("Undo Auto Nest", recorder.UndoText);
+ }
+
+ [Fact]
+ public void UndoAndRedo_DoNothingWhileAScopeIsOpen()
+ {
+ var recorder = Open(out var document);
+ recorder.RecordChange("Move", () => document.Nest.Plates[0].Parts[0].Offset(5, 0));
+ var live = document.Nest;
+
+ using (var change = recorder.BeginChange("Rotate"))
+ {
+ recorder.Undo();
+ Assert.Same(live, document.Nest);
+ change.Complete();
+ }
+
+ Assert.Equal(1, recorder.History.UndoCount);
+ }
+
+ [Fact]
+ public void TakeUnrecordedChangeWarning_ReportsEachUnrecordedEditOnce()
+ {
+ var recorder = Open(out var document);
+ Assert.False(recorder.TakeUnrecordedChangeWarning());
+
+ document.Nest.Plates[0].Parts[0].Offset(1, 0);
+
+ Assert.True(recorder.TakeUnrecordedChangeWarning());
+ Assert.False(recorder.TakeUnrecordedChangeWarning());
+ document.Nest.Plates[0].Parts[0].Offset(1, 0);
+ Assert.True(recorder.TakeUnrecordedChangeWarning());
+ }
+
+ [Fact]
+ public void MenuText_NamesTheNextStep()
+ {
+ var recorder = Open(out var document);
+ Assert.Equal("Undo", recorder.UndoText);
+ Assert.Equal("Redo", recorder.RedoText);
+ Assert.Equal("Undo", NestUndoRecorder.UndoMenuText(null));
+
+ recorder.RecordChange("Fill", () => document.Nest.Plates[0].Parts[0].Offset(1, 0));
+
+ Assert.Equal("Undo Fill", recorder.UndoText);
+ }
+}
diff --git a/OpenNest.Desktop/NestTabs.cs b/OpenNest.Desktop/NestTabs.cs
index 60199aa8..d3d90667 100644
--- a/OpenNest.Desktop/NestTabs.cs
+++ b/OpenNest.Desktop/NestTabs.cs
@@ -2,20 +2,32 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using OpenNest.Undo;
namespace OpenNest.Desktop
{
- /// An open nest: its document, its file and whether it has unsaved changes.
+ ///
+ /// An open nest: its document, its file, its undo history and whether it has unsaved changes.
+ ///
public sealed class NestTab
{
internal NestTab(Document document, string path)
{
Document = document;
Path = path;
+ // As the WinForms nest window binds the nest before its history starts: the empty
+ // plate kept at the end to nest onto belongs to the opened state, not to an edit.
+ using (var plates = new PlateManager(document.Nest))
+ plates.EnsureSentinel();
+ Undo = new NestUndoRecorder(document);
+ Undo.Reset();
}
public Document Document { get; }
+ /// Records this nest's edits and steps its Undo and Redo.
+ public NestUndoRecorder Undo { get; }
+
/// The full path of the file it was opened from or saved to, else null.
public string Path { get; internal set; }
@@ -25,17 +37,20 @@ namespace OpenNest.Desktop
///
public string SavePath => Document.HasSavePath ? Document.LastSavePath : null;
- /// True after a change this session that is not saved.
- public bool Modified { get; private set; }
+ ///
+ /// True when the nest differs from the state last saved or opened: off the history's
+ /// save point, or changed by an edit no scope recorded yet. Undoing back to the saved
+ /// state clears it.
+ ///
+ public bool Modified => Undo.HasUnsavedChanges;
/// The nest's name, else the file name.
public string Name => !string.IsNullOrWhiteSpace(Document.Name)
? Document.Name
: System.IO.Path.GetFileNameWithoutExtension(Path ?? string.Empty);
- public void MarkModified() => Modified = true;
-
- public void MarkSaved() => Modified = false;
+ /// Marks the nest as it stands saved, recording any unscoped edit first.
+ public void MarkSaved() => Undo.MarkSaved();
}
///
@@ -144,9 +159,10 @@ namespace OpenNest.Desktop
public void SaveAs(NestTab tab, string path)
{
var fullPath = CheckTarget(tab, path, export: false);
+ // The save marks the history's current state saved, so it must hold every edit.
+ tab.Undo.RecordUnrecordedChange();
tab.Document.SaveAs(fullPath);
tab.Path = fullPath;
- tab.MarkSaved();
Changed?.Invoke(this, EventArgs.Empty);
}
diff --git a/OpenNest.Desktop/Undo/NestUndoRecorder.cs b/OpenNest.Desktop/Undo/NestUndoRecorder.cs
new file mode 100644
index 00000000..2655aef1
--- /dev/null
+++ b/OpenNest.Desktop/Undo/NestUndoRecorder.cs
@@ -0,0 +1,256 @@
+using System;
+using OpenNest.Data;
+using OpenNest.IO;
+
+namespace OpenNest.Undo
+{
+ ///
+ /// Records a document's nest edits into its undo history and steps through it, for any
+ /// desktop front end. Edits go through or
+ /// ; an edit made outside a scope is found by fingerprint before
+ /// the next scope, Undo, Redo or save and committed as "Change". Undo and Redo read a
+ /// whole-nest copy back and hand it to , which the view sets to rebind
+ /// everything that held the old nest.
+ ///
+ public sealed class NestUndoRecorder
+ {
+ /// The label of an edit no scope recorded, found by fingerprint.
+ public const string UnrecordedChangeLabel = "Change";
+
+ private readonly Document document;
+ private ChangeScope openChange;
+ private int recordedFingerprint;
+ private int warnedFingerprint;
+
+ public NestUndoRecorder(Document document)
+ {
+ ArgumentNullException.ThrowIfNull(document);
+ this.document = document;
+ document.History.Changed += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
+ }
+
+ ///
+ /// Raised after the history or the recorded state changed: a commit, an Undo or Redo,
+ /// a save point or a restore. Unsaved-change marks and Edit menu labels follow it.
+ ///
+ public event EventHandler Changed;
+
+ /// Raised after swapped a nest in.
+ public event EventHandler Restored;
+
+ public Document Document => document;
+
+ public UndoHistory History => document.History;
+
+ ///
+ /// Puts a nest read back from the history in the document and rebinds the view to it.
+ /// It must set ; the default does only that. A view
+ /// that cannot take the nest now throws and the live nest stays.
+ ///
+ public Action SwapIn { get; set; }
+
+ /// True while swaps a nest in.
+ public bool IsRestoring { get; private set; }
+
+ /// True while a change scope is open or a nest is being swapped in.
+ public bool IsChangeInProgress => openChange != null || IsRestoring;
+
+ /// Whether would step back: there is a step and no edit is open.
+ public bool CanUndo => History.CanUndo && !IsChangeInProgress;
+
+ public bool CanRedo => History.CanRedo && !IsChangeInProgress;
+
+ /// "Undo Rotate", or "Undo" when there is nothing to undo.
+ public string UndoText => UndoMenuText(History);
+
+ /// "Redo Fill", or "Redo" when there is nothing to redo.
+ public string RedoText => RedoMenuText(History);
+
+ public static string UndoMenuText(UndoHistory history)
+ where TState : class, IUndoState =>
+ history?.UndoLabel is { } label ? $"Undo {label}" : "Undo";
+
+ public static string RedoMenuText(UndoHistory history)
+ where TState : class, IUndoState =>
+ history?.RedoLabel is { } label ? $"Redo {label}" : "Redo";
+
+ ///
+ /// True when the nest differs from the state last saved (or opened): the history has
+ /// moved off its save point, or an edit no scope recorded is pending.
+ ///
+ public bool HasUnsavedChanges =>
+ History.Current != null
+ && (!History.IsAtSavePoint || NestFingerprint.Compute(document.Nest) != recordedFingerprint);
+
+ /// True when the nest changed since the last recorded state without a scope.
+ public bool HasUnrecordedChange =>
+ History.Current != null && NestFingerprint.Compute(document.Nest) != recordedFingerprint;
+
+ ///
+ /// Starts the history over from the nest as it stands, as opening it does. The state is
+ /// treated as saved.
+ ///
+ public void Reset()
+ {
+ History.Reset(new NestUndoState(NestSnapshot.Capture(document.Nest)));
+ recordedFingerprint = NestFingerprint.Compute(document.Nest);
+ Changed?.Invoke(this, EventArgs.Empty);
+ }
+
+ ///
+ /// Opens a scope that records one undoable edit named . Mark it
+ /// changed and complete it; a scope opened inside another merges into the outermost one.
+ /// Repeated edits with an equal within a second undo as one.
+ ///
+ public ChangeScope BeginChange(string label, object coalesceKey = null)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(label);
+ if (openChange != null)
+ return ChangeScope.Nested(openChange);
+ // Swapping a nest in rebinds the view; nothing it does is an edit.
+ if (IsRestoring)
+ return ChangeScope.Detached();
+
+ RecordUnrecordedChange();
+ openChange = ChangeScope.Root(label, coalesceKey, EndChange);
+ return openChange;
+ }
+
+ /// Runs , which changes the nest, as one undoable step.
+ public void RecordChange(string label, Action edit)
+ {
+ ArgumentNullException.ThrowIfNull(edit);
+ using var change = BeginChange(label);
+ change.MarkChanged();
+ edit();
+ change.Complete();
+ }
+
+ /// Steps back one edit; does nothing while an edit or restore is in progress.
+ public void Undo()
+ {
+ if (IsChangeInProgress)
+ return;
+ RecordUnrecordedChange();
+ if (History.CanUndo)
+ Restore(History.Undo(), History.Redo);
+ }
+
+ /// Repeats the next undone edit; does nothing while an edit or restore is in progress.
+ public void Redo()
+ {
+ if (IsChangeInProgress)
+ return;
+ // An edit made since the undo drops what could be redone.
+ RecordUnrecordedChange();
+ if (History.CanRedo)
+ Restore(History.Redo(), History.Undo);
+ }
+
+ ///
+ /// Marks the nest as it stands saved, committing any edit no scope recorded first so
+ /// the save point covers it.
+ ///
+ public void MarkSaved()
+ {
+ RecordUnrecordedChange();
+ if (History.Current != null)
+ History.MarkSaved();
+ }
+
+ ///
+ /// Commits an edit no scope recorded as its own entry, so a rollback, Undo or Redo
+ /// does not silently discard it, and a save marks it saved. Detected by fingerprint, so
+ /// best effort.
+ ///
+ public void RecordUnrecordedChange()
+ {
+ if (HasUnrecordedChange)
+ Commit(UnrecordedChangeLabel, null);
+ }
+
+ ///
+ /// True the first time an unrecorded edit is seen, for a debug warning that an entry
+ /// point forgot its scope; false while a scope is open or for the edit already reported.
+ ///
+ public bool TakeUnrecordedChangeWarning()
+ {
+ if (IsChangeInProgress || History.Current == null)
+ return false;
+ var fingerprint = NestFingerprint.Compute(document.Nest);
+ if (fingerprint == recordedFingerprint || fingerprint == warnedFingerprint)
+ return false;
+ warnedFingerprint = fingerprint;
+ return true;
+ }
+
+ ///
+ /// Replaces the document's nest with , a copy read back from
+ /// the history, through . The nest's identity (name, status, author,
+ /// creation date) is carried over from the live nest; nothing it does is recorded.
+ ///
+ public void RestoreNest(Nest restored)
+ {
+ ArgumentNullException.ThrowIfNull(restored);
+ var live = document.Nest;
+ restored.Name = live.Name;
+ restored.Status = live.Status;
+ restored.MadeBy = live.MadeBy;
+ restored.DateCreated = live.DateCreated;
+
+ IsRestoring = true;
+ try
+ {
+ if (SwapIn != null)
+ SwapIn(restored);
+ else
+ document.Nest = restored;
+ }
+ finally
+ {
+ IsRestoring = false;
+ }
+
+ recordedFingerprint = NestFingerprint.Compute(document.Nest);
+ Changed?.Invoke(this, EventArgs.Empty);
+ Restored?.Invoke(this, EventArgs.Empty);
+ }
+
+ ///
+ /// Reads back and swaps it in; when the copy cannot be read the
+ /// live nest is untouched, so points the history back at it.
+ ///
+ private void Restore(NestUndoState state, Func moveBack)
+ {
+ Nest restored;
+ try
+ {
+ restored = state.Snapshot.Restore(document.Nest);
+ }
+ catch
+ {
+ moveBack();
+ throw;
+ }
+ RestoreNest(restored);
+ }
+
+ private void EndChange(ChangeScope scope)
+ {
+ openChange = null;
+ if (!scope.IsChanged)
+ return;
+ if (scope.Succeeded)
+ Commit(scope.Label, scope.CoalesceKey);
+ else
+ RestoreNest(History.Current.Snapshot.Restore(document.Nest));
+ }
+
+ private void Commit(string label, object coalesceKey)
+ {
+ History.Commit(label, new NestUndoState(NestSnapshot.Capture(document.Nest)), coalesceKey);
+ recordedFingerprint = NestFingerprint.Compute(document.Nest);
+ Changed?.Invoke(this, EventArgs.Empty);
+ }
+ }
+}
diff --git a/OpenNest/Forms/EditNestForm.Undo.cs b/OpenNest/Forms/EditNestForm.Undo.cs
index 9d1f101a..f125f59c 100644
--- a/OpenNest/Forms/EditNestForm.Undo.cs
+++ b/OpenNest/Forms/EditNestForm.Undo.cs
@@ -5,107 +5,60 @@ using System.Windows.Forms;
using OpenNest.Actions;
using OpenNest.CutOffs;
using OpenNest.Data;
-using OpenNest.IO;
using OpenNest.Undo;
namespace OpenNest.Forms
{
- // Undo and Redo: the history holds whole-nest copies and a restore swaps one in.
+ // Undo and Redo: the history holds whole-nest copies and a restore swaps one in. Recording,
+ // the fingerprint check and stepping are the shared NestUndoRecorder; this window rebinds.
public partial class EditNestForm
{
- private ChangeScope openChange;
- private int recordedFingerprint;
- private bool restoringNest;
+ private NestUndoRecorder undo;
/// Raised after swapped a nest in, for windows that show it.
public event EventHandler NestRestored;
public UndoHistory History => Document.History;
+ /// The shared recorder of this window's nest edits.
+ internal NestUndoRecorder UndoRecorder => undo ??= CreateRecorder();
+
/// True while a change scope is open or a nest is being swapped in.
- public bool IsChangeInProgress => openChange != null || restoringNest;
+ public bool IsChangeInProgress => UndoRecorder.IsChangeInProgress;
///
/// Opens a scope that records one undoable edit named . Mark it
/// changed and complete it; a scope opened inside another merges into the outermost one.
/// Repeated edits with an equal within a second undo as one.
///
- public ChangeScope BeginChange(string label, object coalesceKey = null)
- {
- ArgumentException.ThrowIfNullOrEmpty(label);
- if (openChange != null)
- return ChangeScope.Nested(openChange);
- // Swapping a nest in rebinds the view; nothing it does is an edit.
- if (restoringNest)
- return ChangeScope.Detached();
-
- RecordUnrecordedChange();
- openChange = ChangeScope.Root(label, coalesceKey, EndChange);
- return openChange;
- }
+ public ChangeScope BeginChange(string label, object coalesceKey = null) =>
+ UndoRecorder.BeginChange(label, coalesceKey);
/// Runs , which changes the nest, as one undoable step.
- public void RecordChange(string label, System.Action edit)
- {
- ArgumentNullException.ThrowIfNull(edit);
- using var change = BeginChange(label);
- change.MarkChanged();
- edit();
- change.Complete();
- }
+ public void RecordChange(string label, System.Action edit) => UndoRecorder.RecordChange(label, edit);
/// Whether would step back: no edit or inspection is open.
- public bool CanUndo => History.CanUndo && CanRestore;
+ public bool CanUndo => UndoRecorder.CanUndo && !IsInspecting;
- public bool CanRedo => History.CanRedo && CanRestore;
-
- private bool CanRestore => !IsChangeInProgress && !IsInspecting;
+ public bool CanRedo => UndoRecorder.CanRedo && !IsInspecting;
public void Undo()
{
- if (!CanRestore)
- return;
- RecordUnrecordedChange();
- if (History.CanUndo)
- Restore(History.Undo(), History.Redo);
+ if (!IsInspecting)
+ UndoRecorder.Undo();
}
public void Redo()
{
- if (!CanRestore)
- return;
- // An edit made since the undo drops what could be redone.
- RecordUnrecordedChange();
- if (History.CanRedo)
- Restore(History.Redo(), History.Undo);
- }
-
- ///
- /// Reads back and swaps it in; when the copy cannot be read the
- /// live nest is untouched, so points the history back at it.
- ///
- private void Restore(NestUndoState state, Func moveBack)
- {
- Nest restored;
- try
- {
- restored = state.Snapshot.Restore(Nest);
- }
- catch
- {
- moveBack();
- throw;
- }
- RestoreNest(restored);
+ if (!IsInspecting)
+ UndoRecorder.Redo();
}
///
/// True when the nest differs from the state last saved (or opened): the history has
/// moved off its save point, or an edit no scope recorded is pending.
///
- public bool HasUnsavedChanges =>
- History.Current != null
- && (!History.IsAtSavePoint || NestFingerprint.Compute(Nest) != recordedFingerprint);
+ public bool HasUnsavedChanges => UndoRecorder.HasUnsavedChanges;
///
/// Marks the nest as it stands saved, for a nest something else wrote to the server
@@ -113,54 +66,36 @@ namespace OpenNest.Forms
///
internal void MarkSaved()
{
- RecordUnrecordedChange();
- if (History.Current != null)
- History.MarkSaved();
+ UndoRecorder.MarkSaved();
UpdateTitle();
}
/// Shows the nest's name, marked with an asterisk while it has unsaved changes.
internal void UpdateTitle() => Text = HasUnsavedChanges ? $"{Nest.Name} *" : Nest.Name;
+ private NestUndoRecorder CreateRecorder()
+ {
+ var recorder = new NestUndoRecorder(Document) { SwapIn = SwapInRestoredNest };
+ recorder.Changed += (_, _) => UpdateTitle();
+ recorder.Restored += (_, _) => NestRestored?.Invoke(this, EventArgs.Empty);
+ return recorder;
+ }
+
private void InitializeHistory()
{
- History.Reset(new NestUndoState(NestSnapshot.Capture(Nest)));
- recordedFingerprint = NestFingerprint.Compute(Nest);
- History.Changed += (_, _) => UpdateTitle();
+ UndoRecorder.Reset();
#if DEBUG
Application.Idle += CheckForUnrecordedChange;
FormClosed += (_, _) => Application.Idle -= CheckForUnrecordedChange;
#endif
}
- private void EndChange(ChangeScope scope)
- {
- openChange = null;
- if (!scope.IsChanged)
- return;
- if (scope.Succeeded)
- Commit(scope.Label, scope.CoalesceKey);
- else
- RestoreNest(History.Current.Snapshot.Restore(Nest));
- }
-
- private void Commit(string label, object coalesceKey)
- {
- History.Commit(label, new NestUndoState(NestSnapshot.Capture(Nest)), coalesceKey);
- recordedFingerprint = NestFingerprint.Compute(Nest);
- UpdateTitle();
- }
-
///
/// Commits an edit no scope recorded as its own entry, so a rollback, Undo or Redo
/// does not silently discard it, and a save marks it saved. Detected by fingerprint, so
/// best effort.
///
- internal void RecordUnrecordedChange()
- {
- if (History.Current != null && NestFingerprint.Compute(Nest) != recordedFingerprint)
- Commit("Change", null);
- }
+ internal void RecordUnrecordedChange() => UndoRecorder.RecordUnrecordedChange();
///
/// Replaces the document's nest with , a copy read back from
@@ -168,20 +103,16 @@ namespace OpenNest.Forms
/// identity (name, status, author, creation date), the plate shown, the viewport and the
/// selection (by part index) are kept.
///
- public void RestoreNest(Nest restored)
+ public void RestoreNest(Nest restored) => UndoRecorder.RestoreNest(restored);
+
+ // The recorder's swap-in, run with the identity already carried over and nothing recorded.
+ private void SwapInRestoredNest(Nest restored)
{
- ArgumentNullException.ThrowIfNull(restored);
// Apply after a restore would commit to plates no longer in the nest.
CuttingInspection?.RequestClose();
if (CuttingInspection != null)
throw new InvalidOperationException("Close Cutting Inspection before undoing.");
- var live = Nest;
- restored.Name = live.Name;
- restored.Status = live.Status;
- restored.MadeBy = live.MadeBy;
- restored.DateCreated = live.DateCreated;
-
var plateIndex = PlateManager.CurrentIndex;
var partCount = PlateView.Plate?.Parts.Count ?? 0;
var selected = PlateView.SelectedParts
@@ -190,55 +121,43 @@ namespace OpenNest.Forms
.ToArray();
var viewport = PlateView.CaptureViewport();
- restoringNest = true;
+ // Actions, previews and side panels hold parts and plates of the old nest.
+ PlateView.EndAction();
+ PlateView.ClearPreviewParts();
+ PlateView.CancelOverlapCheck();
+ drawingListBox1.CancelPickup();
+ addPart = false;
+ if (IsSidePanelVisible)
+ HideSidePanel();
+
+ UnbindNest();
+ Document.Nest = restored;
+ BindNest(restored);
+ // Reading regenerates cut-offs with default settings; the view's apply here.
+ // Regenerating empties a cut-off-only plate for a moment, so the batch keeps
+ // the new-plate sentinel from adding or dropping plates until all are done.
+ PlateManager.BeginBatch();
try
{
- // Actions, previews and side panels hold parts and plates of the old nest.
- PlateView.EndAction();
- PlateView.ClearPreviewParts();
- PlateView.CancelOverlapCheck();
- drawingListBox1.CancelPickup();
- addPart = false;
- if (IsSidePanelVisible)
- HideSidePanel();
-
- UnbindNest();
- Document.Nest = restored;
- BindNest(restored);
- // Reading regenerates cut-offs with default settings; the view's apply here.
- // Regenerating empties a cut-off-only plate for a moment, so the batch keeps
- // the new-plate sentinel from adding or dropping plates until all are done.
- PlateManager.BeginBatch();
- try
- {
- foreach (var plate in restored.Plates.Where(plate => plate.CutOffs.Count > 0).ToArray())
- plate.RegenerateCutOffs(PlateView.CutOffSettings);
- }
- finally
- {
- PlateManager.EndBatch();
- }
- ColorSchemeRegistry.RecolorDrawings(restored);
-
- // Loading a plate zooms to fit; an undo keeps the view where it was.
- PlateManager.LoadAt(System.Math.Clamp(plateIndex, 0, System.Math.Max(0, PlateManager.Count - 1)));
- PlateView.RestoreViewport(viewport);
- if (PlateView.Plate.Parts.Count == partCount && selected.Length > 0)
- Reselect(selected);
-
- UpdatePlateList();
- UpdatePlateHeader();
- UpdateRemovePlateButton();
- PlateView.Invalidate();
+ foreach (var plate in restored.Plates.Where(plate => plate.CutOffs.Count > 0).ToArray())
+ plate.RegenerateCutOffs(PlateView.CutOffSettings);
}
finally
{
- restoringNest = false;
+ PlateManager.EndBatch();
}
+ ColorSchemeRegistry.RecolorDrawings(restored);
- recordedFingerprint = NestFingerprint.Compute(Nest);
- UpdateTitle();
- NestRestored?.Invoke(this, EventArgs.Empty);
+ // Loading a plate zooms to fit; an undo keeps the view where it was.
+ PlateManager.LoadAt(System.Math.Clamp(plateIndex, 0, System.Math.Max(0, PlateManager.Count - 1)));
+ PlateView.RestoreViewport(viewport);
+ if (PlateView.Plate.Parts.Count == partCount && selected.Length > 0)
+ Reselect(selected);
+
+ UpdatePlateList();
+ UpdatePlateHeader();
+ UpdateRemovePlateButton();
+ PlateView.Invalidate();
}
private void Reselect(int[] indices)
@@ -255,7 +174,6 @@ namespace OpenNest.Forms
#if DEBUG
private DateTime nextUnrecordedCheck;
- private int warnedFingerprint;
// Every edit should open a change scope; one that does not folds into the next entry.
private void CheckForUnrecordedChange(object sender, EventArgs e)
@@ -263,11 +181,8 @@ namespace OpenNest.Forms
if (IsDisposed || IsChangeInProgress || PlateView.IsFillInProgress || DateTime.UtcNow < nextUnrecordedCheck)
return;
nextUnrecordedCheck = DateTime.UtcNow.AddMilliseconds(500);
- var fingerprint = NestFingerprint.Compute(Nest);
- if (fingerprint == recordedFingerprint || fingerprint == warnedFingerprint)
- return;
- warnedFingerprint = fingerprint;
- Debug.WriteLine($"Undo: '{Nest.Name}' changed outside a change scope; the edit is not recorded yet.");
+ if (UndoRecorder.TakeUnrecordedChangeWarning())
+ Debug.WriteLine($"Undo: '{Nest.Name}' changed outside a change scope; the edit is not recorded yet.");
}
#endif
}
diff --git a/OpenNest/Forms/MainForm.Undo.cs b/OpenNest/Forms/MainForm.Undo.cs
index c37412f9..a65343d9 100644
--- a/OpenNest/Forms/MainForm.Undo.cs
+++ b/OpenNest/Forms/MainForm.Undo.cs
@@ -2,6 +2,7 @@ using System;
using System.Linq;
using System.Windows.Forms;
using OpenNest.Controls;
+using OpenNest.Undo;
namespace OpenNest.Forms
{
@@ -119,8 +120,8 @@ namespace OpenNest.Forms
var history = form?.History;
mnuEditUndo.Enabled = available && form.CanUndo;
mnuEditRedo.Enabled = available && form.CanRedo;
- mnuEditUndo.Text = history?.UndoLabel is { } undo ? $"Undo {undo}" : "Undo";
- mnuEditRedo.Text = history?.RedoLabel is { } redo ? $"Redo {redo}" : "Redo";
+ mnuEditUndo.Text = NestUndoRecorder.UndoMenuText(history);
+ mnuEditRedo.Text = NestUndoRecorder.RedoMenuText(history);
}
private void EditForm_NestRestored(object sender, EventArgs e)
diff --git a/docs/undo-redo.md b/docs/undo-redo.md
index 0f5fcfd2..ebcec6b1 100644
--- a/docs/undo-redo.md
+++ b/docs/undo-redo.md
@@ -10,10 +10,11 @@ once its entry point records it.
|---|---|
| History engine (states, cursor, limits, coalescing, save point) | `OpenNest.Data/UndoHistory.cs` |
| State codec | `OpenNest.IO/NestSnapshot.cs` |
-| Recording scope, fingerprint, history state | `OpenNest.Desktop/Undo/` (shared with `OpenNest.Avalonia`) |
-| Recording, restore, safety net | `OpenNest/Forms/EditNestForm.Undo.cs` |
-| Menu, keys, guards | `OpenNest/Forms/MainForm.Undo.cs`, `MainForm.Guards.cs` (`IsUndoBusy`) |
-| Tests | `OpenNest.WinForms.Tests/Forms/UndoRedoTests.cs`, `UndoRecordingTests.cs`; scope and fingerprint on Linux in `OpenNest.Desktop.Tests/Undo/` |
+| Recording, stepping, safety net, menu labels (`NestUndoRecorder`), scope, fingerprint, history state | `OpenNest.Desktop/Undo/` (shared with `OpenNest.Avalonia`) |
+| WinForms restore (rebinding the window), DEBUG idle check | `OpenNest/Forms/EditNestForm.Undo.cs` |
+| WinForms menu, keys, guards | `OpenNest/Forms/MainForm.Undo.cs`, `MainForm.Guards.cs` (`IsUndoBusy`) |
+| Avalonia: one recorder per tab (`NestTab.Undo`), restore, menu, keys, guards | `OpenNest.Desktop/NestTabs.cs`, `OpenNest.Avalonia/MainWindow.Undo.cs`, `NestView.axaml.cs` |
+| Tests | `OpenNest.WinForms.Tests/Forms/UndoRedoTests.cs`, `UndoRecordingTests.cs`; recorder, scope and fingerprint on Linux in `OpenNest.Desktop.Tests/Undo/` |
## What is undoable
@@ -156,6 +157,14 @@ the program editor keeps its own Ctrl+Z and Ctrl+Y (`KeepsOwnUndo`). Otherwise C
asks the current action's `TryUndoStep()`: Sequence by Path removes its last line, and only
when it has none does the nest undo.
+The Avalonia app follows the same rules through the shared recorder: each tab's `NestTab.Undo`
+starts its history when the nest opens (after the trailing empty plate is added), the tab's
+unsaved dot is `HasUnsavedChanges`, Save records any unscoped edit first, and Auto Nest's commit
+is one "Auto Nest" step. Its window routes the keys in a tunnelling key handler, leaves them to
+a focused `TextBox` (numeric and editable combo boxes and grid editors contain one), disables
+Undo and Redo while Auto Nest runs on the nest, and rebinds the Edit menu when the active tab
+changes. A restore keeps the shown plate, zoom, pan and selection (by part index).
+
## Limits and cost
- 50 undo steps and 128 MB per open nest (`UndoHistory.DefaultMaxDepth`, `DefaultMaxBytes`);