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