From 1481b76aa413f23a09bce8166d3054d1db29d438 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 1 Aug 2026 23:17:40 -0500 Subject: [PATCH 1/5] Show the pixel under the transform handle being placed Issue #35 asks for accurate placement of the perspective bounding box. Two things got in the way: The loupe magnified the cursor rather than the handle. Since a handle can be grabbed anywhere within its bounds, an off-centre grab pointed the crosshair a few pixels away from where the corner would actually land, and it kept moving after the handle had been clamped to the image edge. It now tracks the constrained handle centre while still parking itself next to the cursor. The handle itself is an opaque dot sitting on the exact pixel being aimed at. While dragging, it now becomes a hollow ring with a crosshair that leaves the centre clear; idle handles keep the filled look. Both stay a constant size on screen at any zoom. Also make the loupe magnification adaptive instead of a fixed 6x, which could be *less* magnified than the canvas on a small image at high zoom, and render it with NearestNeighbor so magnified pixels stay crisp. Arrow keys now nudge the last-grabbed handle (1px, Shift 10px, Ctrl 0.25px) for placement finer than the mouse can manage. Co-Authored-By: Claude Opus 5 --- MagickCrop/Controls/PixelPrecisionZoom.xaml | 5 +- .../Controls/PixelPrecisionZoom.xaml.cs | 16 +- MagickCrop/MainWindow.TransformHandles.cs | 190 ++++++++++++++++++ MagickCrop/MainWindow.xaml.cs | 131 ++++++++---- 4 files changed, 298 insertions(+), 44 deletions(-) create mode 100644 MagickCrop/MainWindow.TransformHandles.cs 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/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.cs b/MagickCrop/MainWindow.xaml.cs index 2b1bcdf..aabe064 100644 --- a/MagickCrop/MainWindow.xaml.cs +++ b/MagickCrop/MainWindow.xaml.cs @@ -397,10 +397,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 +436,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 +596,8 @@ private void TopLeft_MouseMove(object sender, MouseEventArgs e) FinishMarkupGroupMove(); } + EndTransformHandleDrag(); + clickedElement = null; pointDraggingIndex = -1; ReleaseMouseCapture(); @@ -712,15 +725,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 +864,19 @@ private void UpdateTransformVisualScale() lines?.StrokeThickness = 2 * inverseScale; + 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); } @@ -5880,8 +5891,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 +6422,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) { @@ -6926,10 +6953,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 +6972,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 the magnified point to image coordinates + PixelZoomControl.CurrentPosition = ConvertCanvasToImageCoordinates(targetPoint); - // Convert ShapeCanvas coordinates to MainGrid coordinates - // ShapeCanvas has transforms applied, so we need to transform the point - Point mainGridPosition = ShapeCanvas.TransformToAncestor(MainGrid).Transform(mousePosition); - - // 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 +6992,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 +7011,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. /// From 0e9f848cde3a8f46a6c17919b4d650d0647087f6 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 1 Aug 2026 23:17:41 -0500 Subject: [PATCH 2/5] Keep crop rectangle handles a constant size when zooming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The perspective transform handles already counter-scale against the canvas zoom, but the crop and local-adjustment rectangles live on the same canvas and still grew and shrank with it — the complaint issue #35 raises about the transform box applies equally here. Re-centre each grab handle on the edge or corner it controls (the margins were off by a pixel), which is what makes counter-scaling about the handle centre leave it pinned in place at any zoom, then scale the handles and the outline inversely from UpdateTransformVisualScale. Resize math is unaffected: ResizableRectangle computes its drag deltas in canvas coordinates, so only the hit-test area changes. Co-Authored-By: Claude Opus 5 --- MagickCrop/Controls/ResizableRectangle.xaml | 18 ++++++------ .../Controls/ResizableRectangle.xaml.cs | 28 +++++++++++++++++++ MagickCrop/MainWindow.xaml.cs | 3 ++ 3 files changed, 40 insertions(+), 9 deletions(-) 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/MainWindow.xaml.cs b/MagickCrop/MainWindow.xaml.cs index aabe064..d4c3a10 100644 --- a/MagickCrop/MainWindow.xaml.cs +++ b/MagickCrop/MainWindow.xaml.cs @@ -864,6 +864,9 @@ private void UpdateTransformVisualScale() lines?.StrokeThickness = 2 * inverseScale; + CroppingRectangle.SetCanvasScale(scale); + LocalAdjustmentRectangle.SetCanvasScale(scale); + UpdateActiveHandleCrosshairScale(); UpdateCornerNavButtons(); } From 18984504760951508414aad21ba5b86c4e93e5d9 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 1 Aug 2026 23:17:41 -0500 Subject: [PATCH 3/5] Name the operation in progress in the canvas bar The canvas bar's busy indicator was a spinner and a hard-coded "Working", which says nothing about what is being waited on. Issue #35 asks for feedback on the stage of processing. Add BusyMessage to the view model and give SetUiForLongTask an optional message, defaulting to the old label so call sites with nothing more specific to say need no argument. Pass real labels at the slow operations: perspective correction, un-warp, tri-fold, grid straighten, edge correction, crop, resize, object erase, edge detection, capture, paste, and project open/save. Co-Authored-By: Claude Opus 5 --- MagickCrop/MainWindow.xaml | 2 +- MagickCrop/MainWindow.xaml.cs | 50 ++++++++++++-------- MagickCrop/ViewModels/MainWindowViewModel.cs | 6 +++ 3 files changed, 38 insertions(+), 20 deletions(-) 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 d4c3a10..bbfd4ec 100644 --- a/MagickCrop/MainWindow.xaml.cs +++ b/MagickCrop/MainWindow.xaml.cs @@ -988,7 +988,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); @@ -1101,7 +1101,7 @@ await Task.Run(() => private async void ApplySaveSplitButton_Click(object sender, RoutedEventArgs e) { - SetUiForLongTask(); + SetUiForLongTask("Saving image"); SaveFileDialog saveFileDialog = new() { @@ -1168,7 +1168,7 @@ private async void Save_Click(object sender, RoutedEventArgs e) if (string.IsNullOrEmpty(ViewModel.ImagePath)) return; - SetUiForLongTask(); + SetUiForLongTask("Saving image"); try { @@ -1445,10 +1445,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(); } @@ -1456,6 +1461,7 @@ private void SetUiForLongTask() private void SetUiForCompletedTask() { ViewModel.IsBusy = false; + ViewModel.BusyMessage = MainWindowViewModel.DefaultBusyMessage; Cursor = null; BottomPane.IsEnabled = true; @@ -1478,7 +1484,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() { @@ -1515,7 +1521,7 @@ private async void PasteButton_Click(object sender, RoutedEventArgs e) return; } - SetUiForLongTask(); + SetUiForLongTask("Pasting image"); try { WelcomeMessageModal.Visibility = Visibility.Collapsed; @@ -1570,7 +1576,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; @@ -2853,7 +2859,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) @@ -2996,7 +3002,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); @@ -3049,6 +3055,7 @@ private async Task RunCropDetectionAsync() if (string.IsNullOrEmpty(ViewModel.ImagePath) || !File.Exists(ViewModel.ImagePath)) return; + ViewModel.BusyMessage = "Detecting edges"; ViewModel.IsBusy = true; try @@ -3087,6 +3094,7 @@ private async Task RunCropDetectionAsync() finally { ViewModel.IsBusy = false; + ViewModel.BusyMessage = MainWindowViewModel.DefaultBusyMessage; } } @@ -3338,7 +3346,7 @@ private async void ApplyTriFoldButton_Click(object sender, RoutedEventArgs e) if (string.IsNullOrWhiteSpace(ViewModel.ImagePath)) return; - SetUiForLongTask(); + SetUiForLongTask("Correcting tri-fold"); try { @@ -3420,6 +3428,7 @@ private async Task RunUnWarpDetectionAsync() if (string.IsNullOrEmpty(ViewModel.ImagePath) || !File.Exists(ViewModel.ImagePath)) return; + ViewModel.BusyMessage = "Detecting edges"; ViewModel.IsBusy = true; try @@ -3461,6 +3470,7 @@ [.. detectionResult.Quadrilaterals.Select(q => finally { ViewModel.IsBusy = false; + ViewModel.BusyMessage = MainWindowViewModel.DefaultBusyMessage; } } @@ -3683,7 +3693,7 @@ private async void ApplyUnWarpButton_Click(object sender, RoutedEventArgs e) if (string.IsNullOrWhiteSpace(ViewModel.ImagePath)) return; - SetUiForLongTask(); + SetUiForLongTask("Un-warping image"); try { @@ -3979,7 +3989,7 @@ private async void ApplyEdgeCorrectionButton_Click(object sender, RoutedEventArg return; } - SetUiForLongTask(); + SetUiForLongTask("Straightening edges"); try { @@ -4326,7 +4336,7 @@ private async void ApplyGridStraightenButton_Click(object sender, RoutedEventArg return; } - SetUiForLongTask(); + SetUiForLongTask("Straightening grid"); try { @@ -4392,6 +4402,7 @@ private async Task RunTransformDetectionAsync() if (string.IsNullOrEmpty(ViewModel.ImagePath) || !File.Exists(ViewModel.ImagePath)) return; + ViewModel.BusyMessage = "Detecting edges"; ViewModel.IsBusy = true; try @@ -4430,6 +4441,7 @@ private async Task RunTransformDetectionAsync() finally { ViewModel.IsBusy = false; + ViewModel.BusyMessage = MainWindowViewModel.DefaultBusyMessage; } } @@ -4525,7 +4537,7 @@ private async void ApplyResizeButton_Click(object sender, RoutedEventArgs e) IgnoreAspectRatio = true }; - SetUiForLongTask(); + SetUiForLongTask("Resizing image"); magickImage.Resize(resizeGeometry); @@ -4863,7 +4875,7 @@ private async void ApplyObjectEraseButton_Click(object sender, RoutedEventArgs e return; } - SetUiForLongTask(); + SetUiForLongTask("Erasing object"); try { @@ -5497,7 +5509,7 @@ private void SaveMeasurementsPackageToFile() if (saveFileDialog.ShowDialog() != true) return; - SetUiForLongTask(); + SetUiForLongTask("Saving project"); try { @@ -5522,7 +5534,7 @@ private void SaveMeasurementsPackageToFile() public async Task LoadMeasurementsPackageFromFile() { - SetUiForLongTask(); + SetUiForLongTask("Opening project"); OpenFileDialog openFileDialog = new() { @@ -5781,7 +5793,7 @@ private async Task LoadMeasurementPackageAsync(string fileName) public async void LoadMeasurementsPackageFromFile(string filePath) { - SetUiForLongTask(); + SetUiForLongTask("Opening project"); WelcomeMessageModal.Visibility = Visibility.Collapsed; await LoadMeasurementPackageAsync(filePath); @@ -6876,7 +6888,7 @@ private async void ApplyRotationButton_Click(object sender, RoutedEventArgs e) return; // no-op } - SetUiForLongTask(); + SetUiForLongTask("Rotating image"); try { string previousPath = ViewModel.ImagePath!; 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"; From d6f6e3582e8170233dcc0d07d45864337f56f3d4 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 1 Aug 2026 23:17:41 -0500 Subject: [PATCH 4/5] Show a grabbing cursor while panning the canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Middle-drag panning used the four-arrow SizeAll cursor; issue #35 asks for the "grabbing" state instead. WPF ships no grab cursor and the project carries no binary assets, so draw a closed-hand glyph at runtime and pack it as an in-memory .cur. Construction is wrapped so any failure falls back to SizeAll — a cursor is not worth breaking panning over. Co-Authored-By: Claude Opus 5 --- MagickCrop/Helpers/CursorHelper.cs | 124 +++++++++++++++++++++++++++++ MagickCrop/MainWindow.xaml.cs | 2 +- 2 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 MagickCrop/Helpers/CursorHelper.cs 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.xaml.cs b/MagickCrop/MainWindow.xaml.cs index bbfd4ec..9af326d 100644 --- a/MagickCrop/MainWindow.xaml.cs +++ b/MagickCrop/MainWindow.xaml.cs @@ -2552,7 +2552,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; } From f8b0c1aa54613750cd621a27f1693964f50421d2 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 1 Aug 2026 23:17:41 -0500 Subject: [PATCH 5/5] Remember the canvas toggles between sessions "Allow outside" and "Show mini map" reset on every launch. Add a small JSON-backed settings store next to the recent-project data in %LocalAppData%\MagickCrop, following the same pattern as RecentProjectsManager, and seed both toggles from it at startup. Turning "Allow outside" off also now pulls handles that are already past the image edge back inside, rather than only affecting the next drag. Co-Authored-By: Claude Opus 5 --- MagickCrop/MainWindow.xaml.cs | 41 ++++++++++++ MagickCrop/Services/AppSettingsService.cs | 76 +++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 MagickCrop/Services/AppSettingsService.cs diff --git a/MagickCrop/MainWindow.xaml.cs b/MagickCrop/MainWindow.xaml.cs index 9af326d..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(); @@ -2359,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) @@ -2663,6 +2701,9 @@ private void ToggleMiniMapMenuItem_Click(object sender, RoutedEventArgs e) ToggleMiniMapMenuItem.IsChecked = showMiniMap; ToggleMiniMapBarMenuItem.IsChecked = showMiniMap; + appSettings.ShowMiniMap = showMiniMap; + appSettings.Save(); + UpdateMiniMap(); } 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; + } +}