Browse Source

Merge pull request #455 from PixiEditor/transform-undo

Enhance transform overlays
Krzysztof Krysiński 2 years ago
parent
commit
23c5d3aa41
28 changed files with 568 additions and 72 deletions
  1. 30 2
      src/ChunkyImageLib/DataHolders/ShapeCorners.cs
  2. 1 1
      src/PixiEditor.DrawingApi.Core/Bridge/DrawingBackendApi.cs
  3. 7 0
      src/PixiEditor.DrawingApi.Core/Numerics/VecD.cs
  4. 8 0
      src/PixiEditor/Models/DocumentModels/ChangeExecutionController.cs
  5. 14 0
      src/PixiEditor/Models/DocumentModels/Public/DocumentOperationsModule.cs
  6. 1 1
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/LassoToolExecutor.cs
  7. 29 10
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/LineToolExecutor.cs
  8. 6 0
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/PasteImageExecutor.cs
  9. 1 1
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/SelectToolExecutor.cs
  10. 21 0
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/ShapeToolExecutor.cs
  11. 7 0
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/TransformReferenceLayerExecutor.cs
  12. 7 0
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/TransformSelectedAreaExecutor.cs
  13. 3 0
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/UpdateableChangeExecutor.cs
  14. 1 0
      src/PixiEditor/ViewModels/SubViewModels/Document/DocumentViewModel.cs
  15. 0 41
      src/PixiEditor/ViewModels/SubViewModels/Document/LineToolOverlayViewModel.cs
  16. 77 2
      src/PixiEditor/ViewModels/SubViewModels/Document/TransformOverlays/DocumentTransformViewModel.cs
  17. 118 0
      src/PixiEditor/ViewModels/SubViewModels/Document/TransformOverlays/LineToolOverlayViewModel.cs
  18. 28 0
      src/PixiEditor/ViewModels/SubViewModels/Document/TransformOverlays/TransformOverlayActionType.cs
  19. 81 0
      src/PixiEditor/ViewModels/SubViewModels/Document/TransformOverlays/TransformOverlayUndoStack.cs
  20. 14 0
      src/PixiEditor/ViewModels/SubViewModels/Main/SelectionViewModel.cs
  21. 4 4
      src/PixiEditor/ViewModels/SubViewModels/Main/UndoViewModel.cs
  22. 25 1
      src/PixiEditor/ViewModels/SubViewModels/Tools/Tools/LassoToolViewModel.cs
  23. 25 1
      src/PixiEditor/ViewModels/SubViewModels/Tools/Tools/SelectToolViewModel.cs
  24. 2 0
      src/PixiEditor/Views/MainWindow.xaml
  25. 16 3
      src/PixiEditor/Views/UserControls/Overlays/LineToolOverlay/LineToolOverlay.cs
  26. 22 3
      src/PixiEditor/Views/UserControls/Overlays/TransformOverlay/TransformOverlay.cs
  27. 11 1
      src/PixiEditor/Views/UserControls/Overlays/TransformOverlay/TransformState.cs
  28. 9 1
      src/PixiEditor/Views/UserControls/Viewport.xaml

+ 30 - 2
src/ChunkyImageLib/DataHolders/ShapeCorners.cs

@@ -55,7 +55,8 @@ public struct ShapeCorners
     {
         get
         {
-            Span<VecD> lengths = stackalloc[] {
+            Span<VecD> lengths = stackalloc[] 
+            {
                 TopLeft - TopRight,
                 TopRight - BottomRight,
                 BottomRight - BottomLeft,
@@ -133,7 +134,7 @@ public struct ShapeCorners
         TopLeft = TopLeft.ReflectY(horAxisY),
         TopRight = TopRight.ReflectY(horAxisY)
     };
-    
+
     public ShapeCorners AsMirroredAcrossVerAxis(int verAxisX) => new ShapeCorners
     {
         BottomLeft = BottomLeft.ReflectX(verAxisX),
@@ -149,4 +150,31 @@ public struct ShapeCorners
         TopLeft = TopLeft.Rotate(angle, around),
         TopRight = TopRight.Rotate(angle, around)
     };
+
+    public ShapeCorners AsTranslated(VecD delta) => new ShapeCorners
+    {
+        BottomLeft = BottomLeft + delta,
+        BottomRight = BottomRight + delta,
+        TopLeft = TopLeft + delta,
+        TopRight = TopRight + delta
+    };
+
+    public static bool operator !=(ShapeCorners left, ShapeCorners right) => !(left == right);
+    public static bool operator == (ShapeCorners left, ShapeCorners right)
+    {
+        return 
+           left.TopLeft == right.TopLeft &&
+           left.TopRight == right.TopRight &&
+           left.BottomLeft == right.BottomLeft &&
+           left.BottomRight == right.BottomRight;
+    }
+
+    public bool AlmostEquals(ShapeCorners other, double epsilon = 0.001)
+    {
+        return
+            TopLeft.AlmostEquals(other.TopLeft, epsilon) &&
+            TopRight.AlmostEquals(other.TopRight, epsilon) &&
+            BottomLeft.AlmostEquals(other.BottomLeft, epsilon) &&
+            BottomRight.AlmostEquals(other.BottomRight, epsilon);
+    }
 }

+ 1 - 1
src/PixiEditor.DrawingApi.Core/Bridge/DrawingBackendApi.cs

@@ -5,7 +5,7 @@ namespace PixiEditor.DrawingApi.Core.Bridge
 {
     public static class DrawingBackendApi
     {
-        private static IDrawingBackend _current;
+        private static IDrawingBackend? _current;
 
         public static IDrawingBackend Current
         {

+ 7 - 0
src/PixiEditor.DrawingApi.Core/Numerics/VecD.cs

@@ -224,4 +224,11 @@ public struct VecD : IEquatable<VecD>
     {
         return other.X == X && other.Y == Y;
     }
+
+    public bool AlmostEquals(VecD other, double axisEpsilon = 0.001)
+    {
+        double dX = Math.Abs(X - other.X);
+        double dY = Math.Abs(Y - other.Y);
+        return dX < axisEpsilon && dY < axisEpsilon;
+    }
 }

+ 8 - 0
src/PixiEditor/Models/DocumentModels/ChangeExecutionController.cs

@@ -89,6 +89,9 @@ internal class ChangeExecutionController
         return true;
     }
 
+    public void MidChangeUndoInlet() => currentSession?.OnMidChangeUndo();
+    public void MidChangeRedoInlet() => currentSession?.OnMidChangeRedo();
+
     public void ConvertedKeyDownInlet(Key key)
     {
         currentSession?.OnConvertedKeyDown(key);
@@ -175,4 +178,9 @@ internal class ChangeExecutionController
     {
         currentSession?.OnLineOverlayMoved(start, end);
     }
+
+    public void SelectedObjectNudgedInlet(VecI distance)
+    {
+        currentSession?.OnSelectedObjectNudged(distance);
+    }
 }

+ 14 - 0
src/PixiEditor/Models/DocumentModels/Public/DocumentOperationsModule.cs

@@ -299,7 +299,10 @@ internal class DocumentOperationsModule
     public void Undo()
     {
         if (Internals.ChangeController.IsChangeActive)
+        {
+            Internals.ChangeController.MidChangeUndoInlet();
             return;
+        }
         Internals.ActionAccumulator.AddActions(new Undo_Action());
     }
 
@@ -309,10 +312,21 @@ internal class DocumentOperationsModule
     public void Redo()
     {
         if (Internals.ChangeController.IsChangeActive)
+        {
+            Internals.ChangeController.MidChangeRedoInlet();
             return;
+        }
         Internals.ActionAccumulator.AddActions(new Redo_Action());
     }
 
+    public void NudgeSelectedObject(VecI distance)
+    {
+        if (Internals.ChangeController.IsChangeActive)
+        {
+            Internals.ChangeController.SelectedObjectNudgedInlet(distance);
+        }    
+    }
+
     /// <summary>
     /// Moves a member next to or inside another structure member
     /// </summary>

+ 1 - 1
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/LassoToolExecutor.cs

@@ -12,7 +12,7 @@ internal sealed class LassoToolExecutor : UpdateableChangeExecutor
     
     public override ExecutionState Start()
     {
-        mode = ViewModelMain.Current?.ToolsSubViewModel.GetTool<LassoToolViewModel>()?.SelectMode;
+        mode = ViewModelMain.Current?.ToolsSubViewModel.GetTool<LassoToolViewModel>()?.ResultingSelectionMode;
 
         if (mode is null)
             return ExecutionState.Error;

+ 29 - 10
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/LineToolExecutor.cs

@@ -55,11 +55,11 @@ internal class LineToolExecutor : UpdateableChangeExecutor
         if (transforming)
             return;
         started = true;
-        curPos = pos;
-        VecI targetPos = pos;
+
         if (toolViewModel!.Snap)
-            targetPos = ShapeToolExecutor<ShapeTool>.Get45IncrementedPosition(startPos, curPos);
-        internals!.ActionAccumulator.AddActions(new DrawLine_Action(memberGuid, startPos, targetPos, strokeWidth, strokeColor, StrokeCap.Butt, drawOnMask));
+            pos = ShapeToolExecutor<ShapeTool>.Get45IncrementedPosition(startPos, pos);
+        curPos = pos;
+        internals!.ActionAccumulator.AddActions(new DrawLine_Action(memberGuid, startPos, pos, strokeWidth, strokeColor, StrokeCap.Butt, drawOnMask));
     }
 
     public override void OnLeftMouseButtonUp()
@@ -69,10 +69,8 @@ internal class LineToolExecutor : UpdateableChangeExecutor
             onEnded!(this);
             return;
         }
-        
-        document!.LineToolOverlayViewModel.LineStart = startPos + new VecD(0.5);
-        document!.LineToolOverlayViewModel.LineEnd = curPos + new VecD(0.5);
-        document!.LineToolOverlayViewModel.IsEnabled = true;
+
+        document!.LineToolOverlayViewModel.Show(startPos + new VecD(0.5), curPos + new VecD(0.5));
         transforming = true;
     }
 
@@ -83,12 +81,33 @@ internal class LineToolExecutor : UpdateableChangeExecutor
         internals!.ActionAccumulator.AddActions(new DrawLine_Action(memberGuid, (VecI)start, (VecI)end, strokeWidth, strokeColor, StrokeCap.Butt, drawOnMask));
     }
 
+    public override void OnSelectedObjectNudged(VecI distance)
+    {
+        if (!transforming)
+            return;
+        document!.LineToolOverlayViewModel.Nudge(distance);
+    }
+
+    public override void OnMidChangeUndo()
+    {
+        if (!transforming)
+            return;
+        document!.LineToolOverlayViewModel.Undo();
+    }
+
+    public override void OnMidChangeRedo()
+    {
+        if (!transforming)
+            return;
+        document!.LineToolOverlayViewModel.Redo();
+    }
+
     public override void OnTransformApplied()
     {
         if (!transforming)
             return;
 
-        document!.LineToolOverlayViewModel.IsEnabled = false;
+        document!.LineToolOverlayViewModel.Hide();
         internals!.ActionAccumulator.AddFinishedActions(new EndDrawLine_Action());
         onEnded!(this);
     }
@@ -96,7 +115,7 @@ internal class LineToolExecutor : UpdateableChangeExecutor
     public override void ForceStop()
     {
         if (transforming)
-            document!.LineToolOverlayViewModel.IsEnabled = false;
+            document!.LineToolOverlayViewModel.Hide();
 
         internals!.ActionAccumulator.AddFinishedActions(new EndDrawLine_Action());
     }

+ 6 - 0
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/PasteImageExecutor.cs

@@ -59,6 +59,12 @@ internal class PasteImageExecutor : UpdateableChangeExecutor
         internals!.ActionAccumulator.AddActions(new PasteImage_Action(image, corners, memberGuid.Value, false, drawOnMask));
     }
 
+    public override void OnSelectedObjectNudged(VecI distance) => document!.TransformViewModel.Nudge(distance);
+
+    public override void OnMidChangeUndo() => document!.TransformViewModel.Undo();
+
+    public override void OnMidChangeRedo() => document!.TransformViewModel.Redo();
+
     public override void OnTransformApplied()
     {
         internals!.ActionAccumulator.AddFinishedActions(new EndPasteImage_Action());

+ 1 - 1
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/SelectToolExecutor.cs

@@ -31,7 +31,7 @@ internal class SelectToolExecutor : UpdateableChangeExecutor
         
         startPos = controller!.LastPixelPosition;
         selectShape = toolViewModel.SelectShape;
-        selectMode = toolViewModel.SelectMode;
+        selectMode = toolViewModel.ResultingSelectionMode;
 
         IAction action = CreateUpdateAction(selectShape, new RectI(startPos, new(0)), selectMode);
         internals!.ActionAccumulator.AddActions(action);

+ 21 - 0
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/ShapeToolExecutor.cs

@@ -114,6 +114,27 @@ internal abstract class ShapeToolExecutor<T> : UpdateableChangeExecutor where T
         onEnded?.Invoke(this);
     }
 
+    public override void OnSelectedObjectNudged(VecI distance)
+    {
+        if (!transforming)
+            return;
+        document!.TransformViewModel.Nudge(distance);
+    }
+
+    public override void OnMidChangeUndo()
+    {
+        if (!transforming)
+            return;
+        document!.TransformViewModel.Undo();
+    }
+
+    public override void OnMidChangeRedo()
+    {
+        if (!transforming)
+            return;
+        document!.TransformViewModel.Redo();
+    }
+
     public override void OnPixelPositionChange(VecI pos)
     {
         if (transforming)

+ 7 - 0
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/TransformReferenceLayerExecutor.cs

@@ -4,6 +4,7 @@ using System.Linq;
 using System.Text;
 using System.Threading.Tasks;
 using ChunkyImageLib.DataHolders;
+using PixiEditor.DrawingApi.Core.Numerics;
 using PixiEditor.Models.Enums;
 
 namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
@@ -25,6 +26,12 @@ internal class TransformReferenceLayerExecutor : UpdateableChangeExecutor
         internals!.ActionAccumulator.AddActions(new TransformReferenceLayer_Action(corners));
     }
 
+    public override void OnSelectedObjectNudged(VecI distance) => document!.TransformViewModel.Nudge(distance);
+
+    public override void OnMidChangeUndo() => document!.TransformViewModel.Undo();
+
+    public override void OnMidChangeRedo() => document!.TransformViewModel.Redo();
+
     public override void OnTransformApplied()
     {
         internals!.ActionAccumulator.AddFinishedActions(new EndTransformReferenceLayer_Action());

+ 7 - 0
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/TransformSelectedAreaExecutor.cs

@@ -1,4 +1,5 @@
 using ChunkyImageLib.DataHolders;
+using PixiEditor.DrawingApi.Core.Numerics;
 using PixiEditor.Models.Enums;
 using PixiEditor.ViewModels.SubViewModels.Document;
 using PixiEditor.ViewModels.SubViewModels.Tools.Tools;
@@ -45,6 +46,12 @@ internal class TransformSelectedAreaExecutor : UpdateableChangeExecutor
             new TransformSelectedArea_Action(membersToTransform!, corners, tool!.KeepOriginalImage, false));
     }
 
+    public override void OnSelectedObjectNudged(VecI distance) => document!.TransformViewModel.Nudge(distance);
+
+    public override void OnMidChangeUndo() => document!.TransformViewModel.Undo();
+
+    public override void OnMidChangeRedo() => document!.TransformViewModel.Redo();
+
     public override void OnTransformApplied()
     {
         if (Type == ExecutorType.ToolLinked)

+ 3 - 0
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/UpdateableChangeExecutor.cs

@@ -47,4 +47,7 @@ internal abstract class UpdateableChangeExecutor
     public virtual void OnTransformMoved(ShapeCorners corners) { }
     public virtual void OnTransformApplied() { }
     public virtual void OnLineOverlayMoved(VecD start, VecD end) { }
+    public virtual void OnMidChangeUndo() { }
+    public virtual void OnMidChangeRedo() { }
+    public virtual void OnSelectedObjectNudged(VecI distance) { }
 }

+ 1 - 0
src/PixiEditor/ViewModels/SubViewModels/Document/DocumentViewModel.cs

@@ -20,6 +20,7 @@ using PixiEditor.Models.DataHolders;
 using PixiEditor.Models.DocumentModels;
 using PixiEditor.Models.DocumentModels.Public;
 using PixiEditor.Models.Enums;
+using PixiEditor.ViewModels.SubViewModels.Document.TransformOverlays;
 using PixiEditor.Views.UserControls.SymmetryOverlay;
 using Color = PixiEditor.DrawingApi.Core.ColorsImpl.Color;
 using Colors = PixiEditor.DrawingApi.Core.ColorsImpl.Colors;

+ 0 - 41
src/PixiEditor/ViewModels/SubViewModels/Document/LineToolOverlayViewModel.cs

@@ -1,41 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using PixiEditor.DrawingApi.Core.Numerics;
-
-namespace PixiEditor.ViewModels.SubViewModels.Document;
-internal class LineToolOverlayViewModel : NotifyableObject
-{
-    public event EventHandler<(VecD, VecD)> LineMoved;
-
-    private VecD lineStart;
-    public VecD LineStart
-    {
-        get => lineStart;
-        set 
-        {
-            if (SetProperty(ref lineStart, value))
-                LineMoved?.Invoke(this, (lineStart, lineEnd));
-        }
-    }
-
-    private VecD lineEnd;
-    public VecD LineEnd
-    {
-        get => lineEnd;
-        set
-        {
-            if (SetProperty(ref lineEnd, value))
-                LineMoved?.Invoke(this, (lineStart, lineEnd));
-        }
-    }
-
-    private bool isEnabled;
-    public bool IsEnabled
-    {
-        get => isEnabled;
-        set => SetProperty(ref isEnabled, value);
-    }
-}

+ 77 - 2
src/PixiEditor/ViewModels/SubViewModels/Document/DocumentTransformViewModel.cs → src/PixiEditor/ViewModels/SubViewModels/Document/TransformOverlays/DocumentTransformViewModel.cs

@@ -1,11 +1,21 @@
-using ChunkyImageLib.DataHolders;
+using System.Windows.Input;
+using ChunkyImageLib.DataHolders;
+using PixiEditor;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Helpers;
 using PixiEditor.Models.Enums;
+using PixiEditor.ViewModels;
+using PixiEditor.ViewModels.SubViewModels;
+using PixiEditor.ViewModels.SubViewModels.Document;
+using PixiEditor.ViewModels.SubViewModels.Document.TransformOverlays;
 using PixiEditor.Views.UserControls.Overlays.TransformOverlay;
 
-namespace PixiEditor.ViewModels.SubViewModels.Document;
+namespace PixiEditor.ViewModels.SubViewModels.Document.TransformOverlays;
 #nullable enable
 internal class DocumentTransformViewModel : NotifyableObject
 {
+    private TransformOverlayUndoStack<(ShapeCorners, TransformState)>? undoStack = null;
+
     private TransformState internalState;
     public TransformState InternalState
     {
@@ -85,18 +95,81 @@ internal class DocumentTransformViewModel : NotifyableObject
         }
     }
 
+    private ICommand? actionCompletedCommand = null;
+    public ICommand? ActionCompletedCommand
+    {
+        get => actionCompletedCommand;
+        set => SetProperty(ref actionCompletedCommand, value);
+    }
+
     public event EventHandler<ShapeCorners>? TransformMoved;
 
     private DocumentTransformMode activeTransformMode = DocumentTransformMode.Scale_Rotate_NoShear_NoPerspective;
 
+    public DocumentTransformViewModel()
+    {
+        ActionCompletedCommand = new RelayCommand((_) =>
+        {
+            if (undoStack is null)
+                return;
+
+            var lastState = undoStack.PeekCurrent();
+            if (lastState is not null && lastState.Value.Item1.AlmostEquals(Corners) && lastState.Value.Item2.AlmostEquals(InternalState))
+                return;
+
+            undoStack.AddState((Corners, InternalState), TransformOverlayStateType.Move);
+        });
+    }
+
+    public bool Undo()
+    {
+        if (undoStack is null)
+            return false;
+        var state = undoStack.Undo();
+        if (state is null)
+            return false;
+        (Corners, InternalState) = state.Value;
+        return true;
+    }
+
+    public bool Redo()
+    {
+        if (undoStack is null)
+            return false;
+        var state = undoStack.Redo();
+        if (state is null)
+            return false;
+        (Corners, InternalState) = state.Value;
+        return true;
+    }
+
+    public bool Nudge(VecD distance)
+    {
+        if (undoStack is null)
+            return false;
+
+        InternalState = InternalState with { Origin = InternalState.Origin + distance };
+        Corners = Corners.AsTranslated(distance);
+        undoStack.AddState((Corners, InternalState), TransformOverlayStateType.Nudge);
+        return true;
+    }
+
     public void HideTransform()
     {
+        if (undoStack is null)
+            return;
+        undoStack = null;
+
         TransformActive = false;
         ShowTransformControls = false;
     }
 
     public void ShowTransform(DocumentTransformMode mode, bool coverWholeScreen, ShapeCorners initPos, bool showApplyButton)
     {
+        if (undoStack is not null)
+            return;
+        undoStack = new();
+
         activeTransformMode = mode;
         CornerFreedom = TransformCornerFreedom.Scale;
         SideFreedom = TransformSideFreedom.Stretch;
@@ -105,6 +178,8 @@ internal class DocumentTransformViewModel : NotifyableObject
         CoverWholeScreen = coverWholeScreen;
         TransformActive = true;
         ShowTransformControls = showApplyButton;
+
+        undoStack.AddState((Corners, InternalState), TransformOverlayStateType.Initial);
     }
 
     public void ModifierKeysInlet(bool isShiftDown, bool isCtrlDown, bool isAltDown)

+ 118 - 0
src/PixiEditor/ViewModels/SubViewModels/Document/TransformOverlays/LineToolOverlayViewModel.cs

@@ -0,0 +1,118 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using System.Windows.Input;
+using PixiEditor;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Helpers;
+using PixiEditor.ViewModels;
+using PixiEditor.ViewModels.SubViewModels;
+using PixiEditor.ViewModels.SubViewModels.Document;
+using PixiEditor.ViewModels.SubViewModels.Document.TransformOverlays;
+
+namespace PixiEditor.ViewModels.SubViewModels.Document.TransformOverlays;
+internal class LineToolOverlayViewModel : NotifyableObject
+{
+    public event EventHandler<(VecD, VecD)>? LineMoved;
+
+    private TransformOverlayUndoStack<(VecD, VecD)>? undoStack = null;
+
+    private VecD lineStart;
+    public VecD LineStart
+    {
+        get => lineStart;
+        set
+        {
+            if (SetProperty(ref lineStart, value))
+                LineMoved?.Invoke(this, (lineStart, lineEnd));
+        }
+    }
+
+    private VecD lineEnd;
+    public VecD LineEnd
+    {
+        get => lineEnd;
+        set
+        {
+            if (SetProperty(ref lineEnd, value))
+                LineMoved?.Invoke(this, (lineStart, lineEnd));
+        }
+    }
+
+    private bool isEnabled;
+    public bool IsEnabled
+    {
+        get => isEnabled;
+        set => SetProperty(ref isEnabled, value);
+    }
+
+    private ICommand? actionCompletedCommand = null;
+    public ICommand? ActionCompletedCommand
+    {
+        get => actionCompletedCommand;
+        set => SetProperty(ref actionCompletedCommand, value);
+    }
+
+    public LineToolOverlayViewModel()
+    {
+        ActionCompletedCommand = new RelayCommand((_) => undoStack?.AddState((LineStart, LineEnd), TransformOverlayStateType.Move));
+    }
+
+    public void Show(VecD lineStart, VecD lineEnd)
+    {
+        if (undoStack is not null)
+            return;
+        undoStack = new();
+        undoStack.AddState((lineStart, lineEnd), TransformOverlayStateType.Initial);
+
+        LineStart = lineStart;
+        LineEnd = lineEnd;
+        IsEnabled = true;
+    }
+
+    public void Hide()
+    {
+        if (undoStack is null)
+            return;
+        undoStack = null;
+        IsEnabled = false;
+    }
+
+    public bool Nudge(VecD distance)
+    {
+        if (undoStack is null)
+            return false;
+        LineStart = LineStart + distance;
+        LineEnd = LineEnd + distance;
+        undoStack.AddState((lineStart, lineEnd), TransformOverlayStateType.Nudge);
+        return true;
+    }
+
+    public bool Undo()
+    {
+        if (undoStack is null)
+            return false;
+
+        var newState = undoStack.Undo();
+        if (newState is null)
+            return false;
+        LineStart = newState.Value.Item1;
+        LineEnd = newState.Value.Item2;
+        return true;
+    }
+
+    public bool Redo()
+    {
+        if (undoStack is null)
+            return false;
+
+        var newState = undoStack.Redo();
+        if (newState is null)
+            return false;
+        LineStart = newState.Value.Item1;
+        LineEnd = newState.Value.Item2;
+        return true;
+    }
+}

+ 28 - 0
src/PixiEditor/ViewModels/SubViewModels/Document/TransformOverlays/TransformOverlayActionType.cs

@@ -0,0 +1,28 @@
+namespace PixiEditor.ViewModels.SubViewModels.Document.TransformOverlays;
+internal enum TransformOverlayStateType
+{
+    /// <summary>
+    /// The overlay was moved via mouse
+    /// </summary>
+    Move,
+
+    /// <summary>
+    /// The overlay was nudged using arrows keys
+    /// </summary>
+    Nudge,
+
+    /// <summary>
+    /// The overlay was set to this state when it was enabled
+    /// </summary>
+    Initial
+}
+
+internal static class TransformOverlayStateTypeEx
+{
+    public static bool IsMergeable(this TransformOverlayStateType type) => type switch
+    {
+        TransformOverlayStateType.Move => false,
+        TransformOverlayStateType.Nudge => true,
+        TransformOverlayStateType.Initial => false
+    };
+}

+ 81 - 0
src/PixiEditor/ViewModels/SubViewModels/Document/TransformOverlays/TransformOverlayUndoStack.cs

@@ -0,0 +1,81 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using System.Windows.Documents;
+
+namespace PixiEditor.ViewModels.SubViewModels.Document.TransformOverlays;
+internal class TransformOverlayUndoStack<TState> where TState : struct
+{
+    private struct StackItem<TState>
+    {
+        public TState State { get; set; }
+        public TransformOverlayStateType Type { get; set; }
+
+        public StackItem(TState state, TransformOverlayStateType type)
+        {
+            State = state;
+            Type = type;
+        }
+    }
+
+    private Stack<StackItem<TState>> undoStack = new();
+    private Stack<StackItem<TState>> redoStack = new();
+    private StackItem<TState>? current;
+
+    public void AddState(TState state, TransformOverlayStateType type)
+    {
+        redoStack.Clear();
+        if (current is not null)
+            undoStack.Push(current.Value);
+
+        current = new(state, type);
+    }
+
+    public TState? PeekCurrent() => current?.State;
+
+    public TState? Undo()
+    {
+        if (current is null || undoStack.Count == 0)
+            return null;
+
+        while (true)
+        {
+            TransformOverlayStateType oldType = current.Value.Type;
+            DoUndoStep();
+            TransformOverlayStateType newType = current.Value.Type;
+            if (oldType != newType || !oldType.IsMergeable() || undoStack.Count == 0)
+                break;
+        }
+        return current.Value.State;
+    }
+
+    public TState? Redo()
+    {
+        if (current is null || redoStack.Count == 0)
+            return null;
+
+        while (true)
+        {
+            TransformOverlayStateType oldType = current.Value.Type;
+            DoRedoStep();
+            TransformOverlayStateType newType = current.Value.Type;
+            if (oldType != newType || !oldType.IsMergeable() || redoStack.Count == 0)
+                break;
+        }
+        return current.Value.State;
+    }
+
+    private void DoUndoStep()
+    {
+        redoStack.Push(current.Value);
+        current = undoStack.Pop();
+    }
+
+    private void DoRedoStep()
+    {
+        undoStack.Push(current.Value);
+        current = redoStack.Pop();
+    }
+}

+ 14 - 0
src/PixiEditor/ViewModels/SubViewModels/Main/SelectionViewModel.cs

@@ -1,4 +1,5 @@
 using System.Windows.Input;
+using PixiEditor.DrawingApi.Core.Numerics;
 using PixiEditor.Models.Commands.Attributes.Commands;
 
 namespace PixiEditor.ViewModels.SubViewModels.Main;
@@ -40,4 +41,17 @@ internal class SelectionViewModel : SubViewModel<ViewModelMain>
     {
         Owner.DocumentManagerSubViewModel.ActiveDocument?.Operations.TransformSelectedArea(false);
     }
+
+    [Command.Basic("PixiEditor.Selection.NudgeSelectedObjectLeft", "Nudge selected object left", "Nudge selected object left", Key = Key.Left, Parameter = new int[] { -1, 0 }, IconPath = "E76B", IconEvaluator = "PixiEditor.FontIcon", CanExecute = "PixiEditor.Selection.CanNudgeSelectedObject")]
+    [Command.Basic("PixiEditor.Selection.NudgeSelectedObjectRight", "Nudge selected object right", "Nudge selected object right", Key = Key.Right, Parameter = new int[] { 1, 0 }, IconPath = "E76C", IconEvaluator = "PixiEditor.FontIcon", CanExecute = "PixiEditor.Selection.CanNudgeSelectedObject")]
+    [Command.Basic("PixiEditor.Selection.NudgeSelectedObjectUp", "Nudge selected object up", "Nudge selected object up", Key = Key.Up, Parameter = new int[] { 0, -1 }, IconPath = "E70E", IconEvaluator = "PixiEditor.FontIcon", CanExecute = "PixiEditor.Selection.CanNudgeSelectedObject")]
+    [Command.Basic("PixiEditor.Selection.NudgeSelectedObjectDown", "Nudge selected object down", "Nudge selected object down", Key = Key.Down, Parameter = new int[] { 0, 1 }, IconPath = "E70D", IconEvaluator = "PixiEditor.FontIcon", CanExecute = "PixiEditor.Selection.CanNudgeSelectedObject")]
+    public void NudgeSelectedObject(int[] dist)
+    {
+        VecI distance = new(dist[0], dist[1]);
+        Owner.DocumentManagerSubViewModel.ActiveDocument?.Operations.NudgeSelectedObject(distance);
+    }
+
+    [Evaluator.CanExecute("PixiEditor.Selection.CanNudgeSelectedObject")]
+    public bool CanNudgeSelectedObject(int[] dist) => Owner.DocumentManagerSubViewModel.ActiveDocument?.UpdateableChangeActive == true;
 }

+ 4 - 4
src/PixiEditor/ViewModels/SubViewModels/Main/UndoViewModel.cs

@@ -21,7 +21,7 @@ internal class UndoViewModel : SubViewModel<ViewModelMain>
     public void Redo()
     {
         var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
-        if (doc is null || doc.UpdateableChangeActive || !doc.HasSavedRedo)
+        if (doc is null || (!doc.UpdateableChangeActive && !doc.HasSavedRedo))
             return;
         doc.Operations.Redo();
     }
@@ -34,7 +34,7 @@ internal class UndoViewModel : SubViewModel<ViewModelMain>
     public void Undo()
     {
         var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
-        if (doc is null || doc.UpdateableChangeActive || !doc.HasSavedUndo)
+        if (doc is null || (!doc.UpdateableChangeActive && !doc.HasSavedUndo))
             return;
         doc.Operations.Undo();
     }
@@ -50,7 +50,7 @@ internal class UndoViewModel : SubViewModel<ViewModelMain>
         var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
         if (doc is null)
             return false;
-        return !doc.UpdateableChangeActive && doc.HasSavedUndo;
+        return doc.UpdateableChangeActive || doc.HasSavedUndo;
     }
 
     /// <summary>
@@ -64,6 +64,6 @@ internal class UndoViewModel : SubViewModel<ViewModelMain>
         var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
         if (doc is null)
             return false;
-        return !doc.UpdateableChangeActive && doc.HasSavedRedo;
+        return doc.UpdateableChangeActive || doc.HasSavedRedo;
     }
 }

+ 25 - 1
src/PixiEditor/ViewModels/SubViewModels/Tools/Tools/LassoToolViewModel.cs

@@ -10,10 +10,34 @@ namespace PixiEditor.ViewModels.SubViewModels.Tools.Tools;
 [Command.ToolAttribute(Key = Key.Q)]
 internal class LassoToolViewModel : ToolViewModel
 {
+    private string defaultActionDisplay = "Click and move to select pixels inside of the lasso. Hold Shift to add to existing selection. Hold Ctrl to subtract from it.";
+
     public LassoToolViewModel()
     {
         Toolbar = ToolbarFactory.Create<LassoToolViewModel>();
-        ActionDisplay = "Click and move to select pixels inside of lasso.";
+        ActionDisplay = defaultActionDisplay;
+    }
+
+    private SelectionMode modifierKeySelectionMode = SelectionMode.New;
+    public SelectionMode ResultingSelectionMode => modifierKeySelectionMode != SelectionMode.New ? modifierKeySelectionMode : SelectMode;
+
+    public override void UpdateActionDisplay(bool ctrlIsDown, bool shiftIsDown, bool altIsDown)
+    {
+        if (shiftIsDown)
+        {
+            ActionDisplay = "Click and move to add pixels inside of the lasso to the selection.";
+            modifierKeySelectionMode = SelectionMode.Add;
+        }
+        else if (ctrlIsDown)
+        {
+            ActionDisplay = "Click and move to subtract pixels inside of the lasso from the selection.";
+            modifierKeySelectionMode = SelectionMode.Subtract;
+        }
+        else
+        {
+            ActionDisplay = defaultActionDisplay;
+            modifierKeySelectionMode = SelectionMode.New;
+        }
     }
 
     public override string Tooltip => $"Lasso. ({Shortcut})";

+ 25 - 1
src/PixiEditor/ViewModels/SubViewModels/Tools/Tools/SelectToolViewModel.cs

@@ -12,13 +12,37 @@ namespace PixiEditor.ViewModels.SubViewModels.Tools.Tools;
 [Command.Tool(Key = Key.M)]
 internal class SelectToolViewModel : ToolViewModel
 {
+    private string defaultActionDisplay = "Click and move to select an area. Hold Shift to add to existing selection. Hold Ctrl to subtract from it.";
+
     public SelectToolViewModel()
     {
-        ActionDisplay = "Click and move to select an area.";
+        ActionDisplay = defaultActionDisplay;
         Toolbar = ToolbarFactory.Create<SelectToolViewModel>();
         Cursor = Cursors.Cross;
     }
 
+    private SelectionMode modifierKeySelectionMode = SelectionMode.New;
+    public SelectionMode ResultingSelectionMode => modifierKeySelectionMode != SelectionMode.New ? modifierKeySelectionMode : SelectMode;
+
+    public override void UpdateActionDisplay(bool ctrlIsDown, bool shiftIsDown, bool altIsDown)
+    {
+        if (shiftIsDown)
+        {
+            ActionDisplay = "Click and move to add to the current selection.";
+            modifierKeySelectionMode = SelectionMode.Add;
+        }
+        else if (ctrlIsDown)
+        {
+            ActionDisplay = "Click and move to subtract from the current selection.";
+            modifierKeySelectionMode = SelectionMode.Subtract;
+        }
+        else
+        {
+            ActionDisplay = defaultActionDisplay;
+            modifierKeySelectionMode = SelectionMode.New;
+        }
+    }
+
     [Settings.Enum("Mode")]
     public SelectionMode SelectMode => GetValue<SelectionMode>();
 

+ 2 - 0
src/PixiEditor/Views/MainWindow.xaml

@@ -98,6 +98,8 @@
         <Grid
             Name="mainGrid"
             Margin="5"
+            KeyboardNavigation.DirectionalNavigation="None"
+            KeyboardNavigation.TabNavigation="None"
             Focusable="True">
             <Grid.ColumnDefinitions>
                 <ColumnDefinition

+ 16 - 3
src/PixiEditor/Views/UserControls/Overlays/LineToolOverlay/LineToolOverlay.cs

@@ -22,6 +22,15 @@ internal class LineToolOverlay : Control
         DependencyProperty.Register(nameof(LineEnd), typeof(VecD), typeof(LineToolOverlay),
             new FrameworkPropertyMetadata(VecD.Zero, FrameworkPropertyMetadataOptions.AffectsRender));
 
+    public static readonly DependencyProperty ActionCompletedProperty =
+        DependencyProperty.Register(nameof(ActionCompleted), typeof(ICommand), typeof(LineToolOverlay), new(null));
+
+    public ICommand? ActionCompleted
+    {
+        get => (ICommand)GetValue(ActionCompletedProperty);
+        set => SetValue(ActionCompletedProperty, value);
+    }
+
     public VecD LineEnd
     {
         get => (VecD)GetValue(LineEndProperty);
@@ -48,6 +57,7 @@ internal class LineToolOverlay : Control
 
     private LineToolOverlayAnchor? capturedAnchor = null;
     private bool dragging = false;
+    private bool movedWhileMouseDown = false;
 
     private PathGeometry handleGeometry = new()
     {
@@ -109,6 +119,7 @@ internal class LineToolOverlay : Control
             capturedAnchor = LineToolOverlayAnchor.End;
         else if (TransformHelper.IsWithinTransformHandle(handlePos, pos, ZoomboxScale))
             dragging = true;
+        movedWhileMouseDown = false;
 
         mouseDownPos = pos;
         lineStartOnMouseDown = LineStart;
@@ -119,19 +130,18 @@ internal class LineToolOverlay : Control
 
     protected void MouseMoved(object sender, MouseEventArgs e)
     {
-        /*base.OnMouseMove(e);
-        e.Handled = true;*/
-
         VecD pos = TransformHelper.ToVecD(e.GetPosition(this));
         if (capturedAnchor == LineToolOverlayAnchor.Start)
         {
             LineStart = pos;
+            movedWhileMouseDown = true;
             return;
         }
 
         if (capturedAnchor == LineToolOverlayAnchor.End)
         {
             LineEnd = pos;
+            movedWhileMouseDown = true;
             return;
         }
 
@@ -140,6 +150,7 @@ internal class LineToolOverlay : Control
             var delta = pos - mouseDownPos;
             LineStart = lineStartOnMouseDown + delta;
             LineEnd = lineEndOnMouseDown + delta;
+            movedWhileMouseDown = true;
             return;
         }
     }
@@ -153,6 +164,8 @@ internal class LineToolOverlay : Control
         e.Handled = true;
         capturedAnchor = null;
         dragging = false;
+        if (movedWhileMouseDown && ActionCompleted is not null && ActionCompleted.CanExecute(null))
+            ActionCompleted.Execute(null);
 
         ReleaseMouseCapture();
     }

+ 22 - 3
src/PixiEditor/Views/UserControls/Overlays/TransformOverlay/TransformOverlay.cs

@@ -47,6 +47,16 @@ internal class TransformOverlay : Decorator
         DependencyProperty.Register(nameof(CoverWholeScreen), typeof(bool), typeof(TransformOverlay),
             new FrameworkPropertyMetadata(true, FrameworkPropertyMetadataOptions.AffectsRender));
 
+
+    public static readonly DependencyProperty ActionCompletedProperty =
+        DependencyProperty.Register(nameof(ActionCompleted), typeof(ICommand), typeof(TransformOverlay), new(null));
+
+    public ICommand? ActionCompleted
+    {
+        get => (ICommand?)GetValue(ActionCompletedProperty);
+        set => SetValue(ActionCompletedProperty, value);
+    }
+
     public bool CoverWholeScreen
     {
         get => (bool)GetValue(ConverWholeScreenProperty);
@@ -428,25 +438,34 @@ internal class TransformOverlay : Decorator
         base.OnMouseUp(e);
         if (e.ChangedButton != MouseButton.Left)
             return;
+
+        bool handled = false;
         if (ReleaseAnchor())
         {
-            e.Handled = true;
+            handled = true;
         }
         else if (isMoving)
         {
             isMoving = false;
-            e.Handled = true;
+            handled = true;
             ReleaseMouseCapture();
         }
         else if (isRotating)
         {
             isRotating = false;
-            e.Handled = true;
+            handled = true;
             ReleaseMouseCapture();
             Cursor = Cursors.Arrow;
             var pos = TransformHelper.ToVecD(e.GetPosition(this));
             UpdateRotationCursor(pos);
         }
+
+        if (handled)
+        {
+            e.Handled = true;
+            if (ActionCompleted is not null && ActionCompleted.CanExecute(null))
+                ActionCompleted.Execute(null);
+        }
     }
 
     private static void OnRequestedCorners(DependencyObject obj, DependencyPropertyChangedEventArgs args)

+ 11 - 1
src/PixiEditor/Views/UserControls/Overlays/TransformOverlay/TransformState.cs

@@ -1,4 +1,5 @@
-using ChunkyImageLib.DataHolders;
+using System.CodeDom;
+using ChunkyImageLib.DataHolders;
 using PixiEditor.DrawingApi.Core.Numerics;
 
 #nullable enable
@@ -9,4 +10,13 @@ internal struct TransformState
     public VecD Origin { get; set; }
     public double ProportionalAngle1 { get; set; }
     public double ProportionalAngle2 { get; set; }
+
+    public bool AlmostEquals(TransformState other, double epsilon = 0.001)
+    {
+        return
+            OriginWasManuallyDragged == other.OriginWasManuallyDragged &&
+            other.Origin.AlmostEquals(Origin, epsilon) &&
+            Math.Abs(ProportionalAngle1 - other.ProportionalAngle1) < epsilon &&
+            Math.Abs(ProportionalAngle2 - other.ProportionalAngle2) < epsilon;
+    }
 }

+ 9 - 1
src/PixiEditor/Views/UserControls/Viewport.xaml

@@ -150,6 +150,7 @@
                 <Grid>
                     <Canvas Visibility="{Binding Source={vm:ToolVM ColorPickerToolViewModel}, Path=PickFromReferenceLayer, Converter={converters:BoolToVisibilityConverter}}">
                         <Image
+                            Focusable="False"
                             Width="{Binding Document.ReferenceLayerViewModel.ReferenceBitmap.Width}"
                             Height="{Binding Document.ReferenceLayerViewModel.ReferenceBitmap.Height}"
                             Source="{Binding Document.ReferenceLayerViewModel.ReferenceBitmap, Mode=OneWay}"
@@ -165,13 +166,14 @@
                         </Image>
                     </Canvas>
                     <Image
-                        Focusable="True"
+                        Focusable="False"
                         Width="{Binding Document.Width}"
                         Height="{Binding Document.Height}"
                         Source="{Binding TargetBitmap}"
                         Visibility="{Binding Source={vm:ToolVM ColorPickerToolViewModel}, Path=PickFromCanvas, Converter={converters:BoolToHiddenVisibilityConverter}}"
                         RenderOptions.BitmapScalingMode="{Binding Zoombox.Scale, Converter={converters:ScaleToBitmapScalingModeConverter}}"/>
                     <symOverlay:SymmetryOverlay
+                        Focusable="False"
                         IsHitTestVisible="{Binding ZoomMode, Converter={converters:ZoomModeToHitTestVisibleConverter}}"
                         ZoomboxScale="{Binding Zoombox.Scale}"
                         HorizontalAxisVisible="{Binding Document.HorizontalSymmetryAxisEnabledBindable}"
@@ -182,10 +184,12 @@
                         DragEndCommand="{cmds:Command PixiEditor.Document.EndDragSymmetry, UseProvided=True}" 
                         DragStartCommand="{cmds:Command PixiEditor.Document.StartDragSymmetry, UseProvided=True}" />
                     <overlays:SelectionOverlay
+                        Focusable="False"
                         ShowFill="{Binding ToolsSubViewModel.ActiveTool, Source={vm:MainVM}, Converter={converters:IsSelectionToolConverter}}"
                         Path="{Binding Document.SelectionPathBindable}"
                         ZoomboxScale="{Binding Zoombox.Scale}" />
                     <brushOverlay:BrushShapeOverlay
+                        Focusable="False"
                         IsHitTestVisible="False"
                         Visibility="{Binding Document.TransformViewModel.TransformActive, Converter={converters:InverseBoolToVisibilityConverter}}"
                         ZoomboxScale="{Binding Zoombox.Scale}"
@@ -195,11 +199,13 @@
                         BrushShape="{Binding ToolsSubViewModel.ActiveTool.BrushShape, Source={vm:MainVM}, FallbackValue={x:Static brushOverlay:BrushShape.Hidden}}"
                         />
                     <transformOverlay:TransformOverlay
+                        Focusable="False"
                         Cursor="Arrow"
                         IsHitTestVisible="{Binding ZoomMode, Converter={converters:ZoomModeToHitTestVisibleConverter}}"
                         HorizontalAlignment="Stretch"
                         VerticalAlignment="Stretch"
                         Visibility="{Binding Document.TransformViewModel.TransformActive, Converter={converters:BoolToVisibilityConverter}}"
+                        ActionCompleted="{Binding Document.TransformViewModel.ActionCompletedCommand}"
                         Corners="{Binding Document.TransformViewModel.Corners, Mode=TwoWay}"
                         RequestedCorners="{Binding Document.TransformViewModel.RequestedCorners, Mode=TwoWay}"
                         CornerFreedom="{Binding Document.TransformViewModel.CornerFreedom}"
@@ -211,7 +217,9 @@
                         ZoomboxScale="{Binding Zoombox.Scale}"
                         ZoomboxAngle="{Binding Zoombox.Angle}"/>
                     <lineOverlay:LineToolOverlay
+                        Focusable="False"
                         Visibility="{Binding Document.LineToolOverlayViewModel.IsEnabled, Converter={converters:BoolToVisibilityConverter}}"
+                        ActionCompleted="{Binding Document.LineToolOverlayViewModel.ActionCompletedCommand}"
                         LineStart="{Binding Document.LineToolOverlayViewModel.LineStart, Mode=TwoWay}"
                         LineEnd="{Binding Document.LineToolOverlayViewModel.LineEnd, Mode=TwoWay}"
                         ZoomboxScale="{Binding Zoombox.Scale}"/>