mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-14 12:35:07 -05:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
9c0a543d79
commit
ad6ffa1fda
6 files changed
+265
-10
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
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/// <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} "
|
||||
|
||||
+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