Initial split impl

This commit is contained in:
Ben Olden-Cooligan 2024-03-09 15:10:16 -08:00
parent 5b78a8f84b
commit 03bd237cd3
7 changed files with 344 additions and 26 deletions

View File

@ -187,8 +187,8 @@ public abstract class AbstractImageTransformer<TImage> where TImage : IMemoryIma
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));
}

View File

@ -1,5 +1,4 @@

// ReSharper disable AutoPropertyCanBeMadeGetOnly.Local
// ReSharper disable AutoPropertyCanBeMadeGetOnly.Local
namespace NAPS2.Images.Transforms;
@ -30,7 +29,7 @@ public record ThumbnailTransform : Transform
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 record ThumbnailTransform : Transform
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;
}

View File

@ -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<Transform> Transforms => [];
protected override List<Transform> 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();
}
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<UiImage>.InsertBefore(image1, image2));
ImageList.AddToSelection(image1);
ImageList.PushUndoElement(
new SplitUndoElement(ImageList, image1, image2, oldTransforms, transform1, transform2));
_lastTransform = transform2;
}
private enum SplitOrientation
{
Horizontal,
Vertical
}
}

View File

@ -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<UiImage>.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<UiImage>.InsertBefore(image1, image2), ListSelection.Empty<UiImage>(),
updateUndoStack: false);
if (imageList.Selection.Contains(image2))
{
imageList.AddToSelection(image1);
}
}
}
}

View File

@ -124,6 +124,16 @@ public class UiImage : IDisposable
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)

View File

@ -28,7 +28,6 @@ public class UiImageList
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<UiImage> Selection
{
get => _selection;
@ -49,6 +48,11 @@ public class UiImageList
public event EventHandler<ImageListEventArgs>? ImagesThumbnailInvalidated;
public void AddToSelection(UiImage image)
{
UpdateSelection(ListSelection.From(Images.Where(x => x == image || Selection.Contains(x))));
}
public void UpdateSelection(ListSelection<UiImage> newSelection)
{
Selection = newSelection;
@ -86,19 +90,20 @@ public class UiImageList
}
public void Mutate(ListMutation<UiImage> mutation, ListSelection<UiImage>? 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<UiImage> mutation, ListSelection<UiImage>? 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<UiImage> mutation, ListSelection<UiImage>? selectionToMutate,
bool isPassiveInteraction, bool updateUndoStack)
bool isPassiveInteraction, bool updateUndoStack, bool disposeDeleted)
{
lock (this)
{
@ -118,17 +123,23 @@ public class UiImageList
var after = Images.ToList();
var afterTransforms = after.Select(img => 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)
{

View File

@ -237,10 +237,6 @@ public abstract class ListMutation<T> where T : notnull
{
public override void Apply(List<T> list, ref ListSelection<T> selection)
{
foreach (var item in list)
{
(item as IDisposable)?.Dispose();
}
list.Clear();
selection = ListSelection.Empty<T>();
}
@ -253,10 +249,6 @@ public abstract class ListMutation<T> where T : notnull
{
public override void Apply(List<T> list, ref ListSelection<T> selection)
{
foreach (var item in selection)
{
(item as IDisposable)?.Dispose();
}
list.RemoveAll(selection);
selection = ListSelection.Empty<T>();
}
@ -300,7 +292,6 @@ public abstract class ListMutation<T> where T : notnull
{
// 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 abstract class ListMutation<T> where T : notnull
}
}
/// <summary>
/// Inserts the given item before the given successor (or at the start of the list if none).
/// </summary>
public class InsertBefore : ListMutation<T>
{
private readonly T _itemToInsert;
private readonly T? _successor;
public InsertBefore(T itemToInsert, T? successor)
{
_itemToInsert = itemToInsert;
_successor = successor;
}
public override void Apply(List<T> list, ref ListSelection<T> 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);
}
}
/// <summary>
/// Replaces the selection with the given item.
/// </summary>