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: 3 additions & 2 deletions src/Filee.App/Controls/CropCanvas.cs
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,10 @@ static CropCanvas()
// The picture is never mirrored, also in a right-to-left UI.
public CropCanvas() => FlowDirection = FlowDirection.LeftToRight;

public void ResetCrop()
/// <summary>Starts the frame over: the whole picture, or <paramref name="frame"/> (a ratio carried from the last picture).</summary>
public void ResetCrop(Rect? frame = null)
{
SetRect(new Rect(0, 0, 1, 1), animate: true);
SetRect(frame ?? new Rect(0, 0, 1, 1), animate: true);
}

public void SetAspectRatio(double ratio)
Expand Down
13 changes: 13 additions & 0 deletions src/Filee.App/Controls/CropMath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,19 @@ public static string Numbers(string mode, bool portrait)
return $"{parts[1]}:{parts[0]}";
}

/// <summary>
/// The largest frame of <paramref name="ratio"/> (width / height in pixels) on a picture, centred, in parts of the
/// picture; the whole picture for 0 (free).
/// </summary>
public static Rect Centered(double ratio, int width, int height)
{
if (ratio <= 0 || width <= 0 || height <= 0)
return new Rect(0, 0, 1, 1);
var w = Math.Min(1, ratio * height / width);
var h = Math.Min(1, width / (ratio * height));
return new Rect((1 - w) / 2, (1 - h) / 2, w, h);
}

/// <summary>The frame in pixels of the picture (at least 1 × 1).</summary>
public static PixelSize PixelSize(Rect normalized, int width, int height) =>
new(Math.Max(1, (int)Math.Round(normalized.Width * width)), Math.Max(1, (int)Math.Round(normalized.Height * height)));
Expand Down
32 changes: 23 additions & 9 deletions src/Filee.App/ViewModels/CropOverlayViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,18 @@ public sealed partial class CropOverlayViewModel : ObservableObject, IDisposable
public string OrientationText => _loc[IsPortrait ? "crop.portrait" : "crop.landscape"];

public event Action? CloseRequested;
public event Action? ResetRequested;

/// <summary>The frame starts over: the whole picture, or the frame given (a ratio carried from the last picture).</summary>
public event Action<Rect?>? ResetRequested;
public event Action<double>? AspectRatioChanged;

/// <summary>
/// The ratio the user picked, kept for the next pictures of the batch (#542), and its orientation; null while none
/// was picked or after Reset, so the next picture starts free.
/// </summary>
private string? _batchAspect;
private bool _batchPortrait;

public Rect CurrentCropRect { get; set; } = new(0.05, 0.05, 0.9, 0.9);

public CropOverlayViewModel(IReadOnlyList<string> files, Preset preset, ILocalizer loc, UserDataStore store, ILogger? log = null)
Expand Down Expand Up @@ -186,14 +195,17 @@ public async Task LoadFileAsync(int index)

OriginalWidth = origW;
OriginalHeight = origH;
SelectedAspect = "free";
IsPortrait = origH > origW;
// A ratio picked for an earlier picture carries on (#542): the frame starts as large as it fits, centred,
// in the orientation picked for it. Free (nothing picked yet) starts on the whole picture.
var mode = _batchAspect ?? "free";
SelectedAspect = mode;
IsPortrait = CropMath.CanSwap(mode) ? _batchPortrait : origH > origW;
IsAspectDialOpen = false;
AspectRatio = 0;
AspectRatioChanged?.Invoke(0);
CurrentCropRect = new Rect(0.05, 0.05, 0.9, 0.9);
UpdateDimensions(CurrentCropRect);
ResetRequested?.Invoke();
AspectRatio = CropMath.Ratio(mode, IsPortrait, origW, origH);
AspectRatioChanged?.Invoke(AspectRatio);
var frame = CropMath.Centered(AspectRatio, origW, origH);
UpdateDimensions(frame);
ResetRequested?.Invoke(frame);
}
catch (Exception ex)
{
Expand Down Expand Up @@ -303,11 +315,12 @@ public async Task SkipAsync()
[RelayCommand]
public void Reset()
{
_batchAspect = null; // the next pictures start free again
SelectedAspect = "free";
IsPortrait = OriginalHeight > OriginalWidth;
AspectRatio = 0;
AspectRatioChanged?.Invoke(0);
ResetRequested?.Invoke();
ResetRequested?.Invoke(null);
}

/// <summary>Cancel, or Close once finished. A crop still saving finishes and goes into the history.</summary>
Expand Down Expand Up @@ -353,6 +366,7 @@ public void ToggleAspectDial()

private void ApplyAspect(string mode)
{
(_batchAspect, _batchPortrait) = (mode == "free" ? null : mode, IsPortrait);
SelectedAspect = mode;
AspectRatio = CropMath.Ratio(mode, IsPortrait, OriginalWidth, OriginalHeight);
AspectRatioChanged?.Invoke(AspectRatio);
Expand Down
2 changes: 1 addition & 1 deletion src/Filee.App/Views/CropOverlayWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ private void OnDataContextChanged(object? sender, EventArgs e)
{
_vm = vm;
vm.CloseRequested += Close;
vm.ResetRequested += () => Canvas.ResetCrop();
vm.ResetRequested += frame => Canvas.ResetCrop(frame);
vm.AspectRatioChanged += ratio => Canvas.SetAspectRatio(ratio);
vm.PropertyChanged += OnViewModelChanged;
Canvas.CropRectChanged += rect => vm.UpdateDimensions(rect);
Expand Down
15 changes: 15 additions & 0 deletions tests/Filee.App.Tests/CropMathTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,21 @@ public void The_frame_in_pixels_is_rounded_and_never_empty()
Assert.Equal(new PixelSize(1, 1), CropMath.PixelSize(new Rect(0, 0, 0.0001, 0), 1600, 1000));
}

[Fact]
public void A_carried_ratio_starts_as_the_largest_centred_frame()
{
// 4:5 on a landscape 1600 × 1000: the whole height, 800 wide in the middle.
Assert.Equal(new Rect(0.25, 0, 0.5, 1), CropMath.Centered(4.0 / 5, 1600, 1000));
// 16:9 on an upright 1000 × 1600: the whole width, 562.5 high in the middle.
var wide = CropMath.Centered(16.0 / 9, 1000, 1600);
Assert.Equal((0, 1), (wide.X, wide.Width));
Assert.Equal(562.5 / 1600, wide.Height, 6);
Assert.Equal((1 - wide.Height) / 2, wide.Y, 6);
// The picture's own ratio, and free, fill it.
Assert.Equal(new Rect(0, 0, 1, 1), CropMath.Centered(1.6, 1600, 1000));
Assert.Equal(new Rect(0, 0, 1, 1), CropMath.Centered(0, 1600, 1000));
}

[Fact]
public void The_size_chip_sits_under_the_frame_inside_it_or_above_it()
{
Expand Down
50 changes: 45 additions & 5 deletions tests/Filee.App.Tests/CropOverlayTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -329,27 +329,67 @@ public void An_upright_picture_crops_at_instagrams_4_to_5_from_the_5_to_4_slice(
}

[AvaloniaFact]
public void The_next_file_starts_free_and_full_in_view_and_canvas_alike()
public void The_next_file_keeps_the_chosen_ratio_as_large_as_it_fits_centred_in_view_and_canvas_alike()
{
// #542: a batch for Instagram picks 4:5 once, not for every picture.
WithOverlay("en", false, ["harbour.jpg", "tall.jpg"], (window, vm, _suffix) =>
{
vm.SetAspect("16:9");
vm.SwapOrientation();
vm.SwapOrientation(); // 9:16
_ = vm.CropAndSaveAsync();
WaitFor(() => vm.CurrentIndex == 1 && !vm.IsBusy && vm.PreviewImage is not null);
Pump();

Assert.Equal("16:9", vm.SelectedAspect);
Assert.True(vm.IsPortrait);
Assert.Equal("9:16", vm.AspectLabel);
Assert.Equal(9.0 / 16, vm.AspectRatio, 6);
Assert.Equal(9.0 / 16, window.CropCanvas.AspectRatio, 6);
// 1000 × 1600: the whole height, 900 wide, in the middle.
var frame = new Rect(0.05, 0, 0.9, 1);
AssertClose(frame, window.CropCanvas.NormalizedCropRect);
AssertClose(frame, vm.CurrentCropRect);
Assert.Equal("900 × 1600 px", vm.DimensionsText);
Assert.Equal(AppHost.Get<ILocalizer>()["crop.save"], vm.SaveText);
});
}

[AvaloniaFact]
public void A_batch_starts_free_until_a_ratio_is_picked_and_again_after_reset()
{
WithOverlay("en", false, ["harbour.jpg", "tall.jpg", "two.jpg"], (window, vm, _suffix) =>
{
// Nothing picked: the next picture starts free, on the whole picture.
_ = vm.CropAndSaveAsync();
WaitFor(() => vm.CurrentIndex == 1 && !vm.IsBusy && vm.PreviewImage is not null);
Pump();
Assert.Equal("free", vm.SelectedAspect);
Assert.Equal(0, vm.AspectRatio);
Assert.Equal(0, window.CropCanvas.AspectRatio);
Assert.True(vm.IsPortrait); // the second picture is upright
Assert.Equal(new Rect(0, 0, 1, 1), window.CropCanvas.NormalizedCropRect);
Assert.Equal(new Rect(0, 0, 1, 1), vm.CurrentCropRect);
Assert.Equal("1000 × 1600 px", vm.DimensionsText);
Assert.Equal(AppHost.Get<ILocalizer>()["crop.save"], vm.SaveText);

// Picked, then R (Reset): back to free for this picture and the next.
vm.SetAspect("4:3");
vm.Reset();
_ = vm.CropAndSaveAsync();
WaitFor(() => vm.CurrentIndex == 2 && !vm.IsBusy && vm.PreviewImage is not null);
Pump();
Assert.Equal("free", vm.SelectedAspect);
Assert.False(vm.IsPortrait);
Assert.Equal(new Rect(0, 0, 1, 1), window.CropCanvas.NormalizedCropRect);
Assert.Equal("1600 × 1000 px", vm.DimensionsText);
});
}

private static void AssertClose(Rect expected, Rect actual)
{
Assert.Equal(expected.X, actual.X, 3);
Assert.Equal(expected.Y, actual.Y, 3);
Assert.Equal(expected.Width, actual.Width, 3);
Assert.Equal(expected.Height, actual.Height, 3);
}

// ───────────────────────── Helpers ─────────────────────────

private static void WithOverlay(string language, bool dark, string[] names, Action<CropOverlayWindow, CropOverlayViewModel, string> act)
Expand Down
Loading