diff --git a/OpenNest.Avalonia/Controls/PlateCanvas.cs b/OpenNest.Avalonia/Controls/PlateCanvas.cs
index 2f1c5f1b..b591a02b 100644
--- a/OpenNest.Avalonia/Controls/PlateCanvas.cs
+++ b/OpenNest.Avalonia/Controls/PlateCanvas.cs
@@ -59,6 +59,20 @@ namespace OpenNest.Avalonia.Controls
}
}
+ ///
+ /// Shows , a copy of the shown plate an Undo or Redo read back,
+ /// keeping the zoom and pan, and selects its part at
+ /// (none when out of range).
+ ///
+ public void ReplacePlate(Plate value, int selectedIndex)
+ {
+ var fit = fitPending;
+ Plate = value;
+ fitPending = fit;
+ if (value != null && selectedIndex >= 0 && selectedIndex < value.Parts.Count)
+ SelectedPart = value.Parts[selectedIndex];
+ }
+
/// The colours drawn with: the application's active scheme unless set.
public ColorScheme Scheme { get; set; } = ColorScheme.Default;
diff --git a/OpenNest.Avalonia/MainWindow.Undo.cs b/OpenNest.Avalonia/MainWindow.Undo.cs
new file mode 100644
index 00000000..5d07d254
--- /dev/null
+++ b/OpenNest.Avalonia/MainWindow.Undo.cs
@@ -0,0 +1,164 @@
+using System;
+using System.Diagnostics;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+using OpenNest.Desktop;
+using OpenNest.Undo;
+using Visual = global::Avalonia.Visual;
+
+namespace OpenNest.Avalonia
+{
+ ///
+ /// Edit > Undo and Redo of the active nest, through its tab's shared
+ /// : Ctrl+Z, Ctrl+Y and Ctrl+Shift+Z unless a text box has
+ /// focus, the menu naming the next step, and nothing while Auto Nest works on the nest.
+ /// Switching tabs rebinds the menu to the active nest's history.
+ ///
+ public partial class MainWindow
+ {
+ // Each open tab's history handler, so closing the tab unhooks it.
+ private readonly System.Collections.Generic.Dictionary undoHandlers = new();
+
+ /// Follows 's history: its unsaved dot and, while active, the Edit menu.
+ private void WatchHistory(NestTab tab)
+ {
+ EventHandler handler = (_, _) => OnHistoryChanged(tab);
+ undoHandlers[tab] = handler;
+ tab.Undo.Changed += handler;
+ }
+
+ private void UnwatchHistory(NestTab tab)
+ {
+ if (undoHandlers.Remove(tab, out var handler))
+ tab.Undo.Changed -= handler;
+ }
+
+ private void OnHistoryChanged(NestTab tab)
+ {
+ RebuildTabStrip();
+ if (tab == tabs.Active)
+ UpdateUndoMenu();
+ }
+
+ ///
+ /// Handles Ctrl+Z, Ctrl+Y and Ctrl+Shift+Z for the active nest, as the WinForms main
+ /// window does. A focused text editor keeps them for its own typing.
+ ///
+ private bool ProcessUndoKeys(KeyEventArgs e)
+ {
+ var modifiers = e.KeyModifiers & ~KeyModifiers.Shift;
+ if (modifiers != KeyModifiers.Control || e.Key is not (Key.Z or Key.Y))
+ return false;
+ var shift = e.KeyModifiers.HasFlag(KeyModifiers.Shift);
+ if (e.Key == Key.Y && shift)
+ return false;
+ if (tabs.Active == null || KeepsOwnUndo(FocusManager?.GetFocusedElement() as Visual))
+ return false;
+
+ if (e.Key == Key.Z && !shift)
+ TryUndo(tabs.Active);
+ else
+ TryRedo(tabs.Active);
+ return true;
+ }
+
+ ///
+ /// True for controls that undo their own typing: a text box, including the one inside a
+ /// numeric box, an editable combo box or a grid cell being edited.
+ ///
+ internal static bool KeepsOwnUndo(Visual focused)
+ {
+ for (var visual = focused; visual != null; visual = visual.GetVisualParent())
+ {
+ if (visual is TextBox)
+ return true;
+ }
+ return false;
+ }
+
+ // Not while Auto Nest works on the nest: its result is applied to the nest it started on.
+ private bool CanUndoOrRedo(NestTab tab) => tab != null && tab != nestingTab;
+
+ private bool TryUndo(NestTab tab)
+ {
+ if (!CanUndoOrRedo(tab) || !tab.Undo.CanUndo)
+ return false;
+ return Step(tab, "undo", tab.Undo.Undo);
+ }
+
+ private bool TryRedo(NestTab tab)
+ {
+ if (!CanUndoOrRedo(tab) || !tab.Undo.CanRedo)
+ return false;
+ return Step(tab, "redo", tab.Undo.Redo);
+ }
+
+ private bool Step(NestTab tab, string what, Action step)
+ {
+ try
+ {
+ step();
+ }
+ catch (Exception ex)
+ {
+ // A copy that cannot be read back leaves the nest and the history as they were.
+ ShowNotice($"Could not {what} in {tab.Name}: {ex.Message}");
+ UpdateUndoMenu();
+ return false;
+ }
+ UpdateUndoMenu();
+ return true;
+ }
+
+ private void OnUndo(object sender, RoutedEventArgs e)
+ {
+ if (tabs.Active != null)
+ TryUndo(tabs.Active);
+ }
+
+ private void OnRedo(object sender, RoutedEventArgs e)
+ {
+ if (tabs.Active != null)
+ TryRedo(tabs.Active);
+ }
+
+ // Busy states have no change event of their own; check them whenever the menu opens.
+ private void OnEditMenuOpened(object sender, RoutedEventArgs e) => UpdateUndoMenu();
+
+ /// Shows the active nest's next Undo and Redo steps, enabled when they can run.
+ private void UpdateUndoMenu()
+ {
+ var tab = tabs.Active;
+ var available = CanUndoOrRedo(tab);
+ UndoMenuItem.IsEnabled = available && tab.Undo.CanUndo;
+ RedoMenuItem.IsEnabled = available && tab.Undo.CanRedo;
+ UndoMenuItem.Header = AccessKeyed(tab?.Undo.UndoText ?? "Undo");
+ RedoMenuItem.Header = AccessKeyed(tab?.Undo.RedoText ?? "Redo");
+ }
+
+ // "Undo Rotate" as "_Undo Rotate": U and R stay the access keys, a label's own
+ // underscores are shown as typed.
+ private static string AccessKeyed(string text) => "_" + text.Replace("_", "__");
+
+#if DEBUG
+ private DispatcherTimer unrecordedCheck;
+
+ // Every edit should open a change scope; one that does not folds into the next entry.
+ private void StartUnrecordedChangeCheck()
+ {
+ unrecordedCheck = new DispatcherTimer(TimeSpan.FromMilliseconds(500), DispatcherPriority.Background,
+ (_, _) =>
+ {
+ var tab = tabs.Active;
+ if (tab != null && tab != nestingTab && tab.Undo.TakeUnrecordedChangeWarning())
+ Debug.WriteLine($"Undo: '{tab.Name}' changed outside a change scope; the edit is not recorded yet.");
+ });
+ unrecordedCheck.Start();
+ Closed += (_, _) => unrecordedCheck.Stop();
+ }
+#endif
+ }
+}
diff --git a/OpenNest.Avalonia/MainWindow.axaml b/OpenNest.Avalonia/MainWindow.axaml
index ff16e5db..15cb0459 100644
--- a/OpenNest.Avalonia/MainWindow.axaml
+++ b/OpenNest.Avalonia/MainWindow.axaml
@@ -18,6 +18,11 @@
+
diff --git a/OpenNest.Avalonia/MainWindow.axaml.cs b/OpenNest.Avalonia/MainWindow.axaml.cs
index b72fea1b..18b92120 100644
--- a/OpenNest.Avalonia/MainWindow.axaml.cs
+++ b/OpenNest.Avalonia/MainWindow.axaml.cs
@@ -90,6 +90,9 @@ namespace OpenNest.Avalonia
AddHandler(DragDrop.DragOverEvent, OnDragOver);
AddHandler(DragDrop.DropEvent, OnDrop);
Closing += OnWindowClosing;
+#if DEBUG
+ StartUnrecordedChangeCheck();
+#endif
Opened += (_, _) =>
{
if (!snapshotMode)
@@ -174,6 +177,7 @@ namespace OpenNest.Avalonia
RestoreViewToggles(view);
view.ViewChanged += (_, _) => { if (view == ActiveView) UpdateStatus(); };
views[tab] = view;
+ WatchHistory(tab);
notice = null;
ShowActiveTab();
}
@@ -228,6 +232,8 @@ namespace OpenNest.Avalonia
: $"{view.Tab.Name} - OpenNest (Avalonia prototype)";
RebuildTabStrip();
UpdateViewToggles();
+ // The Edit menu follows the active nest's history.
+ UpdateUndoMenu();
UpdateStatus();
}
@@ -316,6 +322,7 @@ namespace OpenNest.Avalonia
if (!await ConfirmUnsavedAsync(tab))
return false;
+ UnwatchHistory(tab);
tabs.Close(tab);
if (views.Remove(tab, out var closed))
SaveViewToggles(closed);
@@ -560,6 +567,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);
@@ -669,12 +681,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}"
@@ -701,6 +728,7 @@ namespace OpenNest.Avalonia
{
AutoNestButton.Content = running ? "Stop" : "Auto Nest";
AutoNestMenuItem.Header = running ? "_Stop Auto Nest" : "_Auto Nest";
+ UpdateUndoMenu();
}
/// Completes once the machines are read (see ).
diff --git a/OpenNest.Avalonia/NestView.axaml.cs b/OpenNest.Avalonia/NestView.axaml.cs
index ca2b3ed3..2d472dd7 100644
--- a/OpenNest.Avalonia/NestView.axaml.cs
+++ b/OpenNest.Avalonia/NestView.axaml.cs
@@ -25,9 +25,40 @@ namespace OpenNest.Avalonia
{
Tab = tab;
Canvas.ViewChanged += (_, _) => ViewChanged?.Invoke(this, EventArgs.Empty);
+ tab.Undo.SwapIn = SwapIn;
Refresh(0);
}
+ // Undo and Redo: puts the copy read back from the history in the document and shows it
+ // on the same plate, keeping the zoom, pan and selection (by part index while the part
+ // count is unchanged). Every list is rebuilt: each held the old nest's objects.
+ private void SwapIn(Nest restored)
+ {
+ var plateIndex = System.Math.Max(0, PlateList.SelectedIndex);
+ var shown = Canvas.Plate;
+ keptSelection = shown != null && Canvas.SelectedPart != null
+ ? (shown.Parts.IndexOf(Canvas.SelectedPart), shown.Parts.Count)
+ : (-1, -1);
+ Tab.Document.Nest = restored;
+ // Reading recolours drawings from the file; the view keeps the application's scheme.
+ // Cut-offs stay as reading regenerated them, as when the nest was opened.
+ ColorSchemeRegistry.RecolorDrawings(restored);
+ keepView = true;
+ try
+ {
+ Refresh(plateIndex);
+ }
+ finally
+ {
+ keepView = false;
+ }
+ }
+
+ // While an Undo or Redo swaps its copy in: the plate is shown without zooming to fit,
+ // and the part selected before (its index, the plate's part count then) is selected again.
+ private bool keepView;
+ private (int Index, int PartCount) keptSelection = (-1, -1);
+
/// Raised when the shown plate, zoom, hover or selection changes.
public event EventHandler ViewChanged;
@@ -77,7 +108,11 @@ namespace OpenNest.Avalonia
var nest = Tab.Document.Nest;
var index = PlateList.SelectedIndex;
var plate = index >= 0 && index < nest.Plates.Count ? nest.Plates[index] : null;
- Canvas.Plate = plate;
+ if (keepView)
+ Canvas.ReplacePlate(plate,
+ plate?.Parts.Count == keptSelection.PartCount ? keptSelection.Index : -1);
+ else
+ Canvas.Plate = plate;
PlateLabel.Text = plate == null
? "No plates"
: $"Plate {index + 1} of {nest.Plates.Count} "
diff --git a/docs/undo-redo.md b/docs/undo-redo.md
index 0f5fcfd2..ebcec6b1 100644
--- a/docs/undo-redo.md
+++ b/docs/undo-redo.md
@@ -10,10 +10,11 @@ once its entry point records it.
|---|---|
| History engine (states, cursor, limits, coalescing, save point) | `OpenNest.Data/UndoHistory.cs` |
| State codec | `OpenNest.IO/NestSnapshot.cs` |
-| Recording scope, fingerprint, history state | `OpenNest.Desktop/Undo/` (shared with `OpenNest.Avalonia`) |
-| Recording, restore, safety net | `OpenNest/Forms/EditNestForm.Undo.cs` |
-| Menu, keys, guards | `OpenNest/Forms/MainForm.Undo.cs`, `MainForm.Guards.cs` (`IsUndoBusy`) |
-| Tests | `OpenNest.WinForms.Tests/Forms/UndoRedoTests.cs`, `UndoRecordingTests.cs`; scope and fingerprint on Linux in `OpenNest.Desktop.Tests/Undo/` |
+| Recording, stepping, safety net, menu labels (`NestUndoRecorder`), scope, fingerprint, history state | `OpenNest.Desktop/Undo/` (shared with `OpenNest.Avalonia`) |
+| WinForms restore (rebinding the window), DEBUG idle check | `OpenNest/Forms/EditNestForm.Undo.cs` |
+| WinForms menu, keys, guards | `OpenNest/Forms/MainForm.Undo.cs`, `MainForm.Guards.cs` (`IsUndoBusy`) |
+| Avalonia: one recorder per tab (`NestTab.Undo`), restore, menu, keys, guards | `OpenNest.Desktop/NestTabs.cs`, `OpenNest.Avalonia/MainWindow.Undo.cs`, `NestView.axaml.cs` |
+| Tests | `OpenNest.WinForms.Tests/Forms/UndoRedoTests.cs`, `UndoRecordingTests.cs`; recorder, scope and fingerprint on Linux in `OpenNest.Desktop.Tests/Undo/` |
## What is undoable
@@ -156,6 +157,14 @@ the program editor keeps its own Ctrl+Z and Ctrl+Y (`KeepsOwnUndo`). Otherwise C
asks the current action's `TryUndoStep()`: Sequence by Path removes its last line, and only
when it has none does the nest undo.
+The Avalonia app follows the same rules through the shared recorder: each tab's `NestTab.Undo`
+starts its history when the nest opens (after the trailing empty plate is added), the tab's
+unsaved dot is `HasUnsavedChanges`, Save records any unscoped edit first, and Auto Nest's commit
+is one "Auto Nest" step. Its window routes the keys in a tunnelling key handler, leaves them to
+a focused `TextBox` (numeric and editable combo boxes and grid editors contain one), disables
+Undo and Redo while Auto Nest runs on the nest, and rebinds the Edit menu when the active tab
+changes. A restore keeps the shown plate, zoom, pan and selection (by part index).
+
## Limits and cost
- 50 undo steps and 128 MB per open nest (`UndoHistory.DefaultMaxDepth`, `DefaultMaxBytes`);