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)