Merge branch 'worktree-agent-afafee97ace7d9df4' into claude/avalonia-port-next-tasks-f1f6b6

This commit is contained in:
aj committed 2026-11-13 20:22:37 -05:00
commit d6a551fdea
14 files changed
+923 -174

No files matched your search

+14
View File
@@ -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;
+164
View File
@@ -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 &gt; 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
}
}
+5
View File
@@ -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>
+33 -5
View File
@@ -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>
+36 -1
View File
@@ -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} "
+49 -5
View File
@@ -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);
+1 -1
View File
@@ -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);
+4
View File
@@ -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);
}
}
+23 -7
View File
@@ -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);
}
+256
View File
@@ -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);
}
}
}
+64 -149
View File
@@ -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
}
+3 -2
View File
@@ -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
View File
@@ -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`);