diff --git a/NAPS2.Images/Transforms/AbstractImageTransformer.cs b/NAPS2.Images/Transforms/AbstractImageTransformer.cs index a629a2f594..e81c53e3b3 100644 --- a/NAPS2.Images/Transforms/AbstractImageTransformer.cs +++ b/NAPS2.Images/Transforms/AbstractImageTransformer.cs @@ -187,8 +187,8 @@ protected virtual TImage PerformTransform(TImage image, CropTransform transform) protected virtual TImage PerformTransform(TImage image, ScaleTransform transform) { - var width = (int) Math.Round(image.Width * transform.ScaleFactor); - var height = (int) Math.Round(image.Height * transform.ScaleFactor); + var width = (int) Math.Max(Math.Round(image.Width * transform.ScaleFactor), 1); + var height = (int) Math.Max(Math.Round(image.Height * transform.ScaleFactor), 1); return PerformTransform(image, new ResizeTransform(width, height)); } diff --git a/NAPS2.Images/Transforms/ThumbnailTransform.cs b/NAPS2.Images/Transforms/ThumbnailTransform.cs index 72ed41e45e..96d84ec34f 100644 --- a/NAPS2.Images/Transforms/ThumbnailTransform.cs +++ b/NAPS2.Images/Transforms/ThumbnailTransform.cs @@ -1,5 +1,4 @@ - -// ReSharper disable AutoPropertyCanBeMadeGetOnly.Local +// ReSharper disable AutoPropertyCanBeMadeGetOnly.Local namespace NAPS2.Images.Transforms; @@ -30,7 +29,7 @@ public ThumbnailTransform(int size) width = Size; left = 0; // Scale the drawing height to match the original bitmap's aspect ratio - height = (int)(originalHeight * (Size / (double)originalWidth)); + height = (int) Math.Max(originalHeight * (Size / (double) originalWidth), 1); // Center the drawing vertically top = (Size - height) / 2; } @@ -40,7 +39,7 @@ public ThumbnailTransform(int size) height = Size; top = 0; // Scale the drawing width to match the original bitmap's aspect ratio - width = (int)(originalWidth * (Size / (double)originalHeight)); + width = (int) Math.Max(originalWidth * (Size / (double) originalHeight), 1); // Center the drawing horizontally left = (Size - width) / 2; } diff --git a/NAPS2.Lib/EtoForms/Ui/SplitForm.cs b/NAPS2.Lib/EtoForms/Ui/SplitForm.cs index 5a4e029822..a1e26ba0c8 100644 --- a/NAPS2.Lib/EtoForms/Ui/SplitForm.cs +++ b/NAPS2.Lib/EtoForms/Ui/SplitForm.cs @@ -1,15 +1,246 @@ using Eto.Drawing; +using Eto.Forms; +using NAPS2.EtoForms.Layout; namespace NAPS2.EtoForms.Ui; public class SplitForm : UnaryImageFormBase { - public SplitForm(Naps2Config config, UiImageList imageList, ThumbnailController thumbnailController) : + private const int HANDLE_WIDTH = 1; + private const double HANDLE_RADIUS_RATIO = 0.2; + private const int HANDLE_MIN_RADIUS = 50; + + private static CropTransform? _lastTransform; + + private readonly ColorScheme _colorScheme; + private readonly Button _vSplit; + private readonly Button _hSplit; + + // Mouse down location + private PointF _mouseOrigin; + + // Crop amounts from each side as a fraction of the total image size (updated as the user drags) + private float _cropX, _cropY; + + // Crop amounts from each side as pixels of the image to be cropped (updated on mouse up) + private float _realX, _realY; + + private bool _dragging; + private SplitOrientation _orientation; + + public SplitForm(Naps2Config config, UiImageList imageList, ThumbnailController thumbnailController, + IIconProvider iconProvider, ColorScheme colorScheme) : base(config, imageList, thumbnailController) { + _colorScheme = colorScheme; Icon = new Icon(1f, Icons.split.ToEtoImage()); Title = UiStrings.Split; + + _vSplit = C.IconButton(iconProvider.GetIcon("split")!, () => SetOrientation(SplitOrientation.Vertical)); + _hSplit = C.IconButton(iconProvider.GetIcon("split_hor")!, () => SetOrientation(SplitOrientation.Horizontal)); + Overlay.MouseDown += Overlay_MouseDown; + Overlay.MouseMove += Overlay_MouseMove; + Overlay.MouseUp += Overlay_MouseUp; + } + + protected override List Transforms => throw new NotSupportedException(); + + private int HandleClickRadius => + (int) Math.Max(Math.Round((_orientation == SplitOrientation.Horizontal ? _overlayH : _overlayW) * HANDLE_RADIUS_RATIO), HANDLE_MIN_RADIUS); + + protected override void OnPreLoad(EventArgs e) + { + base.OnPreLoad(e); + if (_lastTransform != null && _lastTransform.OriginalWidth == RealImageWidth && + _lastTransform.OriginalHeight == RealImageHeight) + { + _realX = _lastTransform.Left == 0 ? RealImageWidth / 2f : _lastTransform.Left; + _realY = _lastTransform.Top == 0 ? RealImageHeight / 2f : _lastTransform.Top; + _cropX = _realX / RealImageWidth; + _cropY = _realY / RealImageHeight; + } + else + { + _cropX = 0.5f; + _cropY = 0.5f; + _realX = RealImageWidth / 2f; + _realY = RealImageHeight / 2f; + } + } + + protected override LayoutElement CreateControls() + { + return L.Row( + C.Filler(), + L.Row(_vSplit, _hSplit), + C.Filler() + ); + } + + protected override void InitDisplayImage() + { + base.InitDisplayImage(); + _orientation = WorkingImage!.Width > WorkingImage!.Height + ? SplitOrientation.Vertical + : SplitOrientation.Horizontal; + } + + protected override void OnShown(EventArgs e) + { + base.OnShown(e); + (_orientation == SplitOrientation.Horizontal ? _hSplit : _vSplit).Focus(); + } + + private void SetOrientation(SplitOrientation orientation) + { + _orientation = orientation; + UpdatePreviewBox(); + } + + protected override IMemoryImage RenderPreview() + { + return WorkingImage!.Clone(); + } + + protected override void Revert() + { + _cropX = _cropY = 0.5f; + _realX = RealImageWidth / 2f; + _realY = RealImageHeight / 2f; + Overlay.Invalidate(); } - protected override List Transforms => []; + private void Overlay_MouseDown(object? sender, MouseEventArgs e) + { + _dragging = IsHandleUnderMouse(e); + _mouseOrigin = e.Location; + Overlay.Invalidate(); + } + + private void Overlay_MouseUp(object? sender, MouseEventArgs e) + { + _realX = _cropX * RealImageWidth; + _realY = _cropY * RealImageHeight; + _dragging = false; + Overlay.Invalidate(); + } + + private void Overlay_MouseMove(object? sender, MouseEventArgs e) + { + Overlay.Cursor = _dragging || IsHandleUnderMouse(e) + ? _orientation == SplitOrientation.Horizontal + ? Cursors.HorizontalSplit + : Cursors.VerticalSplit + : Cursors.Arrow; + if (_dragging) + { + UpdateCrop(e.Location); + Overlay.Invalidate(); + } + } + + protected override void PaintOverlay(object? sender, PaintEventArgs e) + { + base.PaintOverlay(sender, e); + + if (_overlayW == 0 || _overlayH == 0) + { + return; + } + + var offsetX = _cropX * _overlayW; + var offsetY = _cropY * _overlayH; + var fillColor = new Color(0.3f, 0.3f, 0.3f, 0.3f); + var handlePen = new Pen(_colorScheme.CropColor, HANDLE_WIDTH); + + if (_overlayW >= 1 && _overlayH >= 1) + { + // Fade out cropped-out portions of the image + if (_orientation == SplitOrientation.Horizontal) + { + e.Graphics.FillRectangle(fillColor, _overlayL, _overlayT + offsetY, _overlayW, _overlayH - offsetY); + } + else + { + e.Graphics.FillRectangle(fillColor, _overlayL + offsetX, _overlayT, _overlayW - offsetX, _overlayH); + } + } + + if (_orientation == SplitOrientation.Horizontal) + { + var y = _overlayT + offsetY - HANDLE_WIDTH / 2f; + e.Graphics.DrawLine(handlePen, _overlayL, y, _overlayR - 1, y); + } + else + { + var x = _overlayL + offsetX - HANDLE_WIDTH / 2f; + e.Graphics.DrawLine(handlePen, x, _overlayT, x, _overlayB - 1); + } + } + + private bool IsHandleUnderMouse(MouseEventArgs e) + { + var radius = HandleClickRadius; + if (_orientation == SplitOrientation.Horizontal) + { + var y = _overlayT + _cropY * _overlayH; + return e.Location.Y > y - radius && e.Location.Y < y + radius && e.Location.X > _overlayL && e.Location.X < _overlayR; + } + else + { + var x = _overlayL + _cropX * _overlayW; + return e.Location.X > x - radius && e.Location.X < x + radius && e.Location.Y > _overlayT && e.Location.Y < _overlayB; + } + } + + private void UpdateCrop(PointF mousePos) + { + var delta = mousePos - _mouseOrigin; + if (_orientation == SplitOrientation.Vertical) + { + _cropX = (_realX / RealImageWidth + delta.X / _overlayW) + .Clamp(1f / RealImageWidth, (RealImageWidth - 1f) / RealImageWidth); + } + else + { + _cropY = (_realY / RealImageHeight + delta.Y / _overlayH) + .Clamp(1f / RealImageHeight, (RealImageHeight - 1f) / RealImageHeight); + } + } + + protected override void Apply() + { + var transform1 = _orientation == SplitOrientation.Horizontal + ? new CropTransform(0, 0, 0, RealImageHeight - (int) Math.Round(_realY), RealImageWidth, RealImageHeight) + : new CropTransform(0, RealImageWidth - (int) Math.Round(_realX), 0, 0, RealImageWidth, RealImageHeight); + var transform2 = _orientation == SplitOrientation.Horizontal + ? new CropTransform(0, 0, (int) Math.Round(_realY), 0, RealImageWidth, RealImageHeight) + : new CropTransform((int) Math.Round(_realX), 0, 0, 0, RealImageWidth, RealImageHeight); + + var thumb1 = WorkingImage!.Clone() + .PerformAllTransforms([transform1, new ThumbnailTransform(ThumbnailController.RenderSize)]); + var thumb2 = WorkingImage.Clone() + .PerformAllTransforms([transform2, new ThumbnailTransform(ThumbnailController.RenderSize)]); + + // We keep the second image as the original UiImage reference so that any InsertAfter points come after the + // pair of images. For example, if I'm in the middle of scanning and I split the most-recently scanned image, + // the next scanned image should appear at the end of the list, not in between the split images. + var oldTransforms = Image.TransformState; + var image1 = new UiImage(Image.GetClonedImage()); + var image2 = Image; + image1.AddTransform(transform1, thumb1); + image2.AddTransform(transform2, thumb2); + ImageList.Mutate(new ListMutation.InsertBefore(image1, image2)); + ImageList.AddToSelection(image1); + ImageList.PushUndoElement( + new SplitUndoElement(ImageList, image1, image2, oldTransforms, transform1, transform2)); + + _lastTransform = transform2; + } + + private enum SplitOrientation + { + Horizontal, + Vertical + } } \ No newline at end of file diff --git a/NAPS2.Lib/Images/SplitUndoElement.cs b/NAPS2.Lib/Images/SplitUndoElement.cs new file mode 100644 index 0000000000..933227de8c --- /dev/null +++ b/NAPS2.Lib/Images/SplitUndoElement.cs @@ -0,0 +1,46 @@ +namespace NAPS2.Images; + +public class SplitUndoElement( + UiImageList imageList, + UiImage image1, + UiImage image2, + TransformState oldTransforms, + CropTransform transform1, + CropTransform transform2) + : IUndoElement +{ + public void ApplyUndo() + { + if (imageList.Images.Contains(image1) && imageList.Images.Contains(image2) && + image1.TransformState == oldTransforms.AddOrSimplify(transform1) && + image2.TransformState == oldTransforms.AddOrSimplify(transform2)) + { + image1.ReplaceTransformState(image1.TransformState, oldTransforms); + image2.ReplaceTransformState(image2.TransformState, oldTransforms); + if (imageList.Selection.Contains(image1)) + { + imageList.AddToSelection(image2); + } + imageList.Mutate(new ListMutation.DeleteSelected(), ListSelection.Of(image1), + updateUndoStack: false, disposeDeleted: false); + image1.GetImageWeakReference().ProcessedImage.Dispose(); + } + } + + public void ApplyRedo() + { + if (imageList.Images.Contains(image2) && !imageList.Images.Contains(image1) && + image2.TransformState == oldTransforms && !image1.IsDisposed) + { + image1.ReplaceInternalImage(image2.GetClonedImage()); + image1.AddTransform(transform1); + image2.AddTransform(transform2); + imageList.Mutate(new ListMutation.InsertBefore(image1, image2), ListSelection.Empty(), + updateUndoStack: false); + if (imageList.Selection.Contains(image2)) + { + imageList.AddToSelection(image1); + } + } + } +} \ No newline at end of file diff --git a/NAPS2.Lib/Images/UiImage.cs b/NAPS2.Lib/Images/UiImage.cs index 64c8e91cb4..6c4067136e 100644 --- a/NAPS2.Lib/Images/UiImage.cs +++ b/NAPS2.Lib/Images/UiImage.cs @@ -124,6 +124,16 @@ public void ReplaceTransformState(TransformState oldTransformState, TransformSta ThumbnailInvalidated?.Invoke(this, EventArgs.Empty); } + public void ReplaceInternalImage(ProcessedImage newImage) + { + lock (this) + { + _processedImage = newImage; + _saved = false; + } + ThumbnailInvalidated?.Invoke(this, EventArgs.Empty); + } + public void ResetTransforms() { lock (this) diff --git a/NAPS2.Lib/Images/UiImageList.cs b/NAPS2.Lib/Images/UiImageList.cs index 297013d552..21e89fa946 100644 --- a/NAPS2.Lib/Images/UiImageList.cs +++ b/NAPS2.Lib/Images/UiImageList.cs @@ -28,7 +28,6 @@ private UiImageList(List images) public StateToken CurrentState => new(Images.Select(x => x.GetImageWeakReference()).ToImmutableList()); - // TODO: We should make this selection maintain insertion order, or otherwise guarantee that for things like FDesktop.SavePDF we actually get the images in the right order public ListSelection Selection { get => _selection; @@ -49,6 +48,11 @@ public ListSelection Selection public event EventHandler? ImagesThumbnailInvalidated; + public void AddToSelection(UiImage image) + { + UpdateSelection(ListSelection.From(Images.Where(x => x == image || Selection.Contains(x)))); + } + public void UpdateSelection(ListSelection newSelection) { Selection = newSelection; @@ -86,19 +90,20 @@ public void MarkAllSaved() } public void Mutate(ListMutation mutation, ListSelection? selectionToMutate = null, - bool isPassiveInteraction = false, bool updateUndoStack = true) + bool isPassiveInteraction = false, bool updateUndoStack = true, bool disposeDeleted = true) { - MutateInternal(mutation, selectionToMutate, isPassiveInteraction, updateUndoStack); + MutateInternal(mutation, selectionToMutate, isPassiveInteraction, updateUndoStack, disposeDeleted); } public async Task MutateAsync(ListMutation mutation, ListSelection? selectionToMutate = null, - bool isPassiveInteraction = false, bool updateUndoStack = true) + bool isPassiveInteraction = false, bool updateUndoStack = true, bool disposeDeleted = true) { - await Task.Run(() => MutateInternal(mutation, selectionToMutate, isPassiveInteraction, updateUndoStack)); + await Task.Run(() => + MutateInternal(mutation, selectionToMutate, isPassiveInteraction, updateUndoStack, disposeDeleted)); } private void MutateInternal(ListMutation mutation, ListSelection? selectionToMutate, - bool isPassiveInteraction, bool updateUndoStack) + bool isPassiveInteraction, bool updateUndoStack, bool disposeDeleted) { lock (this) { @@ -118,17 +123,23 @@ private void MutateInternal(ListMutation mutation, ListSelection img.TransformState).ToList(); - foreach (var added in after.Except(before)) + var allAdded = after.Except(before).ToList(); + foreach (var added in allAdded) { added.ThumbnailChanged += ImageThumbnailChanged; added.ThumbnailInvalidated += ImageThumbnailInvalidated; } - foreach (var removed in before.Except(after)) + var allRemoved = before.Except(after).ToList(); + foreach (var removed in allRemoved) { removed.ThumbnailChanged -= ImageThumbnailChanged; removed.ThumbnailInvalidated -= ImageThumbnailInvalidated; - removed.Dispose(); + if (disposeDeleted) + { + removed.Dispose(); + } } + currentSelection = ListSelection.From(currentSelection.Except(allRemoved)); if (updateUndoStack) { diff --git a/NAPS2.Lib/Util/ListMutation.cs b/NAPS2.Lib/Util/ListMutation.cs index c5d4cecbde..b81ca23cae 100644 --- a/NAPS2.Lib/Util/ListMutation.cs +++ b/NAPS2.Lib/Util/ListMutation.cs @@ -237,10 +237,6 @@ public class DeleteAll : ListMutation { public override void Apply(List list, ref ListSelection selection) { - foreach (var item in list) - { - (item as IDisposable)?.Dispose(); - } list.Clear(); selection = ListSelection.Empty(); } @@ -253,10 +249,6 @@ public class DeleteSelected : ListMutation { public override void Apply(List list, ref ListSelection selection) { - foreach (var item in selection) - { - (item as IDisposable)?.Dispose(); - } list.RemoveAll(selection); selection = ListSelection.Empty(); } @@ -300,7 +292,6 @@ public override void Apply(List list, ref ListSelection selection) { // Default to the end of the list int index = list.Count; - // Use the index after the last item from the same source (if it exists) if (_predecessor != null) { int lastIndex = list.IndexOf(_predecessor); @@ -313,6 +304,36 @@ public override void Apply(List list, ref ListSelection selection) } } + /// + /// Inserts the given item before the given successor (or at the start of the list if none). + /// + public class InsertBefore : ListMutation + { + private readonly T _itemToInsert; + private readonly T? _successor; + + public InsertBefore(T itemToInsert, T? successor) + { + _itemToInsert = itemToInsert; + _successor = successor; + } + + public override void Apply(List list, ref ListSelection selection) + { + // Default to the start of the list + int index = 0; + if (_successor != null) + { + int lastIndex = list.IndexOf(_successor); + if (lastIndex != -1) + { + index = lastIndex; + } + } + list.Insert(index, _itemToInsert); + } + } + /// /// Replaces the selection with the given item. ///