diff --git a/MagickCrop/Controls/PixelPrecisionZoom.xaml b/MagickCrop/Controls/PixelPrecisionZoom.xaml
index ed49b3d..dab548c 100644
--- a/MagickCrop/Controls/PixelPrecisionZoom.xaml
+++ b/MagickCrop/Controls/PixelPrecisionZoom.xaml
@@ -27,7 +27,10 @@
Width="150"
Height="150"
ClipToBounds="True">
-
+
diff --git a/MagickCrop/Controls/PixelPrecisionZoom.xaml.cs b/MagickCrop/Controls/PixelPrecisionZoom.xaml.cs
index 5dcbe73..bbb78af 100644
--- a/MagickCrop/Controls/PixelPrecisionZoom.xaml.cs
+++ b/MagickCrop/Controls/PixelPrecisionZoom.xaml.cs
@@ -15,9 +15,21 @@ public partial class PixelPrecisionZoom : UserControl
private const int DefaultPreviewSize = 150;
///
- /// Gets or sets the zoom magnification factor.
+ /// Gets or sets the zoom magnification factor, in screen pixels per source image pixel.
///
- public double ZoomFactor { get; set; } = DefaultZoomFactor;
+ public double ZoomFactor
+ {
+ get => zoomFactor;
+ set
+ {
+ if (Math.Abs(zoomFactor - value) < double.Epsilon)
+ return;
+
+ zoomFactor = value;
+ UpdateZoomPreview();
+ }
+ }
+ private double zoomFactor = DefaultZoomFactor;
///
/// Gets or sets the source image to magnify.
diff --git a/MagickCrop/Controls/ResizableRectangle.xaml b/MagickCrop/Controls/ResizableRectangle.xaml
index cbb9974..6887a57 100644
--- a/MagickCrop/Controls/ResizableRectangle.xaml
+++ b/MagickCrop/Controls/ResizableRectangle.xaml
@@ -22,7 +22,7 @@
-
+
+ /// Counter-scales the grab handles and the outline against the canvas zoom, so they keep a
+ /// constant on-screen size and the extra precision gained by zooming in is not thrown away.
+ /// Each handle's centre sits exactly on the edge/corner it controls, so scaling about that
+ /// centre leaves it pinned in place at any zoom.
+ ///
+ /// The current ShapeCanvas scale factor.
+ public void SetCanvasScale(double canvasScale)
+ {
+ if (canvasScale <= 0)
+ return;
+
+ double inverseScale = 1.0 / canvasScale;
+
+ foreach (UIElement child in RootGrid.Children)
+ {
+ if (ReferenceEquals(child, rectangle) || child is not FrameworkElement handle)
+ continue;
+
+ handle.RenderTransformOrigin = new Point(0.5, 0.5);
+ handle.RenderTransform = new System.Windows.Media.ScaleTransform(inverseScale, inverseScale);
+ }
+
+ // StrokeDashArray is measured in stroke thicknesses, so counter-scaling the thickness
+ // already keeps the dash pattern a constant size on screen.
+ rectangle.StrokeThickness = 2 * inverseScale;
+ }
+
///
/// Sets the stroke color and optionally hides the fill of the inner rectangle.
///
diff --git a/MagickCrop/Helpers/CursorHelper.cs b/MagickCrop/Helpers/CursorHelper.cs
new file mode 100644
index 0000000..024137e
--- /dev/null
+++ b/MagickCrop/Helpers/CursorHelper.cs
@@ -0,0 +1,124 @@
+using System.IO;
+using System.Windows;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+
+namespace MagickCrop.Helpers;
+
+///
+/// Builds the closed-hand ("grabbing") cursor shown while the canvas is being panned. WPF ships no
+/// such cursor and the app carries no binary assets, so it is drawn at runtime and packed into an
+/// in-memory .cur file.
+///
+public static class CursorHelper
+{
+ private const int CursorSize = 32;
+ private const int Hotspot = CursorSize / 2;
+
+ private static Cursor? grabbingCursor;
+
+ /// Closed hand — shown while the canvas is being dragged.
+ public static Cursor Grabbing => grabbingCursor ??= Create() ?? Cursors.SizeAll;
+
+ private static Cursor? Create()
+ {
+ try
+ {
+ byte[] bgra = RenderGrabbingHand();
+ using MemoryStream stream = new();
+ WriteCursorFile(stream, bgra);
+ stream.Position = 0;
+ return new Cursor(stream);
+ }
+ catch (Exception)
+ {
+ // Any failure here is cosmetic — the caller falls back to a stock cursor.
+ return null;
+ }
+ }
+
+ ///
+ /// Draws a closed-hand glyph and returns it as top-down premultiplied BGRA rows.
+ ///
+ private static byte[] RenderGrabbingHand()
+ {
+ DrawingVisual visual = new();
+ using (DrawingContext context = visual.RenderOpen())
+ {
+ Pen outline = new(Brushes.Black, 1.4);
+ outline.Freeze();
+
+ // Palm
+ context.DrawRoundedRectangle(Brushes.White, outline, new Rect(9, 15, 15, 12), 4, 4);
+
+ // Fingers, curled down into the palm
+ for (int i = 0; i < 4; i++)
+ {
+ double x = 10 + (i * 3.6);
+ context.DrawRoundedRectangle(Brushes.White, outline, new Rect(x, 11, 3, 5), 1.5, 1.5);
+ }
+
+ // Thumb
+ context.DrawRoundedRectangle(Brushes.White, outline, new Rect(6, 17, 5, 3), 1.5, 1.5);
+ }
+
+ RenderTargetBitmap bitmap = new(CursorSize, CursorSize, 96, 96, PixelFormats.Pbgra32);
+ bitmap.Render(visual);
+
+ int stride = CursorSize * 4;
+ byte[] pixels = new byte[stride * CursorSize];
+ bitmap.CopyPixels(pixels, stride, 0);
+ return pixels;
+ }
+
+ ///
+ /// Packs 32-bpp BGRA pixels into a classic (non-PNG) .cur: ICONDIR + ICONDIRENTRY +
+ /// BITMAPINFOHEADER + bottom-up XOR rows + an all-zero AND mask.
+ ///
+ private static void WriteCursorFile(Stream stream, byte[] bgraTopDown)
+ {
+ int stride = CursorSize * 4;
+ int maskStride = ((CursorSize + 31) / 32) * 4; // AND mask rows are DWORD aligned
+ int xorSize = stride * CursorSize;
+ int andSize = maskStride * CursorSize;
+ int imageSize = 40 + xorSize + andSize;
+
+ using BinaryWriter writer = new(stream, System.Text.Encoding.UTF8, leaveOpen: true);
+
+ // ICONDIR
+ writer.Write((ushort)0); // reserved
+ writer.Write((ushort)2); // 2 = cursor
+ writer.Write((ushort)1); // one image
+
+ // ICONDIRENTRY — for cursors the "planes"/"bitCount" fields carry the hotspot
+ writer.Write((byte)CursorSize);
+ writer.Write((byte)CursorSize);
+ writer.Write((byte)0); // colour count (0 = >=256)
+ writer.Write((byte)0); // reserved
+ writer.Write((ushort)Hotspot);
+ writer.Write((ushort)Hotspot);
+ writer.Write(imageSize);
+ writer.Write(22); // offset of the image data
+
+ // BITMAPINFOHEADER — height is doubled to cover the XOR and AND bitmaps
+ writer.Write(40);
+ writer.Write(CursorSize);
+ writer.Write(CursorSize * 2);
+ writer.Write((ushort)1);
+ writer.Write((ushort)32);
+ writer.Write(0); // BI_RGB
+ writer.Write(xorSize + andSize);
+ writer.Write(0);
+ writer.Write(0);
+ writer.Write(0);
+ writer.Write(0);
+
+ // XOR bitmap, bottom-up
+ for (int y = CursorSize - 1; y >= 0; y--)
+ writer.Write(bgraTopDown, y * stride, stride);
+
+ // AND mask — unused for 32-bpp cursors, but must be present
+ writer.Write(new byte[andSize]);
+ }
+}
diff --git a/MagickCrop/MainWindow.TransformHandles.cs b/MagickCrop/MainWindow.TransformHandles.cs
new file mode 100644
index 0000000..8626aa6
--- /dev/null
+++ b/MagickCrop/MainWindow.TransformHandles.cs
@@ -0,0 +1,190 @@
+using MagickCrop.Helpers;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Shapes;
+
+namespace MagickCrop;
+
+///
+/// Interaction polish for the perspective/tri-fold/un-warp transform handles: keeping the pixel
+/// under the dragged handle visible, and nudging a handle with the arrow keys.
+///
+public partial class MainWindow
+{
+ /// Half-extent of the crosshair, in screen pixels. Geometry spans 0..2x this.
+ private const double CrosshairRadius = 16;
+
+ /// Gap left around the centre so the targeted pixel itself stays uncovered.
+ private const double CrosshairCenterGap = 5;
+
+ private static readonly Brush TransformHandleAccent =
+ new SolidColorBrush(Color.FromRgb(0x00, 0x66, 0xFF));
+
+ private Ellipse? draggedHandle;
+ private Brush? draggedHandleOriginalFill;
+ private Brush? draggedHandleOriginalStroke;
+ private double draggedHandleOriginalStrokeThickness;
+
+ private Path? activeHandleCrosshair;
+
+ /// The handle most recently grabbed, so the arrow keys have something to nudge.
+ private Ellipse? lastActiveTransformHandle;
+ private int lastActiveTransformIndex = -1;
+
+ ///
+ /// Places a transform handle so its centre lands on , applying
+ /// the image-bounds constraint and refreshing everything that follows the handle.
+ /// Shared by the drag path and the arrow-key nudge. Returns the centre actually used.
+ ///
+ private Point MoveTransformHandleTo(FrameworkElement handle, int handleIndex, Point desiredCenter)
+ {
+ Point center = ConstrainHandlePosition(desiredCenter);
+
+ Canvas.SetLeft(handle, center.X - (handle.Width / 2));
+ Canvas.SetTop(handle, center.Y - (handle.Height / 2));
+
+ MovePolyline(handleIndex, center);
+ UpdateActiveHandleCrosshair(center);
+ UpdateCornerNavButtons();
+
+ return center;
+ }
+
+ ///
+ /// Switches the grabbed handle to a hollow ring and drops a crosshair over it, so the pixel
+ /// being targeted stays visible instead of sitting under an opaque dot.
+ ///
+ private void BeginTransformHandleDrag(Ellipse handle, Point center)
+ {
+ // A previous drag that never saw a mouse-up would otherwise leave a handle hollow.
+ EndTransformHandleDrag();
+
+ draggedHandle = handle;
+ draggedHandleOriginalFill = handle.Fill;
+ draggedHandleOriginalStroke = handle.Stroke;
+ draggedHandleOriginalStrokeThickness = handle.StrokeThickness;
+
+ handle.Fill = null;
+ handle.Stroke = TransformHandleAccent;
+ handle.StrokeThickness = 2;
+
+ EnsureActiveHandleCrosshair().Visibility = Visibility.Visible;
+ UpdateActiveHandleCrosshair(center);
+ }
+
+ /// Restores the dragged handle's normal appearance and hides the crosshair.
+ private void EndTransformHandleDrag()
+ {
+ if (draggedHandle is not null)
+ {
+ draggedHandle.Fill = draggedHandleOriginalFill;
+ draggedHandle.Stroke = draggedHandleOriginalStroke;
+ draggedHandle.StrokeThickness = draggedHandleOriginalStrokeThickness;
+ draggedHandle = null;
+ draggedHandleOriginalFill = null;
+ draggedHandleOriginalStroke = null;
+ }
+
+ if (activeHandleCrosshair is not null)
+ activeHandleCrosshair.Visibility = Visibility.Collapsed;
+ }
+
+ ///
+ /// Builds the crosshair once, in a 32x32 box centred on (16, 16), with a hole in the middle.
+ ///
+ private Path EnsureActiveHandleCrosshair()
+ {
+ if (activeHandleCrosshair is not null)
+ return activeHandleCrosshair;
+
+ const double c = CrosshairRadius;
+ GeometryGroup arms = new();
+ arms.Children.Add(new LineGeometry(new Point(0, c), new Point(c - CrosshairCenterGap, c)));
+ arms.Children.Add(new LineGeometry(new Point(c + CrosshairCenterGap, c), new Point(2 * c, c)));
+ arms.Children.Add(new LineGeometry(new Point(c, 0), new Point(c, c - CrosshairCenterGap)));
+ arms.Children.Add(new LineGeometry(new Point(c, c + CrosshairCenterGap), new Point(c, 2 * c)));
+ arms.Freeze();
+
+ activeHandleCrosshair = new Path
+ {
+ Data = arms,
+ Stroke = TransformHandleAccent,
+ StrokeThickness = 1,
+ IsHitTestVisible = false,
+ Visibility = Visibility.Collapsed,
+ };
+
+ Panel.SetZIndex(activeHandleCrosshair, 950);
+ ShapeCanvas.Children.Add(activeHandleCrosshair);
+ return activeHandleCrosshair;
+ }
+
+ ///
+ /// Moves the crosshair onto the handle centre. The geometry is authored in screen pixels, so
+ /// it is counter-scaled against the canvas zoom the same way the handles themselves are.
+ ///
+ private void UpdateActiveHandleCrosshair(Point center)
+ {
+ if (activeHandleCrosshair is null || activeHandleCrosshair.Visibility != Visibility.Visible)
+ return;
+
+ double inverseScale = 1.0 / Math.Max(MinZoom, canvasScale.ScaleX);
+
+ // Layout puts the geometry's own (0,0) at Canvas.Left/Top, and the scale is taken about
+ // the geometry centre, so the crosshair centre stays exactly on Left + CrosshairRadius.
+ Canvas.SetLeft(activeHandleCrosshair, center.X - CrosshairRadius);
+ Canvas.SetTop(activeHandleCrosshair, center.Y - CrosshairRadius);
+ activeHandleCrosshair.RenderTransform =
+ new ScaleTransform(inverseScale, inverseScale, CrosshairRadius, CrosshairRadius);
+ }
+
+ /// Keeps the crosshair at a constant screen size when the zoom changes mid-drag.
+ private void UpdateActiveHandleCrosshairScale()
+ {
+ if (draggedHandle is null)
+ return;
+
+ UpdateActiveHandleCrosshair(GeometryMathHelper.GetEllipseCenter(draggedHandle));
+ }
+
+ ///
+ /// Nudges the most recently grabbed transform handle by one step. Returns false when there is
+ /// nothing to nudge so the key press can fall through to its normal handling.
+ ///
+ private bool TryNudgeTransformHandle(Key key)
+ {
+ if (lastActiveTransformHandle is not Ellipse handle
+ || handle.Visibility != Visibility.Visible
+ || lastActiveTransformIndex < 0)
+ {
+ return false;
+ }
+
+ Vector direction = key switch
+ {
+ Key.Left => new Vector(-1, 0),
+ Key.Right => new Vector(1, 0),
+ Key.Up => new Vector(0, -1),
+ Key.Down => new Vector(0, 1),
+ _ => default,
+ };
+
+ if (direction.LengthSquared == 0)
+ return false;
+
+ double step = 1;
+ if ((Keyboard.Modifiers & ModifierKeys.Shift) == ModifierKeys.Shift)
+ step = 10;
+ else if ((Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control)
+ step = 0.25;
+
+ Point center = GeometryMathHelper.GetEllipseCenter(handle) + (direction * step);
+ Point placed = MoveTransformHandleTo(handle, lastActiveTransformIndex, center);
+
+ // Show where it landed — the nudge is otherwise easy to miss when zoomed out.
+ ShowPixelZoom(placed);
+ return true;
+ }
+}
diff --git a/MagickCrop/MainWindow.xaml b/MagickCrop/MainWindow.xaml
index 4a0532e..1eb508c 100644
--- a/MagickCrop/MainWindow.xaml
+++ b/MagickCrop/MainWindow.xaml
@@ -497,7 +497,7 @@
Margin="6,0,0,0"
VerticalAlignment="Center"
Foreground="#FFD1D5DB"
- Text="Working" />
+ Text="{Binding BusyMessage}" />
diff --git a/MagickCrop/MainWindow.xaml.cs b/MagickCrop/MainWindow.xaml.cs
index 2b1bcdf..3f62b98 100644
--- a/MagickCrop/MainWindow.xaml.cs
+++ b/MagickCrop/MainWindow.xaml.cs
@@ -143,6 +143,7 @@ void IMainWindowView.SetBusy(bool busy)
private List? markupGroupMoveTexts;
private Services.RecentProjectsManager? recentProjectsManager;
+ private readonly AppSettingsService appSettings = Singleton.Instance;
private System.Timers.Timer? autoSaveTimer;
private readonly int AutoSaveIntervalMs = (int)TimeSpan.FromSeconds(5).TotalMilliseconds;
@@ -303,6 +304,7 @@ public MainWindow()
ShapeCanvas.LostMouseCapture += ShapeCanvas_LostMouseCapture; // safety to ensure capture released
MainGrid.LostMouseCapture += MainGrid_LostMouseCapture;
rotationOverlayLabel = FindName("RotationOverlayLabel") as WpfTextBlock; // cache
+ ApplyPersistedCanvasSettings();
UpdateCanvasNavigationUi();
UpdateTransformVisualScale();
@@ -397,10 +399,15 @@ private void TopLeft_MouseDown(object sender, MouseButtonEventArgs e)
Canvas.GetLeft(ellipse) + (ellipse.Width / 2),
Canvas.GetTop(ellipse) + (ellipse.Height / 2));
handleGrabOffset = clickedPoint - handleCenter;
+ lastActiveTransformHandle = ellipse;
+ lastActiveTransformIndex = pointDraggingIndex;
CaptureMouse();
- // Show pixel zoom for precise corner placement
- ShowPixelZoom(clickedPoint);
+ BeginTransformHandleDrag(ellipse, handleCenter);
+
+ // Magnify the handle centre — not the cursor — so an off-centre grab still shows the
+ // pixel the corner will land on.
+ ShowPixelZoom(handleCenter, clickedPoint);
}
private void TopLeft_MouseMove(object sender, MouseEventArgs e)
@@ -431,14 +438,20 @@ private void TopLeft_MouseMove(object sender, MouseEventArgs e)
Point mousePos = e.GetPosition(ShapeCanvas);
if (ShouldShowPixelZoom())
{
+ // While dragging a transform handle the loupe follows the handle centre (grab offset
+ // removed and bounds applied), so the crosshair marks where the point really lands.
+ Point loupeTarget = draggingMode == DraggingMode.MoveElement && clickedElement is not null
+ ? ConstrainHandlePosition(mousePos - handleGrabOffset)
+ : mousePos;
+
// Show the pixel zoom if not already visible
if (PixelZoomControl.Visibility != Visibility.Visible)
{
- ShowPixelZoom(mousePos);
+ ShowPixelZoom(loupeTarget, mousePos);
}
else
{
- UpdatePixelZoom(mousePos);
+ UpdatePixelZoom(loupeTarget, mousePos);
}
}
else
@@ -585,6 +598,8 @@ private void TopLeft_MouseMove(object sender, MouseEventArgs e)
FinishMarkupGroupMove();
}
+ EndTransformHandleDrag();
+
clickedElement = null;
pointDraggingIndex = -1;
ReleaseMouseCapture();
@@ -712,15 +727,12 @@ private void TopLeft_MouseMove(object sender, MouseEventArgs e)
if (draggingMode != DraggingMode.MoveElement || clickedElement is null)
return;
- Point newHandleCenter = new(
- movingPoint.X - handleGrabOffset.X,
- movingPoint.Y - handleGrabOffset.Y);
- newHandleCenter = ConstrainHandlePosition(newHandleCenter);
- Canvas.SetTop(clickedElement, newHandleCenter.Y - (clickedElement.Height / 2));
- Canvas.SetLeft(clickedElement, newHandleCenter.X - (clickedElement.Width / 2));
-
- MovePolyline(newHandleCenter);
- UpdateCornerNavButtons();
+ MoveTransformHandleTo(
+ clickedElement,
+ pointDraggingIndex,
+ new Point(
+ movingPoint.X - handleGrabOffset.X,
+ movingPoint.Y - handleGrabOffset.Y));
if (draggingMode == DraggingMode.CreatingMeasurement && isCreatingMeasurement)
{
@@ -854,18 +866,22 @@ private void UpdateTransformVisualScale()
lines?.StrokeThickness = 2 * inverseScale;
+ CroppingRectangle.SetCanvasScale(scale);
+ LocalAdjustmentRectangle.SetCanvasScale(scale);
+
+ UpdateActiveHandleCrosshairScale();
UpdateCornerNavButtons();
}
- private void MovePolyline(Point newPoint)
+ private void MovePolyline(int handleIndex, Point newPoint)
{
- if (pointDraggingIndex < 0)
+ if (handleIndex < 0)
return;
// Update standard 4-corner polyline when dragging corner markers (index 0-3)
- if (pointDraggingIndex < 4 && lines is not null)
+ if (handleIndex < 4 && lines is not null)
{
- lines.Points[pointDraggingIndex] = newPoint;
+ lines.Points[handleIndex] = newPoint;
AspectRatioTransformPreview.SetAndScalePoints(lines.Points);
}
@@ -974,7 +990,7 @@ private async void ApplyButton_Click(object sender, RoutedEventArgs e)
if (string.IsNullOrWhiteSpace(ViewModel.ImagePath))
return;
- SetUiForLongTask();
+ SetUiForLongTask("Correcting perspective");
// Capture original image dimensions and crop rectangle position before distortion
Size originalDisplaySize = new(MainImage.ActualWidth, MainImage.ActualHeight);
@@ -1087,7 +1103,7 @@ await Task.Run(() =>
private async void ApplySaveSplitButton_Click(object sender, RoutedEventArgs e)
{
- SetUiForLongTask();
+ SetUiForLongTask("Saving image");
SaveFileDialog saveFileDialog = new()
{
@@ -1154,7 +1170,7 @@ private async void Save_Click(object sender, RoutedEventArgs e)
if (string.IsNullOrEmpty(ViewModel.ImagePath))
return;
- SetUiForLongTask();
+ SetUiForLongTask("Saving image");
try
{
@@ -1431,10 +1447,15 @@ void SetVisibilityForRender(UIElement element, Visibility visibility)
}
}
- private void SetUiForLongTask()
+ ///
+ /// What the user is waiting for, shown next to the canvas progress ring. Defaults to a generic
+ /// label so call sites that have nothing more specific to say need no argument.
+ ///
+ private void SetUiForLongTask(string message = MainWindowViewModel.DefaultBusyMessage)
{
BottomPane.IsEnabled = false;
Cursor = Cursors.Wait;
+ ViewModel.BusyMessage = message;
ViewModel.IsBusy = true;
autoSaveTimer?.Stop();
}
@@ -1442,6 +1463,7 @@ private void SetUiForLongTask()
private void SetUiForCompletedTask()
{
ViewModel.IsBusy = false;
+ ViewModel.BusyMessage = MainWindowViewModel.DefaultBusyMessage;
Cursor = null;
BottomPane.IsEnabled = true;
@@ -1464,7 +1486,7 @@ private void LocalAdjustmentCheckBox_Unchecked(object sender, RoutedEventArgs e)
private async void OpenFileButton_Click(object sender, RoutedEventArgs e)
{
- SetUiForLongTask();
+ SetUiForLongTask("Opening image");
OpenFileDialog openFileDialog = new()
{
@@ -1501,7 +1523,7 @@ private async void PasteButton_Click(object sender, RoutedEventArgs e)
return;
}
- SetUiForLongTask();
+ SetUiForLongTask("Pasting image");
try
{
WelcomeMessageModal.Visibility = Visibility.Collapsed;
@@ -1556,7 +1578,7 @@ private async void CameraButton_Click(object sender, RoutedEventArgs e)
{
try
{
- SetUiForLongTask();
+ SetUiForLongTask("Capturing image");
WelcomeMessageModal.Visibility = Visibility.Collapsed;
nint hwnd = new System.Windows.Interop.WindowInteropHelper(this).Handle;
@@ -2339,9 +2361,45 @@ private void FitTransformButton_Click(object sender, RoutedEventArgs e)
CenterAndZoomToFit();
}
+ ///
+ /// Restores the canvas preferences saved by a previous session.
+ ///
+ private void ApplyPersistedCanvasSettings()
+ {
+ allowHandlesOutsideImage = appSettings.AllowHandlesOutsideImage;
+ showMiniMap = appSettings.ShowMiniMap;
+
+ AllowOutsideImageToggle.IsChecked = allowHandlesOutsideImage;
+ }
+
private void AllowOutsideImageToggle_Changed(object sender, RoutedEventArgs e)
{
allowHandlesOutsideImage = AllowOutsideImageToggle.IsChecked == true;
+
+ appSettings.AllowHandlesOutsideImage = allowHandlesOutsideImage;
+ appSettings.Save();
+
+ // Turning the restriction on should pull handles that are already outside the image back
+ // in, rather than only affecting the next drag.
+ if (!allowHandlesOutsideImage)
+ ConstrainAllTransformHandles();
+ }
+
+ ///
+ /// Re-applies the image-bounds constraint to every visible transform handle.
+ ///
+ private void ConstrainAllTransformHandles()
+ {
+ foreach (Ellipse handle in GetTransformHandles())
+ {
+ if (handle.Visibility != Visibility.Visible || handle.Tag is not string tag
+ || !int.TryParse(tag, out int index))
+ {
+ continue;
+ }
+
+ MoveTransformHandleTo(handle, index, GeometryMathHelper.GetEllipseCenter(handle));
+ }
}
private void CanvasZoomSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e)
@@ -2532,7 +2590,7 @@ private void MainGrid_PreviewMouseDown(object sender, MouseButtonEventArgs e)
draggingMode = DraggingMode.Panning;
clickedPoint = e.GetPosition(this);
MainGrid.CaptureMouse();
- Cursor = Cursors.SizeAll;
+ Cursor = CursorHelper.Grabbing;
e.Handled = true;
}
@@ -2643,6 +2701,9 @@ private void ToggleMiniMapMenuItem_Click(object sender, RoutedEventArgs e)
ToggleMiniMapMenuItem.IsChecked = showMiniMap;
ToggleMiniMapBarMenuItem.IsChecked = showMiniMap;
+ appSettings.ShowMiniMap = showMiniMap;
+ appSettings.Save();
+
UpdateMiniMap();
}
@@ -2839,7 +2900,7 @@ private async Task PickColorPointAsync(Point imagePoint, bool isWhitePoint)
WhitePointColorPreview.Visibility = Visibility.Visible;
await Task.Delay(800);
- SetUiForLongTask();
+ SetUiForLongTask("Sampling color");
// Build the per-channel Level adjustment
void ApplyColorPoint(MagickImage target)
@@ -2982,7 +3043,7 @@ private async void ApplyCropButton_Click(object sender, RoutedEventArgs e)
double factor = magickImage.Height / displayHeight;
cropGeometry.ScaleAll(factor);
- SetUiForLongTask();
+ SetUiForLongTask("Cropping image");
magickImage.Crop(cropGeometry);
@@ -3035,6 +3096,7 @@ private async Task RunCropDetectionAsync()
if (string.IsNullOrEmpty(ViewModel.ImagePath) || !File.Exists(ViewModel.ImagePath))
return;
+ ViewModel.BusyMessage = "Detecting edges";
ViewModel.IsBusy = true;
try
@@ -3073,6 +3135,7 @@ private async Task RunCropDetectionAsync()
finally
{
ViewModel.IsBusy = false;
+ ViewModel.BusyMessage = MainWindowViewModel.DefaultBusyMessage;
}
}
@@ -3324,7 +3387,7 @@ private async void ApplyTriFoldButton_Click(object sender, RoutedEventArgs e)
if (string.IsNullOrWhiteSpace(ViewModel.ImagePath))
return;
- SetUiForLongTask();
+ SetUiForLongTask("Correcting tri-fold");
try
{
@@ -3406,6 +3469,7 @@ private async Task RunUnWarpDetectionAsync()
if (string.IsNullOrEmpty(ViewModel.ImagePath) || !File.Exists(ViewModel.ImagePath))
return;
+ ViewModel.BusyMessage = "Detecting edges";
ViewModel.IsBusy = true;
try
@@ -3447,6 +3511,7 @@ [.. detectionResult.Quadrilaterals.Select(q =>
finally
{
ViewModel.IsBusy = false;
+ ViewModel.BusyMessage = MainWindowViewModel.DefaultBusyMessage;
}
}
@@ -3669,7 +3734,7 @@ private async void ApplyUnWarpButton_Click(object sender, RoutedEventArgs e)
if (string.IsNullOrWhiteSpace(ViewModel.ImagePath))
return;
- SetUiForLongTask();
+ SetUiForLongTask("Un-warping image");
try
{
@@ -3965,7 +4030,7 @@ private async void ApplyEdgeCorrectionButton_Click(object sender, RoutedEventArg
return;
}
- SetUiForLongTask();
+ SetUiForLongTask("Straightening edges");
try
{
@@ -4312,7 +4377,7 @@ private async void ApplyGridStraightenButton_Click(object sender, RoutedEventArg
return;
}
- SetUiForLongTask();
+ SetUiForLongTask("Straightening grid");
try
{
@@ -4378,6 +4443,7 @@ private async Task RunTransformDetectionAsync()
if (string.IsNullOrEmpty(ViewModel.ImagePath) || !File.Exists(ViewModel.ImagePath))
return;
+ ViewModel.BusyMessage = "Detecting edges";
ViewModel.IsBusy = true;
try
@@ -4416,6 +4482,7 @@ private async Task RunTransformDetectionAsync()
finally
{
ViewModel.IsBusy = false;
+ ViewModel.BusyMessage = MainWindowViewModel.DefaultBusyMessage;
}
}
@@ -4511,7 +4578,7 @@ private async void ApplyResizeButton_Click(object sender, RoutedEventArgs e)
IgnoreAspectRatio = true
};
- SetUiForLongTask();
+ SetUiForLongTask("Resizing image");
magickImage.Resize(resizeGeometry);
@@ -4849,7 +4916,7 @@ private async void ApplyObjectEraseButton_Click(object sender, RoutedEventArgs e
return;
}
- SetUiForLongTask();
+ SetUiForLongTask("Erasing object");
try
{
@@ -5483,7 +5550,7 @@ private void SaveMeasurementsPackageToFile()
if (saveFileDialog.ShowDialog() != true)
return;
- SetUiForLongTask();
+ SetUiForLongTask("Saving project");
try
{
@@ -5508,7 +5575,7 @@ private void SaveMeasurementsPackageToFile()
public async Task LoadMeasurementsPackageFromFile()
{
- SetUiForLongTask();
+ SetUiForLongTask("Opening project");
OpenFileDialog openFileDialog = new()
{
@@ -5767,7 +5834,7 @@ private async Task LoadMeasurementPackageAsync(string fileName)
public async void LoadMeasurementsPackageFromFile(string filePath)
{
- SetUiForLongTask();
+ SetUiForLongTask("Opening project");
WelcomeMessageModal.Visibility = Visibility.Collapsed;
await LoadMeasurementPackageAsync(filePath);
@@ -5880,8 +5947,11 @@ private void ResetTransientState()
// --- Cancel any active placement / creation state ---
isCreatingMeasurement = false;
draggingMode = DraggingMode.None;
+ EndTransformHandleDrag();
clickedElement = null;
pointDraggingIndex = -1;
+ lastActiveTransformHandle = null;
+ lastActiveTransformIndex = -1;
ShapeCanvas.ReleaseMouseCapture();
ReleaseMouseCapture();
@@ -6408,6 +6478,19 @@ private void FluentWindow_PreviewKeyDown(object sender, KeyEventArgs e)
// leave Ctrl+Z/Ctrl+Y to the text box's own undo
bool typingInTextBox = Keyboard.FocusedElement is System.Windows.Controls.Primitives.TextBoxBase;
+ // Arrow keys nudge the transform handle that was last grabbed, for placement that is finer
+ // than the mouse can manage. Controls that use the arrow keys themselves keep priority.
+ if (e.Key is Key.Left or Key.Right or Key.Up or Key.Down
+ && !typingInTextBox
+ && Keyboard.FocusedElement is not Slider
+ && Keyboard.FocusedElement is not System.Windows.Controls.ComboBox
+ && (Keyboard.Modifiers & ModifierKeys.Alt) != ModifierKeys.Alt
+ && TryNudgeTransformHandle(e.Key))
+ {
+ e.Handled = true;
+ return;
+ }
+
// Handle Ctrl+Z for undo
if (!typingInTextBox && (Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control && e.Key == Key.Z)
{
@@ -6846,7 +6929,7 @@ private async void ApplyRotationButton_Click(object sender, RoutedEventArgs e)
return; // no-op
}
- SetUiForLongTask();
+ SetUiForLongTask("Rotating image");
try
{
string previousPath = ViewModel.ImagePath!;
@@ -6926,10 +7009,17 @@ private void PreciseRotateMenuItem_Click(object sender, RoutedEventArgs e)
#region Pixel Precision Zoom
///
- /// Shows the pixel precision zoom control at the current mouse position.
+ /// Shows the pixel precision zoom control.
///
- /// Mouse position in ShapeCanvas coordinates
- private void ShowPixelZoom(Point mousePosition)
+ ///
+ /// The point to magnify, in ShapeCanvas coordinates. When a handle is being dragged by its
+ /// edge this is the handle centre, not the cursor, so the crosshair marks where the point
+ /// will actually land.
+ ///
+ ///
+ /// Where to park the loupe, in ShapeCanvas coordinates. Defaults to .
+ ///
+ private void ShowPixelZoom(Point targetPoint, Point? cursorPoint = null)
{
if (MainImage.Source == null)
return;
@@ -6938,17 +7028,12 @@ private void ShowPixelZoom(Point mousePosition)
{
// Set the source image for the zoom control
PixelZoomControl.SourceImage = MainImage.Source;
+ UpdateLoupeMagnification();
- // Convert mouse position to image coordinates
- Point imagePosition = ConvertCanvasToImageCoordinates(mousePosition);
- PixelZoomControl.CurrentPosition = imagePosition;
-
- // Convert ShapeCanvas coordinates to MainGrid coordinates
- // ShapeCanvas has transforms applied, so we need to transform the point
- Point mainGridPosition = ShapeCanvas.TransformToAncestor(MainGrid).Transform(mousePosition);
+ // Convert the magnified point to image coordinates
+ PixelZoomControl.CurrentPosition = ConvertCanvasToImageCoordinates(targetPoint);
- // Position the zoom control near the cursor in MainGrid coordinates
- PixelZoomControl.PositionNearCursor(mainGridPosition, MainGrid.ActualWidth, MainGrid.ActualHeight);
+ PositionPixelZoom(cursorPoint ?? targetPoint);
// Show the control
PixelZoomControl.Visibility = Visibility.Visible;
@@ -6963,24 +7048,18 @@ private void ShowPixelZoom(Point mousePosition)
///
/// Updates the pixel precision zoom control position and preview.
///
- /// Mouse position in ShapeCanvas coordinates
- private void UpdatePixelZoom(Point mousePosition)
+ /// The point to magnify, in ShapeCanvas coordinates
+ /// Where to park the loupe, in ShapeCanvas coordinates
+ private void UpdatePixelZoom(Point targetPoint, Point? cursorPoint = null)
{
if (PixelZoomControl.Visibility != Visibility.Visible)
return;
try
{
- // Convert mouse position to image coordinates
- Point imagePosition = ConvertCanvasToImageCoordinates(mousePosition);
- PixelZoomControl.CurrentPosition = imagePosition;
-
- // Convert ShapeCanvas coordinates to MainGrid coordinates
- // ShapeCanvas has transforms applied, so we need to transform the point
- Point mainGridPosition = ShapeCanvas.TransformToAncestor(MainGrid).Transform(mousePosition);
-
- // Update the zoom control position in MainGrid coordinates
- PixelZoomControl.PositionNearCursor(mainGridPosition, MainGrid.ActualWidth, MainGrid.ActualHeight);
+ UpdateLoupeMagnification();
+ PixelZoomControl.CurrentPosition = ConvertCanvasToImageCoordinates(targetPoint);
+ PositionPixelZoom(cursorPoint ?? targetPoint);
}
catch (Exception)
{
@@ -6988,6 +7067,32 @@ private void UpdatePixelZoom(Point mousePosition)
}
}
+ ///
+ /// Moves the loupe next to the cursor. ShapeCanvas is pan/zoom transformed, so the point has
+ /// to be projected into MainGrid coordinates first.
+ ///
+ private void PositionPixelZoom(Point cursorPoint)
+ {
+ Point mainGridPosition = ShapeCanvas.TransformToAncestor(MainGrid).Transform(cursorPoint);
+ PixelZoomControl.PositionNearCursor(mainGridPosition, MainGrid.ActualWidth, MainGrid.ActualHeight);
+ }
+
+ ///
+ /// Keeps the loupe more magnified than the canvas itself. The loupe magnifies source pixels,
+ /// so on a small image at a high canvas zoom a fixed factor would actually show *less* detail
+ /// than the canvas underneath it.
+ ///
+ private void UpdateLoupeMagnification()
+ {
+ if (MainImage.Source is not BitmapSource source
+ || source.PixelWidth <= 0
+ || MainImage.ActualWidth <= 0)
+ return;
+
+ double canvasPixelsPerSourcePixel = MainImage.ActualWidth / source.PixelWidth * canvasScale.ScaleX;
+ PixelZoomControl.ZoomFactor = Math.Clamp(canvasPixelsPerSourcePixel * 2.5, 6.0, 24.0);
+ }
+
///
/// Hides the pixel precision zoom control.
///
diff --git a/MagickCrop/Services/AppSettingsService.cs b/MagickCrop/Services/AppSettingsService.cs
new file mode 100644
index 0000000..1273a8a
--- /dev/null
+++ b/MagickCrop/Services/AppSettingsService.cs
@@ -0,0 +1,76 @@
+using System.IO;
+using System.Text.Json;
+
+namespace MagickCrop.Services;
+
+///
+/// Small JSON-backed store for canvas preferences that should survive a restart. Kept alongside
+/// the recent-project data in %LocalAppData%\MagickCrop.
+///
+public class AppSettingsService
+{
+ private const string SettingsFileName = "settings.json";
+ private readonly string _settingsPath;
+
+ /// Whether transform handles may be dragged past the edge of the image.
+ public bool AllowHandlesOutsideImage { get; set; } = true;
+
+ /// Whether the canvas mini map overlay is shown.
+ public bool ShowMiniMap { get; set; } = true;
+
+ public AppSettingsService()
+ {
+ string appDataFolder = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "MagickCrop");
+
+ Directory.CreateDirectory(appDataFolder);
+ _settingsPath = Path.Combine(appDataFolder, SettingsFileName);
+
+ Load();
+ }
+
+ private void Load()
+ {
+ if (!File.Exists(_settingsPath))
+ return;
+
+ try
+ {
+ SettingsDto? saved = JsonSerializer.Deserialize(File.ReadAllText(_settingsPath));
+ if (saved is null)
+ return;
+
+ AllowHandlesOutsideImage = saved.AllowHandlesOutsideImage;
+ ShowMiniMap = saved.ShowMiniMap;
+ }
+ catch (Exception)
+ {
+ // Corrupt or unreadable settings just fall back to the defaults.
+ }
+ }
+
+ public void Save()
+ {
+ try
+ {
+ SettingsDto dto = new()
+ {
+ AllowHandlesOutsideImage = AllowHandlesOutsideImage,
+ ShowMiniMap = ShowMiniMap,
+ };
+
+ File.WriteAllText(_settingsPath, JsonSerializer.Serialize(dto));
+ }
+ catch (Exception)
+ {
+ // Preferences are not worth interrupting the user over.
+ }
+ }
+
+ private sealed class SettingsDto
+ {
+ public bool AllowHandlesOutsideImage { get; set; } = true;
+ public bool ShowMiniMap { get; set; } = true;
+ }
+}
diff --git a/MagickCrop/ViewModels/MainWindowViewModel.cs b/MagickCrop/ViewModels/MainWindowViewModel.cs
index 4f16e5e..e5484a0 100644
--- a/MagickCrop/ViewModels/MainWindowViewModel.cs
+++ b/MagickCrop/ViewModels/MainWindowViewModel.cs
@@ -89,6 +89,12 @@ public void SetView(IMainWindowView view)
[NotifyPropertyChangedFor(nameof(IsNotBusy))]
private bool isBusy;
+ /// What the app is currently busy doing, shown next to the canvas progress ring.
+ [ObservableProperty]
+ private string busyMessage = DefaultBusyMessage;
+
+ public const string DefaultBusyMessage = "Working";
+
[ObservableProperty]
private string windowTitle = "Magick Crop & Measure by TheJoeFin";