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
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 15 additions & 6 deletions src/Filee.App/Controls/AspectDial.cs
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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)
{
Expand All @@ -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));
}

/// <summary>
/// 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.
/// </summary>
internal const double MinLabelSize = 8;

/// <summary>As wide as the slice is a little outside its middle; a long word gets a smaller size before it would break.</summary>
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;

/// <summary>A small outline of the ratio over its name; free is a dashed square.</summary>
private void DrawShape(DrawingContext context, IBrush? ink, Point center)
{
Expand All @@ -258,7 +267,7 @@ private void DrawShape(DrawingContext context, IBrush? ink, Point center)
}
}

/// <summary>The dial in miniature (the toolbar button): six slices, the one in use lit.</summary>
/// <summary>The dial in miniature (the toolbar button): a slice per ratio, the one in use lit.</summary>
public sealed class AspectGlyph : Control
{
public static readonly StyledProperty<int> SelectedIndexProperty =
Expand Down
5 changes: 3 additions & 2 deletions src/Filee.App/Controls/CropMath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ public static class CropMath
/// <summary>The aspect dial's choices, clockwise from the top.</summary>
public static IReadOnlyList<string> 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"];

/// <summary>Where the mode sits on the dial, -1 for none.</summary>
public static int AspectIndex(string mode) => Array.IndexOf(Modes, mode);

/// <summary>A fixed ratio with a portrait and a landscape form (not free, the picture's own or square).</summary>
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";

/// <summary>Width / height of the mode, 0 for free. Portrait turns a swappable ratio around (4:3 → 3:4).</summary>
public static double Ratio(string mode, bool portrait, int originalWidth, int originalHeight)
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/Filee.App/ViewModels/CropOverlayViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ public sealed partial class CropOverlayViewModel : ObservableObject, IDisposable
[NotifyPropertyChangedFor(nameof(IsAspectFree))]
private string _selectedAspect = "free";

/// <summary>The fixed ratios (4:3, 3:2, 16:9) turned upright: 3:4, 2:3, 9:16.</summary>
/// <summary>The fixed ratios (5:4, 4:3, 3:2, 16:9) turned upright: 4:5, 3:4, 2:3, 9:16.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(AspectLabel))]
[NotifyPropertyChangedFor(nameof(AspectToolTip))]
Expand Down Expand Up @@ -318,7 +318,7 @@ public void Cancel()
CloseRequested?.Invoke();
}

/// <summary>Locks the frame to a ratio of the dial ("free", "original", "1:1", "4:3", "3:2", "16:9") and closes the dial.</summary>
/// <summary>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.</summary>
[RelayCommand]
public void SetAspect(string mode)
{
Expand Down
1 change: 1 addition & 0 deletions src/Filee.App/Views/CropOverlayWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,7 @@
<c:RingSegment Mode="free" Content="{DynamicResource crop.aspect_free}" Command="{Binding SetAspectCommand}" CommandParameter="free" />
<c:RingSegment Mode="original" Content="{DynamicResource crop.aspect_original}" Command="{Binding SetAspectCommand}" CommandParameter="original" />
<c:RingSegment Mode="1:1" Command="{Binding SetAspectCommand}" CommandParameter="1:1" />
<c:RingSegment Mode="5:4" Command="{Binding SetAspectCommand}" CommandParameter="5:4" />
<c:RingSegment Mode="4:3" Command="{Binding SetAspectCommand}" CommandParameter="4:3" />
<c:RingSegment Mode="3:2" Command="{Binding SetAspectCommand}" CommandParameter="3:2" />
<c:RingSegment Mode="16:9" Command="{Binding SetAspectCommand}" CommandParameter="16:9" />
Expand Down
9 changes: 6 additions & 3 deletions tests/Filee.App.Tests/CropMathTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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]
Expand Down
40 changes: 40 additions & 0 deletions tests/Filee.App.Tests/CropOverlayTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Dictionary<string, string>>(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()
{
Expand Down
Loading