From ad6ffa1fda99d14125290fb26b0bbbe5f0407ff1 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 13 Nov 2026 19:27:07 -0500 Subject: [PATCH] feat(avalonia): undo and redo nest edits from the Edit menu Edit > Undo/Redo name the next step and follow the active tab's history; Ctrl+Z, Ctrl+Y and Ctrl+Shift+Z undo the nest unless a text box has focus. A restore keeps the plate, zoom, pan and selection. Auto Nest's commit is one undo step and blocks Undo on its nest while it runs. DEBUG builds warn about edits made outside a change scope. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Avalonia/Controls/PlateCanvas.cs | 14 ++ OpenNest.Avalonia/MainWindow.Undo.cs | 164 ++++++++++++++++++++++ OpenNest.Avalonia/MainWindow.axaml | 5 + OpenNest.Avalonia/MainWindow.axaml.cs | 38 ++++- OpenNest.Avalonia/NestView.axaml.cs | 37 ++++- docs/undo-redo.md | 17 ++- 6 files changed, 265 insertions(+), 10 deletions(-) create mode 100644 OpenNest.Avalonia/MainWindow.Undo.cs 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`);