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`);