diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index abb52f53..90e50d27 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 d4cd75e4..1d4819ad 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. @@ -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,20 @@ 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 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) => + 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 +267,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/src/Filee.App/Controls/CropMath.cs b/src/Filee.App/Controls/CropMath.cs index 14f06064..b7bca2e4 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 cd2f8d7e..2ab53d48 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 22276d7d..4c8c86a7 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 426d2ad0..7a021537 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 52134aa6..834e61d6 100644 --- a/tests/Filee.App.Tests/CropOverlayTests.cs +++ b/tests/Filee.App.Tests/CropOverlayTests.cs @@ -288,6 +288,46 @@ 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() + { + 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() {