Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion MagickCrop/Controls/PixelPrecisionZoom.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@
Width="150"
Height="150"
ClipToBounds="True">
<Image x:Name="ZoomImage" Stretch="None" />
<Image
x:Name="ZoomImage"
RenderOptions.BitmapScalingMode="NearestNeighbor"
Stretch="None" />
</Canvas>
</Viewbox>

Expand Down
16 changes: 14 additions & 2 deletions MagickCrop/Controls/PixelPrecisionZoom.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,21 @@ public partial class PixelPrecisionZoom : UserControl
private const int DefaultPreviewSize = 150;

/// <summary>
/// Gets or sets the zoom magnification factor.
/// Gets or sets the zoom magnification factor, in screen pixels per source image pixel.
/// </summary>
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;

/// <summary>
/// Gets or sets the source image to magnify.
Expand Down
18 changes: 9 additions & 9 deletions MagickCrop/Controls/ResizableRectangle.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
<Setter Property="StrokeThickness" Value="1" />
</Style>
</UserControl.Resources>
<Grid>
<Grid x:Name="RootGrid">
<Rectangle
x:Name="rectangle"
Cursor="SizeAll"
Expand All @@ -40,7 +40,7 @@
</Rectangle>

<Ellipse
Margin="-5,-5,0,0"
Margin="-6,-6,0,0"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Cursor="SizeNWSE"
Expand All @@ -49,7 +49,7 @@
Tag="TopLeft"
ToolTip="Resize from top-left" />
<Ellipse
Margin="0,-5,-5,0"
Margin="0,-6,-6,0"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Cursor="SizeNESW"
Expand All @@ -58,7 +58,7 @@
Tag="TopRight"
ToolTip="Resize from top-right" />
<Ellipse
Margin="0,0,-5,-5"
Margin="0,0,-6,-6"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Cursor="SizeNWSE"
Expand All @@ -67,7 +67,7 @@
Tag="BottomRight"
ToolTip="Resize from bottom-right" />
<Ellipse
Margin="-5,0,0,-5"
Margin="-6,0,0,-6"
HorizontalAlignment="Left"
VerticalAlignment="Bottom"
Cursor="SizeNESW"
Expand All @@ -79,7 +79,7 @@
<Rectangle
Width="20"
Height="8"
Margin="0,-3,0,0"
Margin="0,-4,0,0"
HorizontalAlignment="Center"
VerticalAlignment="Top"
Cursor="SizeNS"
Expand All @@ -94,7 +94,7 @@
<Rectangle
Width="8"
Height="20"
Margin="0,0,-3,0"
Margin="0,0,-4,0"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Cursor="SizeWE"
Expand All @@ -109,7 +109,7 @@
<Rectangle
Width="20"
Height="8"
Margin="0,0,0,-3"
Margin="0,0,0,-4"
HorizontalAlignment="Center"
VerticalAlignment="Bottom"
Cursor="SizeNS"
Expand All @@ -124,7 +124,7 @@
<Rectangle
Width="8"
Height="20"
Margin="-3,0,0,0"
Margin="-4,0,0,0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Cursor="SizeWE"
Expand Down
28 changes: 28 additions & 0 deletions MagickCrop/Controls/ResizableRectangle.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,34 @@ public MagickGeometry CropShape
(uint)rectangle.ActualHeight);
}

/// <summary>
/// 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.
/// </summary>
/// <param name="canvasScale">The current ShapeCanvas scale factor.</param>
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;
}

/// <summary>
/// Sets the stroke color and optionally hides the fill of the inner rectangle.
/// </summary>
Expand Down
124 changes: 124 additions & 0 deletions MagickCrop/Helpers/CursorHelper.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
public static class CursorHelper
{
private const int CursorSize = 32;
private const int Hotspot = CursorSize / 2;

private static Cursor? grabbingCursor;

/// <summary>Closed hand — shown while the canvas is being dragged.</summary>
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;
}
}

/// <summary>
/// Draws a closed-hand glyph and returns it as top-down premultiplied BGRA rows.
/// </summary>
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;
}

/// <summary>
/// Packs 32-bpp BGRA pixels into a classic (non-PNG) .cur: ICONDIR + ICONDIRENTRY +
/// BITMAPINFOHEADER + bottom-up XOR rows + an all-zero AND mask.
/// </summary>
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]);
}
}
Loading
Loading