From 311a00878c011e84d946502ec8290aa7a0fdaee2 Mon Sep 17 00:00:00 2001
From: cre8ive-marketing <168519979+cre8ive-marketing@users.noreply.github.com>
Date: Sat, 24 Oct 2026 18:17:23 +0530
Subject: [PATCH 1/3] Crop: a 5:4 slice on the aspect dial, 4:5 on upright
pictures (Instagram)
#542, from #517: Instagram's portrait size is 4:5, which the dial didn't have.
5:4 sits between 1:1 and 4:3 and turns like the other fixed ratios, so an
upright picture gets 4:5 and the hub swaps it. No new strings.
---
docs/ARCHITECTURE.md | 2 +-
src/Filee.App/Controls/AspectDial.cs | 4 ++--
src/Filee.App/Controls/CropMath.cs | 5 +++--
.../ViewModels/CropOverlayViewModel.cs | 4 ++--
src/Filee.App/Views/CropOverlayWindow.axaml | 1 +
tests/Filee.App.Tests/CropMathTests.cs | 9 ++++++---
tests/Filee.App.Tests/CropOverlayTests.cs | 19 +++++++++++++++++++
7 files changed, 34 insertions(+), 10 deletions(-)
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index abb52f53e..90e50d27c 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -165,7 +165,7 @@ text, vectors, outlines, forms, page labels and document information remain PDF
`CropService.LoadPreviewAsync`; nothing is decoded again while dragging. Programmatic changes (reset, a ratio, a
new picture) ease in with tweens read against the clock, so a lost frame never leaves one half done; dragging and
arrow-key nudges follow at once, and `PlayAnimations` (from `Motion.Enabled`) turns all of it off.
-- `Controls/AspectDial` is a small donut of the ratios (free, original, 1:1, 4:3, 3:2, 16:9): each slice a
+- `Controls/AspectDial` is a small donut of the ratios (free, original, 1:1, 5:4, 4:3, 3:2, 16:9): each slice a
`RingSegment` button that answers only inside its wedge (`DonutGeometry.Wedge`, rounded by the softness), the hub
swaps portrait and landscape. A with the pointer over the stage opens it around the pointer, as the donut opens,
growing from its middle and kept inside the window; A without a pointer there (the keyboard) and its toolbar button
diff --git a/src/Filee.App/Controls/AspectDial.cs b/src/Filee.App/Controls/AspectDial.cs
index d4cd75e4b..b7a179707 100644
--- a/src/Filee.App/Controls/AspectDial.cs
+++ b/src/Filee.App/Controls/AspectDial.cs
@@ -1,5 +1,5 @@
-// The crop overlay's aspect dial: a small donut whose slices are the ratios (free, the picture's own, 1:1, 4:3, 3:2,
-// 16:9), each with a tiny outline of its shape, and a hub that swaps portrait and landscape. The slices are real
+// The crop overlay's aspect dial: a small donut whose slices are the ratios (free, the picture's own, 1:1, 5:4, 4:3,
+// 3:2, 16:9), each with a tiny outline of its shape, and a hub that swaps portrait and landscape. The slices are real
// buttons (focus, keyboard, screen readers) that only answer inside their wedge. AspectGlyph is the dial in miniature
// on the toolbar button that opens it.
diff --git a/src/Filee.App/Controls/CropMath.cs b/src/Filee.App/Controls/CropMath.cs
index 14f060644..b7bca2e40 100644
--- a/src/Filee.App/Controls/CropMath.cs
+++ b/src/Filee.App/Controls/CropMath.cs
@@ -10,13 +10,13 @@ public static class CropMath
/// The aspect dial's choices, clockwise from the top.
public static IReadOnlyList AspectModes => Modes;
- private static readonly string[] Modes = ["free", "original", "1:1", "4:3", "3:2", "16:9"];
+ private static readonly string[] Modes = ["free", "original", "1:1", "5:4", "4:3", "3:2", "16:9"];
/// Where the mode sits on the dial, -1 for none.
public static int AspectIndex(string mode) => Array.IndexOf(Modes, mode);
/// A fixed ratio with a portrait and a landscape form (not free, the picture's own or square).
- public static bool CanSwap(string mode) => mode is "4:3" or "3:2" or "16:9";
+ public static bool CanSwap(string mode) => mode is "5:4" or "4:3" or "3:2" or "16:9";
/// Width / height of the mode, 0 for free. Portrait turns a swappable ratio around (4:3 → 3:4).
public static double Ratio(string mode, bool portrait, int originalWidth, int originalHeight)
@@ -25,6 +25,7 @@ public static double Ratio(string mode, bool portrait, int originalWidth, int or
{
"original" => originalWidth > 0 && originalHeight > 0 ? (double)originalWidth / originalHeight : 0,
"1:1" => 1.0,
+ "5:4" => 5.0 / 4.0, // upright: 4:5, Instagram's portrait size (#542)
"4:3" => 4.0 / 3.0,
"3:2" => 3.0 / 2.0,
"16:9" => 16.0 / 9.0,
diff --git a/src/Filee.App/ViewModels/CropOverlayViewModel.cs b/src/Filee.App/ViewModels/CropOverlayViewModel.cs
index cd2f8d7e2..2ab53d48d 100644
--- a/src/Filee.App/ViewModels/CropOverlayViewModel.cs
+++ b/src/Filee.App/ViewModels/CropOverlayViewModel.cs
@@ -62,7 +62,7 @@ public sealed partial class CropOverlayViewModel : ObservableObject, IDisposable
[NotifyPropertyChangedFor(nameof(IsAspectFree))]
private string _selectedAspect = "free";
- /// The fixed ratios (4:3, 3:2, 16:9) turned upright: 3:4, 2:3, 9:16.
+ /// The fixed ratios (5:4, 4:3, 3:2, 16:9) turned upright: 4:5, 3:4, 2:3, 9:16.
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(AspectLabel))]
[NotifyPropertyChangedFor(nameof(AspectToolTip))]
@@ -318,7 +318,7 @@ public void Cancel()
CloseRequested?.Invoke();
}
- /// Locks the frame to a ratio of the dial ("free", "original", "1:1", "4:3", "3:2", "16:9") and closes the dial.
+ /// Locks the frame to a ratio of the dial ("free", "original", "1:1", "5:4", "4:3", "3:2", "16:9") and closes the dial.
[RelayCommand]
public void SetAspect(string mode)
{
diff --git a/src/Filee.App/Views/CropOverlayWindow.axaml b/src/Filee.App/Views/CropOverlayWindow.axaml
index 22276d7d2..4c8c86a7b 100644
--- a/src/Filee.App/Views/CropOverlayWindow.axaml
+++ b/src/Filee.App/Views/CropOverlayWindow.axaml
@@ -407,6 +407,7 @@
+
diff --git a/tests/Filee.App.Tests/CropMathTests.cs b/tests/Filee.App.Tests/CropMathTests.cs
index 426d2ad0f..7a021537d 100644
--- a/tests/Filee.App.Tests/CropMathTests.cs
+++ b/tests/Filee.App.Tests/CropMathTests.cs
@@ -11,6 +11,8 @@ public class CropMathTests
[Theory]
[InlineData("free", false, 0.0)]
[InlineData("1:1", true, 1.0)]
+ [InlineData("5:4", false, 5.0 / 4.0)]
+ [InlineData("5:4", true, 4.0 / 5.0)] // Instagram's portrait size
[InlineData("4:3", false, 4.0 / 3.0)]
[InlineData("4:3", true, 3.0 / 4.0)]
[InlineData("3:2", true, 2.0 / 3.0)]
@@ -43,13 +45,14 @@ public void A_frame_as_wide_as_the_picture_never_counts_as_snapped()
[Fact]
public void Only_fixed_ratios_that_arent_square_swap()
{
- Assert.Equal(["4:3", "3:2", "16:9"], CropMath.AspectModes.Where(CropMath.CanSwap));
+ Assert.Equal(["5:4", "4:3", "3:2", "16:9"], CropMath.AspectModes.Where(CropMath.CanSwap));
+ Assert.Equal("4:5", CropMath.Numbers("5:4", portrait: true));
Assert.Equal("9:16", CropMath.Numbers("16:9", portrait: true));
Assert.Equal("16:9", CropMath.Numbers("16:9", portrait: false));
Assert.Equal("1:1", CropMath.Numbers("1:1", portrait: true));
Assert.Equal(0, CropMath.Ratio("original", false, 0, 0));
- Assert.Equal(3, CropMath.AspectIndex("4:3"));
- Assert.Equal(-1, CropMath.AspectIndex("5:4"));
+ Assert.Equal(4, CropMath.AspectIndex("4:3"));
+ Assert.Equal(-1, CropMath.AspectIndex("21:9"));
}
[Fact]
diff --git a/tests/Filee.App.Tests/CropOverlayTests.cs b/tests/Filee.App.Tests/CropOverlayTests.cs
index 52134aa6a..31208d7f3 100644
--- a/tests/Filee.App.Tests/CropOverlayTests.cs
+++ b/tests/Filee.App.Tests/CropOverlayTests.cs
@@ -288,6 +288,25 @@ public void Fixed_ratios_follow_the_pictures_orientation_and_swap()
});
}
+ [AvaloniaFact]
+ public void An_upright_picture_crops_at_instagrams_4_to_5_from_the_5_to_4_slice()
+ {
+ WithOverlay("en", false, ["tall.jpg"], (window, vm, _suffix) =>
+ {
+ Settle(window);
+ var slice = window.AspectDial.Segments.Single(s => s.Mode == "5:4");
+ slice.Command!.Execute(slice.CommandParameter);
+ Settle(window);
+ Assert.Equal("4:5", vm.AspectLabel);
+ Assert.Equal(0.8, window.CropCanvas.AspectRatio, 6);
+ var frame = window.CropCanvas.NormalizedCropRect;
+ Assert.Equal(0.8, frame.Width * 1000 / (frame.Height * 1600), 2);
+ vm.ToggleAspectDial();
+ Settle(window);
+ RenderTests.Save(window, "crop-dial-4-5.png");
+ });
+ }
+
[AvaloniaFact]
public void The_next_file_starts_free_and_full_in_view_and_canvas_alike()
{
From 53f27602cb244127ec2ae35c1e7e6351da0ba1bf Mon Sep 17 00:00:00 2001
From: cre8ive-marketing <168519979+cre8ive-marketing@users.noreply.github.com>
Date: Sun, 25 Oct 2026 11:20:56 +0530
Subject: [PATCH 2/3] Crop dial: long one-word ratio names fit a seventh slice
again
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Russian's "Произвольные" needs 9.4 px with seven slices, so the floor goes
from 9.5 to 9. A test checks every language's free and original names
against the slice width. The AspectGlyph summary no longer counts the slices.
---
src/Filee.App/Controls/AspectDial.cs | 14 ++++++++++----
tests/Filee.App.Tests/CropOverlayTests.cs | 21 +++++++++++++++++++++
2 files changed, 31 insertions(+), 4 deletions(-)
diff --git a/src/Filee.App/Controls/AspectDial.cs b/src/Filee.App/Controls/AspectDial.cs
index b7a179707..da5c0558d 100644
--- a/src/Filee.App/Controls/AspectDial.cs
+++ b/src/Filee.App/Controls/AspectDial.cs
@@ -220,8 +220,7 @@ public override void Render(DrawingContext context)
DrawShape(context, ink, new Point(labelCenter.X, labelCenter.Y - 9));
var typeface = new Typeface(GetValue(TextElement.FontFamilyProperty), FontStyle.Normal, FontWeight.SemiBold);
- // As wide as the slice is a little outside its middle; a long word gets a smaller size before it would break.
- var width = 2 * ((_inner + _outer) / 2 + 6) * Math.Sin(Math.Min(Math.PI / 2, DonutGeometry.Span(_count) / 2)) - 12;
+ var width = LabelWidth(_inner, _outer, _count);
var direction = CultureInfo.CurrentUICulture.TextInfo.IsRightToLeft ? FlowDirection.RightToLeft : FlowDirection.LeftToRight;
FormattedText Text(double size) => new(Label, CultureInfo.CurrentUICulture, direction, typeface, size, ink)
{
@@ -233,10 +232,17 @@ public override void Render(DrawingContext context)
var text = Text(12);
var longest = Label.Split(' ').Max(word => new FormattedText(word, CultureInfo.CurrentUICulture, direction, typeface, 12, ink).Width);
if (longest > width)
- text = Text(Math.Max(9.5, 12 * width / longest));
+ text = Text(Math.Max(MinLabelSize, 12 * width / longest));
context.DrawText(text, new Point(labelCenter.X - width / 2, labelCenter.Y + 2));
}
+ /// The smallest size a long one-word label shrinks to before it breaks: ru's "Произвольные" needs 9.4 with seven slices.
+ internal const double MinLabelSize = 9;
+
+ /// As wide as the slice is a little outside its middle; a long word gets a smaller size before it would break.
+ internal static double LabelWidth(double inner, double outer, int count) =>
+ 2 * ((inner + outer) / 2 + 6) * Math.Sin(Math.Min(Math.PI / 2, DonutGeometry.Span(count) / 2)) - 12;
+
/// A small outline of the ratio over its name; free is a dashed square.
private void DrawShape(DrawingContext context, IBrush? ink, Point center)
{
@@ -258,7 +264,7 @@ private void DrawShape(DrawingContext context, IBrush? ink, Point center)
}
}
-/// The dial in miniature (the toolbar button): six slices, the one in use lit.
+/// The dial in miniature (the toolbar button): a slice per ratio, the one in use lit.
public sealed class AspectGlyph : Control
{
public static readonly StyledProperty SelectedIndexProperty =
diff --git a/tests/Filee.App.Tests/CropOverlayTests.cs b/tests/Filee.App.Tests/CropOverlayTests.cs
index 31208d7f3..834e61d6f 100644
--- a/tests/Filee.App.Tests/CropOverlayTests.cs
+++ b/tests/Filee.App.Tests/CropOverlayTests.cs
@@ -288,6 +288,27 @@ public void Fixed_ratios_follow_the_pictures_orientation_and_swap()
});
}
+ [AvaloniaFact]
+ public void Every_language_fits_its_longest_one_word_ratio_name_on_a_slice_without_breaking_it()
+ {
+ var dial = new AspectDial();
+ var width = RingSegment.LabelWidth(dial.InnerRadius, dial.OuterRadius, CropMath.AspectModes.Count);
+ var i18n = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "src", "Filee.App", "Assets", "i18n");
+ foreach (var language in LocalizationService.Languages.Select(l => l.Code))
+ {
+ var strings = System.Text.Json.JsonSerializer.Deserialize>(File.ReadAllText(Path.Combine(i18n, language + ".json")))!;
+ var typeface = new Avalonia.Media.Typeface(SoftPalette.FontFamilyFor(language), Avalonia.Media.FontStyle.Normal, Avalonia.Media.FontWeight.SemiBold);
+ foreach (var key in new[] { "crop.aspect_free", "crop.aspect_original" })
+ {
+ if (!strings.TryGetValue(key, out var label))
+ continue;
+ var longest = label.Split(' ').Max(word => new Avalonia.Media.FormattedText(word, System.Globalization.CultureInfo.InvariantCulture,
+ Avalonia.Media.FlowDirection.LeftToRight, typeface, 12, Avalonia.Media.Brushes.Black).Width);
+ Assert.True(12 * width / longest >= RingSegment.MinLabelSize, $"{language} {key} \"{label}\" needs {12 * width / longest:F2}");
+ }
+ }
+ }
+
[AvaloniaFact]
public void An_upright_picture_crops_at_instagrams_4_to_5_from_the_5_to_4_slice()
{
From 2d3becb59e2211911eadb2041e0157a980f8870f Mon Sep 17 00:00:00 2001
From: cre8ive-marketing <168519979+cre8ive-marketing@users.noreply.github.com>
Date: Sun, 25 Oct 2026 12:38:46 +0530
Subject: [PATCH 3/3] Crop dial: let long one-word ratio names shrink to 8 px
Russian's label needs 8.7 px on CI's runners (9.4 on a desktop): the fonts differ, so the floor leaves room below both.
---
src/Filee.App/Controls/AspectDial.cs | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/src/Filee.App/Controls/AspectDial.cs b/src/Filee.App/Controls/AspectDial.cs
index da5c0558d..1d4819ad0 100644
--- a/src/Filee.App/Controls/AspectDial.cs
+++ b/src/Filee.App/Controls/AspectDial.cs
@@ -236,8 +236,11 @@ public override void Render(DrawingContext context)
context.DrawText(text, new Point(labelCenter.X - width / 2, labelCenter.Y + 2));
}
- /// The smallest size a long one-word label shrinks to before it breaks: ru's "Произвольные" needs 9.4 with seven slices.
- internal const double MinLabelSize = 9;
+ ///
+ /// The smallest size a long one-word label shrinks to before it breaks. Ru's "Произвольные" needs 9.4 with seven
+ /// slices on a desktop and 8.7 on CI's runners, whose fonts differ, so there's room below that.
+ ///
+ internal const double MinLabelSize = 8;
/// As wide as the slice is a little outside its middle; a long word gets a smaller size before it would break.
internal static double LabelWidth(double inner, double outer, int count) =>