mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-14 05:45:07 -05:00
Merge branch 'worktree-agent-afafee97ace7d9df4' into claude/avalonia-port-next-tasks-f1f6b6
This commit is contained in:
commit
d6a551fdea
14 files changed
+923
-174
No files matched your search
@@ -59,6 +59,20 @@ namespace OpenNest.Avalonia.Controls
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows <paramref name="value"/>, a copy of the shown plate an Undo or Redo read back,
|
||||
/// keeping the zoom and pan, and selects its part at <paramref name="selectedIndex"/>
|
||||
/// (none when out of range).
|
||||
/// </summary>
|
||||
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];
|
||||
}
|
||||
|
||||
/// <summary>The colours drawn with: the application's active scheme unless set.</summary>
|
||||
public ColorScheme Scheme { get; set; } = ColorScheme.Default;
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Edit > Undo and Redo of the active nest, through its tab's shared
|
||||
/// <see cref="NestUndoRecorder"/>: 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.
|
||||
/// </summary>
|
||||
public partial class MainWindow
|
||||
{
|
||||
// Each open tab's history handler, so closing the tab unhooks it.
|
||||
private readonly System.Collections.Generic.Dictionary<NestTab, EventHandler> undoHandlers = new();
|
||||
|
||||
/// <summary>Follows <paramref name="tab"/>'s history: its unsaved dot and, while active, the Edit menu.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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();
|
||||
|
||||
/// <summary>Shows the active nest's next Undo and Redo steps, enabled when they can run.</summary>
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,11 @@
|
||||
<Separator />
|
||||
<MenuItem Header="E_xit" InputGesture="Ctrl+Q" Click="OnExit" />
|
||||
</MenuItem>
|
||||
<MenuItem x:Name="EditMenu" Header="_Edit" SubmenuOpened="OnEditMenuOpened">
|
||||
<!-- Ctrl+Z, Ctrl+Y and Ctrl+Shift+Z are routed in MainWindow.Undo.cs, so text boxes keep theirs. -->
|
||||
<MenuItem x:Name="UndoMenuItem" Header="_Undo" InputGesture="Ctrl+Z" Click="OnUndo" />
|
||||
<MenuItem x:Name="RedoMenuItem" Header="_Redo" InputGesture="Ctrl+Y" Click="OnRedo" />
|
||||
</MenuItem>
|
||||
<MenuItem Header="_Nest">
|
||||
<MenuItem x:Name="AutoNestMenuItem" Header="_Auto Nest" Click="OnAutoNest" />
|
||||
</MenuItem>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/// <summary>Completes once the machines are read (see <see cref="DesktopSettings.LoadMachinesAsync"/>).</summary>
|
||||
|
||||
@@ -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);
|
||||
|
||||
/// <summary>Raised when the shown plate, zoom, hover or selection changes.</summary>
|
||||
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} "
|
||||
|
||||
@@ -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<SaveTargetRefusedException>(() => 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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -5,6 +5,10 @@ namespace OpenNest.Desktop.Tests;
|
||||
|
||||
internal static class TestNests
|
||||
{
|
||||
/// <summary>Records an edit of the tab's nest, as a desktop command does.</summary>
|
||||
public static void Edit(NestTab tab) =>
|
||||
tab.Undo.RecordChange("Edit", () => tab.Document.Nest.Notes += "edited");
|
||||
|
||||
/// <summary>A nest with one 48x96 plate holding one 10x10 square part.</summary>
|
||||
public static Nest OneSquare()
|
||||
{
|
||||
|
||||
@@ -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<InvalidOperationException>(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<NestUndoState>(null));
|
||||
|
||||
recorder.RecordChange("Fill", () => document.Nest.Plates[0].Parts[0].Offset(1, 0));
|
||||
|
||||
Assert.Equal("Undo Fill", recorder.UndoText);
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,32 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using OpenNest.Undo;
|
||||
|
||||
namespace OpenNest.Desktop
|
||||
{
|
||||
/// <summary>An open nest: its document, its file and whether it has unsaved changes.</summary>
|
||||
/// <summary>
|
||||
/// An open nest: its document, its file, its undo history and whether it has unsaved changes.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>Records this nest's edits and steps its Undo and Redo.</summary>
|
||||
public NestUndoRecorder Undo { get; }
|
||||
|
||||
/// <summary>The full path of the file it was opened from or saved to, else null.</summary>
|
||||
public string Path { get; internal set; }
|
||||
|
||||
@@ -25,17 +37,20 @@ namespace OpenNest.Desktop
|
||||
/// </summary>
|
||||
public string SavePath => Document.HasSavePath ? Document.LastSavePath : null;
|
||||
|
||||
/// <summary>True after a change this session that is not saved.</summary>
|
||||
public bool Modified { get; private set; }
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool Modified => Undo.HasUnsavedChanges;
|
||||
|
||||
/// <summary>The nest's name, else the file name.</summary>
|
||||
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;
|
||||
/// <summary>Marks the nest as it stands saved, recording any unscoped edit first.</summary>
|
||||
public void MarkSaved() => Undo.MarkSaved();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
using System;
|
||||
using OpenNest.Data;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Undo
|
||||
{
|
||||
/// <summary>
|
||||
/// Records a document's nest edits into its undo history and steps through it, for any
|
||||
/// desktop front end. Edits go through <see cref="BeginChange"/> or
|
||||
/// <see cref="RecordChange"/>; 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 <see cref="SwapIn"/>, which the view sets to rebind
|
||||
/// everything that held the old nest.
|
||||
/// </summary>
|
||||
public sealed class NestUndoRecorder
|
||||
{
|
||||
/// <summary>The label of an edit no scope recorded, found by fingerprint.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public event EventHandler Changed;
|
||||
|
||||
/// <summary>Raised after <see cref="RestoreNest"/> swapped a nest in.</summary>
|
||||
public event EventHandler Restored;
|
||||
|
||||
public Document Document => document;
|
||||
|
||||
public UndoHistory<NestUndoState> History => document.History;
|
||||
|
||||
/// <summary>
|
||||
/// Puts a nest read back from the history in the document and rebinds the view to it.
|
||||
/// It must set <see cref="OpenNest.Document.Nest"/>; the default does only that. A view
|
||||
/// that cannot take the nest now throws and the live nest stays.
|
||||
/// </summary>
|
||||
public Action<Nest> SwapIn { get; set; }
|
||||
|
||||
/// <summary>True while <see cref="RestoreNest"/> swaps a nest in.</summary>
|
||||
public bool IsRestoring { get; private set; }
|
||||
|
||||
/// <summary>True while a change scope is open or a nest is being swapped in.</summary>
|
||||
public bool IsChangeInProgress => openChange != null || IsRestoring;
|
||||
|
||||
/// <summary>Whether <see cref="Undo"/> would step back: there is a step and no edit is open.</summary>
|
||||
public bool CanUndo => History.CanUndo && !IsChangeInProgress;
|
||||
|
||||
public bool CanRedo => History.CanRedo && !IsChangeInProgress;
|
||||
|
||||
/// <summary>"Undo Rotate", or "Undo" when there is nothing to undo.</summary>
|
||||
public string UndoText => UndoMenuText(History);
|
||||
|
||||
/// <summary>"Redo Fill", or "Redo" when there is nothing to redo.</summary>
|
||||
public string RedoText => RedoMenuText(History);
|
||||
|
||||
public static string UndoMenuText<TState>(UndoHistory<TState> history)
|
||||
where TState : class, IUndoState =>
|
||||
history?.UndoLabel is { } label ? $"Undo {label}" : "Undo";
|
||||
|
||||
public static string RedoMenuText<TState>(UndoHistory<TState> history)
|
||||
where TState : class, IUndoState =>
|
||||
history?.RedoLabel is { } label ? $"Redo {label}" : "Redo";
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool HasUnsavedChanges =>
|
||||
History.Current != null
|
||||
&& (!History.IsAtSavePoint || NestFingerprint.Compute(document.Nest) != recordedFingerprint);
|
||||
|
||||
/// <summary>True when the nest changed since the last recorded state without a scope.</summary>
|
||||
public bool HasUnrecordedChange =>
|
||||
History.Current != null && NestFingerprint.Compute(document.Nest) != recordedFingerprint;
|
||||
|
||||
/// <summary>
|
||||
/// Starts the history over from the nest as it stands, as opening it does. The state is
|
||||
/// treated as saved.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
History.Reset(new NestUndoState(NestSnapshot.Capture(document.Nest)));
|
||||
recordedFingerprint = NestFingerprint.Compute(document.Nest);
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a scope that records one undoable edit named <paramref name="label"/>. Mark it
|
||||
/// changed and complete it; a scope opened inside another merges into the outermost one.
|
||||
/// Repeated edits with an equal <paramref name="coalesceKey"/> within a second undo as one.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Runs <paramref name="edit"/>, which changes the nest, as one undoable step.</summary>
|
||||
public void RecordChange(string label, Action edit)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(edit);
|
||||
using var change = BeginChange(label);
|
||||
change.MarkChanged();
|
||||
edit();
|
||||
change.Complete();
|
||||
}
|
||||
|
||||
/// <summary>Steps back one edit; does nothing while an edit or restore is in progress.</summary>
|
||||
public void Undo()
|
||||
{
|
||||
if (IsChangeInProgress)
|
||||
return;
|
||||
RecordUnrecordedChange();
|
||||
if (History.CanUndo)
|
||||
Restore(History.Undo(), History.Redo);
|
||||
}
|
||||
|
||||
/// <summary>Repeats the next undone edit; does nothing while an edit or restore is in progress.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the nest as it stands saved, committing any edit no scope recorded first so
|
||||
/// the save point covers it.
|
||||
/// </summary>
|
||||
public void MarkSaved()
|
||||
{
|
||||
RecordUnrecordedChange();
|
||||
if (History.Current != null)
|
||||
History.MarkSaved();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void RecordUnrecordedChange()
|
||||
{
|
||||
if (HasUnrecordedChange)
|
||||
Commit(UnrecordedChangeLabel, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the document's nest with <paramref name="restored"/>, a copy read back from
|
||||
/// the history, through <see cref="SwapIn"/>. The nest's identity (name, status, author,
|
||||
/// creation date) is carried over from the live nest; nothing it does is recorded.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads <paramref name="state"/> back and swaps it in; when the copy cannot be read the
|
||||
/// live nest is untouched, so <paramref name="moveBack"/> points the history back at it.
|
||||
/// </summary>
|
||||
private void Restore(NestUndoState state, Func<NestUndoState> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Raised after <see cref="RestoreNest"/> swapped a nest in, for windows that show it.</summary>
|
||||
public event EventHandler NestRestored;
|
||||
|
||||
public UndoHistory<NestUndoState> History => Document.History;
|
||||
|
||||
/// <summary>The shared recorder of this window's nest edits.</summary>
|
||||
internal NestUndoRecorder UndoRecorder => undo ??= CreateRecorder();
|
||||
|
||||
/// <summary>True while a change scope is open or a nest is being swapped in.</summary>
|
||||
public bool IsChangeInProgress => openChange != null || restoringNest;
|
||||
public bool IsChangeInProgress => UndoRecorder.IsChangeInProgress;
|
||||
|
||||
/// <summary>
|
||||
/// Opens a scope that records one undoable edit named <paramref name="label"/>. Mark it
|
||||
/// changed and complete it; a scope opened inside another merges into the outermost one.
|
||||
/// Repeated edits with an equal <paramref name="coalesceKey"/> within a second undo as one.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>Runs <paramref name="edit"/>, which changes the nest, as one undoable step.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>Whether <see cref="Undo"/> would step back: no edit or inspection is open.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads <paramref name="state"/> back and swaps it in; when the copy cannot be read the
|
||||
/// live nest is untouched, so <paramref name="moveBack"/> points the history back at it.
|
||||
/// </summary>
|
||||
private void Restore(NestUndoState state, Func<NestUndoState> moveBack)
|
||||
{
|
||||
Nest restored;
|
||||
try
|
||||
{
|
||||
restored = state.Snapshot.Restore(Nest);
|
||||
}
|
||||
catch
|
||||
{
|
||||
moveBack();
|
||||
throw;
|
||||
}
|
||||
RestoreNest(restored);
|
||||
if (!IsInspecting)
|
||||
UndoRecorder.Redo();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool HasUnsavedChanges =>
|
||||
History.Current != null
|
||||
&& (!History.IsAtSavePoint || NestFingerprint.Compute(Nest) != recordedFingerprint);
|
||||
public bool HasUnsavedChanges => UndoRecorder.HasUnsavedChanges;
|
||||
|
||||
/// <summary>
|
||||
/// Marks the nest as it stands saved, for a nest something else wrote to the server
|
||||
@@ -113,54 +66,36 @@ namespace OpenNest.Forms
|
||||
/// </summary>
|
||||
internal void MarkSaved()
|
||||
{
|
||||
RecordUnrecordedChange();
|
||||
if (History.Current != null)
|
||||
History.MarkSaved();
|
||||
UndoRecorder.MarkSaved();
|
||||
UpdateTitle();
|
||||
}
|
||||
|
||||
/// <summary>Shows the nest's name, marked with an asterisk while it has unsaved changes.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal void RecordUnrecordedChange()
|
||||
{
|
||||
if (History.Current != null && NestFingerprint.Compute(Nest) != recordedFingerprint)
|
||||
Commit("Change", null);
|
||||
}
|
||||
internal void RecordUnrecordedChange() => UndoRecorder.RecordUnrecordedChange();
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the document's nest with <paramref name="restored"/>, 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.
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+13
-4
@@ -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`);
|
||||
|
||||
Reference in new issue
Block a user