From bebeb18814249d72e97250beb2af3e15614294fc Mon Sep 17 00:00:00 2001
From: cre8ive-marketing <168519979+cre8ive-marketing@users.noreply.github.com>
Date: Sun, 25 Oct 2026 12:35:05 +0530
Subject: [PATCH] Crop: the ratio picked for one picture carries on to the next
(#542)
A batch for Instagram picks 4:5 once: the next picture starts at the same ratio and orientation, as large as it fits and centred, so only a move is left. R (Reset) goes back to Free for this picture and the next; a batch that never picked a ratio still starts each picture at Free.
---
src/Filee.App/Controls/CropCanvas.cs | 5 +-
src/Filee.App/Controls/CropMath.cs | 13 +++++
.../ViewModels/CropOverlayViewModel.cs | 32 ++++++++----
.../Views/CropOverlayWindow.axaml.cs | 2 +-
tests/Filee.App.Tests/CropMathTests.cs | 15 ++++++
tests/Filee.App.Tests/CropOverlayTests.cs | 50 +++++++++++++++++--
6 files changed, 100 insertions(+), 17 deletions(-)
diff --git a/src/Filee.App/Controls/CropCanvas.cs b/src/Filee.App/Controls/CropCanvas.cs
index c51728da5..9b7cfad60 100644
--- a/src/Filee.App/Controls/CropCanvas.cs
+++ b/src/Filee.App/Controls/CropCanvas.cs
@@ -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()
+ /// Starts the frame over: the whole picture, or (a ratio carried from the last picture).
+ 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)
diff --git a/src/Filee.App/Controls/CropMath.cs b/src/Filee.App/Controls/CropMath.cs
index b7bca2e40..2ad1a7212 100644
--- a/src/Filee.App/Controls/CropMath.cs
+++ b/src/Filee.App/Controls/CropMath.cs
@@ -43,6 +43,19 @@ public static string Numbers(string mode, bool portrait)
return $"{parts[1]}:{parts[0]}";
}
+ ///
+ /// The largest frame of (width / height in pixels) on a picture, centred, in parts of the
+ /// picture; the whole picture for 0 (free).
+ ///
+ 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);
+ }
+
/// The frame in pixels of the picture (at least 1 × 1).
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)));
diff --git a/src/Filee.App/ViewModels/CropOverlayViewModel.cs b/src/Filee.App/ViewModels/CropOverlayViewModel.cs
index 2ab53d48d..da9e19931 100644
--- a/src/Filee.App/ViewModels/CropOverlayViewModel.cs
+++ b/src/Filee.App/ViewModels/CropOverlayViewModel.cs
@@ -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;
+
+ /// The frame starts over: the whole picture, or the frame given (a ratio carried from the last picture).
+ public event Action? ResetRequested;
public event Action? AspectRatioChanged;
+ ///
+ /// 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.
+ ///
+ private string? _batchAspect;
+ private bool _batchPortrait;
+
public Rect CurrentCropRect { get; set; } = new(0.05, 0.05, 0.9, 0.9);
public CropOverlayViewModel(IReadOnlyList files, Preset preset, ILocalizer loc, UserDataStore store, ILogger? log = null)
@@ -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)
{
@@ -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);
}
/// Cancel, or Close once finished. A crop still saving finishes and goes into the history.
@@ -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);
diff --git a/src/Filee.App/Views/CropOverlayWindow.axaml.cs b/src/Filee.App/Views/CropOverlayWindow.axaml.cs
index 068c2abe5..4894e304d 100644
--- a/src/Filee.App/Views/CropOverlayWindow.axaml.cs
+++ b/src/Filee.App/Views/CropOverlayWindow.axaml.cs
@@ -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);
diff --git a/tests/Filee.App.Tests/CropMathTests.cs b/tests/Filee.App.Tests/CropMathTests.cs
index 7a021537d..3b5975cc5 100644
--- a/tests/Filee.App.Tests/CropMathTests.cs
+++ b/tests/Filee.App.Tests/CropMathTests.cs
@@ -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()
{
diff --git a/tests/Filee.App.Tests/CropOverlayTests.cs b/tests/Filee.App.Tests/CropOverlayTests.cs
index 834e61d6f..e436a7e4d 100644
--- a/tests/Filee.App.Tests/CropOverlayTests.cs
+++ b/tests/Filee.App.Tests/CropOverlayTests.cs
@@ -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()["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()["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 act)