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";