From 0c5fced29beee4c11611db2571be4b33fa16b86e Mon Sep 17 00:00:00 2001 From: cre8ive-marketing <168519979+cre8ive-marketing@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:20:42 +0530 Subject: [PATCH] Crop: a custom W:H slice on the aspect dial (#542) An eighth slice, after 16:9: a ratio typed as W and H in two boxes beside the ratio button, while it's the one in use (4 x 5 or 1080 x 1350; only the ratio counts). The slice and the button show it as small as it goes (1080 x 1350 is 4:5). It is taken as typed whatever way the picture stands, the hub turns it, and it's kept for the batch like the other ratios, while the crop window is open. Typing a new number starts the frame over as large as the ratio fits. Arrow keys and letters typed in the boxes are the boxes', not the window's shortcuts. The dial is wider (outer radius 132) so the eight slices' names still fit: the label test checks every language on eight slices, and the dial screenshots now include pl, ne and de. Strings in all 17 languages. --- src/Filee.App/Assets/i18n/ar.json | 3 + src/Filee.App/Assets/i18n/de.json | 3 + src/Filee.App/Assets/i18n/en.json | 3 + src/Filee.App/Assets/i18n/es.json | 3 + src/Filee.App/Assets/i18n/fr.json | 3 + src/Filee.App/Assets/i18n/hi.json | 3 + src/Filee.App/Assets/i18n/ja.json | 3 + src/Filee.App/Assets/i18n/ko.json | 3 + src/Filee.App/Assets/i18n/ne.json | 3 + src/Filee.App/Assets/i18n/pl.json | 3 + src/Filee.App/Assets/i18n/pt-BR.json | 3 + src/Filee.App/Assets/i18n/pt-PT.json | 3 + src/Filee.App/Assets/i18n/ru.json | 3 + src/Filee.App/Assets/i18n/th.json | 3 + src/Filee.App/Assets/i18n/tr.json | 3 + src/Filee.App/Assets/i18n/zh-CN.json | 3 + src/Filee.App/Assets/i18n/zh-TW.json | 3 + src/Filee.App/Controls/AspectDial.cs | 21 ++++-- src/Filee.App/Controls/CropMath.cs | 36 +++++++--- .../ViewModels/CropOverlayViewModel.cs | 50 +++++++++++++- src/Filee.App/Views/CropOverlayWindow.axaml | 17 ++++- .../Views/CropOverlayWindow.axaml.cs | 7 +- tests/Filee.App.Tests/CropMathTests.cs | 16 ++++- tests/Filee.App.Tests/CropOverlayTests.cs | 68 +++++++++++++++++++ 24 files changed, 245 insertions(+), 21 deletions(-) diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 3de1151e..9e35eec7 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -210,6 +210,9 @@ "crop.aspect_title": "نسبة الأبعاد:", "crop.aspect_free": "حرة", "crop.aspect_original": "الأصلية", + "crop.aspect_custom": "نسبة مخصّصة (اكتب العرض والارتفاع بجانب زر النسبة)", + "crop.custom_width": "العرض", + "crop.custom_height": "الارتفاع", "crop.page_counter": "{0} من {1}", "crop.save_next": "اقتصاص والتالي", "crop.swap": "تبديل الوضع الطولي والعرضي (X)", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index fc742a82..d669c412 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -210,6 +210,9 @@ "crop.aspect_title": "Seitenverhältnis:", "crop.aspect_free": "Frei", "crop.aspect_original": "Original", + "crop.aspect_custom": "Eigenes Verhältnis (B und H neben der Verhältnis-Taste eingeben)", + "crop.custom_width": "Breite (B)", + "crop.custom_height": "Höhe (H)", "crop.page_counter": "{0} von {1}", "crop.save_next": "Zuschneiden & Weiter", "crop.swap": "Hoch- und Querformat tauschen (X)", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index 75d0ee92..c412965d 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -219,6 +219,9 @@ "crop.aspect_title": "Aspect:", "crop.aspect_free": "Free", "crop.aspect_original": "Original", + "crop.aspect_custom": "Custom ratio (type W and H beside the ratio button)", + "crop.custom_width": "Width (W)", + "crop.custom_height": "Height (H)", "crop.page_counter": "{0} of {1}", "crop.save_next": "Crop & Next", "crop.swap": "Swap portrait and landscape (X)", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index c074a17f..fcf60fc1 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -210,6 +210,9 @@ "crop.aspect_title": "Proporción:", "crop.aspect_free": "Libre", "crop.aspect_original": "Original", + "crop.aspect_custom": "Proporción personalizada (escribe An y Al junto al botón de proporción)", + "crop.custom_width": "Ancho (An)", + "crop.custom_height": "Alto (Al)", "crop.page_counter": "{0} de {1}", "crop.save_next": "Recortar y seguir", "crop.swap": "Cambiar entre vertical y horizontal (X)", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index 68101164..f8e50731 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -219,6 +219,9 @@ "crop.aspect_title": "Proportions :", "crop.aspect_free": "Libre", "crop.aspect_original": "Original", + "crop.aspect_custom": "Rapport personnalisé (saisissez L et H à côté du bouton du rapport)", + "crop.custom_width": "Largeur (L)", + "crop.custom_height": "Hauteur (H)", "crop.page_counter": "{0} sur {1}", "crop.save_next": "Recadrer et suivant", "crop.swap": "Inverser portrait et paysage (X)", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index 957153f4..dd79a945 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -210,6 +210,9 @@ "crop.aspect_title": "अनुपात:", "crop.aspect_free": "मुक्त", "crop.aspect_original": "मूल", + "crop.aspect_custom": "अपना अनुपात (अनुपात बटन के पास चौड़ाई और ऊँचाई लिखें)", + "crop.custom_width": "चौड़ाई", + "crop.custom_height": "ऊँचाई", "crop.page_counter": "{1} में से {0}", "crop.save_next": "क्रॉप करके अगला", "crop.swap": "खड़ा और आड़ा बदलें (X)", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 5dcc03f9..aa001ac5 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -210,6 +210,9 @@ "crop.aspect_title": "縦横比:", "crop.aspect_free": "自由", "crop.aspect_original": "元の比率", + "crop.aspect_custom": "カスタム比率 (比率ボタンの横に幅と高さを入力)", + "crop.custom_width": "幅", + "crop.custom_height": "高さ", "crop.page_counter": "{0}/{1}", "crop.save_next": "トリミングして次へ", "crop.swap": "縦向きと横向きを切り替え (X)", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 5e899b54..b4a5bc01 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -219,6 +219,9 @@ "crop.aspect_title": "비율:", "crop.aspect_free": "자유", "crop.aspect_original": "원본", + "crop.aspect_custom": "직접 입력 비율 (비율 버튼 옆에 너비와 높이를 입력)", + "crop.custom_width": "너비", + "crop.custom_height": "높이", "crop.page_counter": "{0}/{1}", "crop.save_next": "자르고 다음으로", "crop.swap": "세로/가로 바꾸기 (X)", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index a9a85890..b79c62ea 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -210,6 +210,9 @@ "crop.aspect_title": "अनुपात:", "crop.aspect_free": "स्वतन्त्र", "crop.aspect_original": "मूल", + "crop.aspect_custom": "आफ्नै अनुपात (अनुपात बटनको छेउमा चौडाइ र उचाइ लेख्नुहोस्)", + "crop.custom_width": "चौडाइ", + "crop.custom_height": "उचाइ", "crop.page_counter": "{1} मध्ये {0}", "crop.save_next": "क्रप गरी अर्कोमा जानुहोस्", "crop.swap": "ठाडो र तेर्सो बदल्नुहोस् (X)", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 72f4aa02..ac5afdd3 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -219,6 +219,9 @@ "crop.aspect_title": "Proporcje:", "crop.aspect_free": "Dowolne", "crop.aspect_original": "Oryginalne", + "crop.aspect_custom": "Własne proporcje (wpisz S i W obok przycisku proporcji)", + "crop.custom_width": "Szerokość (S)", + "crop.custom_height": "Wysokość (W)", "crop.page_counter": "{0} z {1}", "crop.save_next": "Kadruj i dalej", "crop.swap": "Zamień pion i poziom (X)", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 75099813..c3c162f3 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -220,6 +220,9 @@ "crop.aspect_title": "Proporção:", "crop.aspect_free": "Livre", "crop.aspect_original": "Original", + "crop.aspect_custom": "Proporção personalizada (digite L e A ao lado do botão de proporção)", + "crop.custom_width": "Largura (L)", + "crop.custom_height": "Altura (A)", "crop.page_counter": "{0} de {1}", "crop.save_next": "Recortar e avançar", "crop.swap": "Trocar retrato e paisagem (X)", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index e6ca7c15..f21ce773 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -219,6 +219,9 @@ "crop.aspect_title": "Proporção:", "crop.aspect_free": "Livre", "crop.aspect_original": "Original", + "crop.aspect_custom": "Proporção personalizada (escreva L e A ao lado do botão de proporção)", + "crop.custom_width": "Largura (L)", + "crop.custom_height": "Altura (A)", "crop.page_counter": "{0} de {1}", "crop.save_next": "Recortar e avançar", "crop.swap": "Alternar entre vertical e horizontal (X)", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 29acdd1e..84fae82b 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -219,6 +219,9 @@ "crop.aspect_title": "Пропорции:", "crop.aspect_free": "Произвольные", "crop.aspect_original": "Исходные", + "crop.aspect_custom": "Своё соотношение (введите Ш и В рядом с кнопкой соотношения)", + "crop.custom_width": "Ширина (Ш)", + "crop.custom_height": "Высота (В)", "crop.page_counter": "{0} из {1}", "crop.save_next": "Обрезать и дальше", "crop.swap": "Поменять книжную и альбомную (X)", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 8c419e6b..cc88d14f 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -210,6 +210,9 @@ "crop.aspect_title": "สัดส่วน:", "crop.aspect_free": "อิสระ", "crop.aspect_original": "ต้นฉบับ", + "crop.aspect_custom": "อัตราส่วนกำหนดเอง (พิมพ์กว้างและสูงข้างปุ่มอัตราส่วน)", + "crop.custom_width": "กว้าง", + "crop.custom_height": "สูง", "crop.page_counter": "{0} จาก {1}", "crop.save_next": "ครอบตัดและต่อไป", "crop.swap": "สลับแนวตั้งและแนวนอน (X)", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 62a8e5e1..99fa7e39 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -210,6 +210,9 @@ "crop.aspect_title": "Oran:", "crop.aspect_free": "Serbest", "crop.aspect_original": "Özgün", + "crop.aspect_custom": "Özel oran (oran düğmesinin yanına G ve Y yazın)", + "crop.custom_width": "Genişlik (G)", + "crop.custom_height": "Yükseklik (Y)", "crop.page_counter": "{0} / {1}", "crop.save_next": "Kırp ve ilerle", "crop.swap": "Dikey ve yatayı değiştir (X)", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 340de83b..75a970e0 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -219,6 +219,9 @@ "crop.aspect_title": "比例:", "crop.aspect_free": "自由", "crop.aspect_original": "原始比例", + "crop.aspect_custom": "自定义比例(在比例按钮旁输入宽和高)", + "crop.custom_width": "宽", + "crop.custom_height": "高", "crop.page_counter": "{0}/{1}", "crop.save_next": "裁剪并下一张", "crop.swap": "切换纵向与横向 (X)", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index ee508a74..6e66cf97 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -219,6 +219,9 @@ "crop.aspect_title": "比例:", "crop.aspect_free": "自由", "crop.aspect_original": "原始比例", + "crop.aspect_custom": "自訂比例(在比例按鈕旁輸入寬和高)", + "crop.custom_width": "寬", + "crop.custom_height": "高", "crop.page_counter": "{0}/{1}", "crop.save_next": "裁剪並下一張", "crop.swap": "切換縱向與橫向 (X)", diff --git a/src/Filee.App/Controls/AspectDial.cs b/src/Filee.App/Controls/AspectDial.cs index 1d4819ad..acb5142e 100644 --- a/src/Filee.App/Controls/AspectDial.cs +++ b/src/Filee.App/Controls/AspectDial.cs @@ -17,7 +17,7 @@ namespace Filee.App.Controls; public sealed class AspectDial : Panel { public static readonly StyledProperty OuterRadiusProperty = - AvaloniaProperty.Register(nameof(OuterRadius), 118); + AvaloniaProperty.Register(nameof(OuterRadius), 132); // room for eight slices' names (#542) public static readonly StyledProperty InnerRadiusProperty = AvaloniaProperty.Register(nameof(InnerRadius), 52); @@ -36,12 +36,17 @@ public sealed class AspectDial : Panel public static readonly StyledProperty OriginalRatioProperty = AvaloniaProperty.Register(nameof(OriginalRatio)); + /// Width / height typed for the custom slice (#542): its outline. + public static readonly StyledProperty CustomRatioProperty = + AvaloniaProperty.Register(nameof(CustomRatio)); + public double OuterRadius { get => GetValue(OuterRadiusProperty); set => SetValue(OuterRadiusProperty, value); } public double InnerRadius { get => GetValue(InnerRadiusProperty); set => SetValue(InnerRadiusProperty, value); } public CornerRadius CornerRadius { get => GetValue(CornerRadiusProperty); set => SetValue(CornerRadiusProperty, value); } public string? SelectedMode { get => GetValue(SelectedModeProperty); set => SetValue(SelectedModeProperty, value); } public bool IsPortrait { get => GetValue(IsPortraitProperty); set => SetValue(IsPortraitProperty, value); } public double OriginalRatio { get => GetValue(OriginalRatioProperty); set => SetValue(OriginalRatioProperty, value); } + public double CustomRatio { get => GetValue(CustomRatioProperty); set => SetValue(CustomRatioProperty, value); } /// Gap between slices, as on the donut. public const double Gap = 4; @@ -49,7 +54,7 @@ public sealed class AspectDial : Panel static AspectDial() { AffectsArrange(OuterRadiusProperty, InnerRadiusProperty, CornerRadiusProperty, SelectedModeProperty, - IsPortraitProperty, OriginalRatioProperty); + IsPortraitProperty, OriginalRatioProperty, CustomRatioProperty); AffectsMeasure(OuterRadiusProperty); } @@ -93,7 +98,7 @@ protected override Size ArrangeOverride(Size finalSize) { var segment = segments[i]; segment.Place(center, i, segments.Count, InnerRadius, OuterRadius, Math.Clamp(CornerRadius.TopLeft, 0, 28) * 0.5); - segment.Show(SelectedMode == segment.Mode, IsPortrait, OriginalRatio); + segment.Show(SelectedMode == segment.Mode, IsPortrait, OriginalRatio, CustomRatio); segment.Arrange(new Rect(finalSize)); } @@ -141,6 +146,7 @@ public sealed class RingSegment : Button, ICustomHitTest private Geometry? _wedge; private bool _portrait; private double _originalRatio; + private double _customRatio; /// Whether this slice's ratio is the one in use. public bool IsSelected { get; private set; } @@ -170,12 +176,13 @@ internal void Place(Point center, int index, int count, double inner, double out InvalidateVisual(); } - internal void Show(bool selected, bool portrait, double originalRatio) + internal void Show(bool selected, bool portrait, double originalRatio, double customRatio = 0) { var label = Label; IsSelected = selected; _portrait = portrait; _originalRatio = originalRatio; + _customRatio = customRatio; PseudoClasses.Set(":selected", selected); if (label != Label || AutomationProperties.GetName(this) is null or "") AutomationProperties.SetName(this, Label); @@ -237,8 +244,8 @@ public override void Render(DrawingContext context) } /// - /// 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. + /// The smallest size a long one-word label shrinks to before it breaks. The dial draws in the embedded Noto Sans, where + /// Ru's "Произвольные" needs about 8.7 on seven slices; with eight, the dial is wider (OuterRadius) so it still fits. /// internal const double MinLabelSize = 8; @@ -251,7 +258,7 @@ private void DrawShape(DrawingContext context, IBrush? ink, Point center) { if (ink is null) return; - var ratio = Mode == "original" ? _originalRatio : CropMath.Ratio(Mode, _portrait, 0, 0); + var ratio = Mode == "original" ? _originalRatio : CropMath.Ratio(Mode, _portrait, 0, 0, _customRatio); const double box = 15; double w = box, h = box; if (ratio > 0) diff --git a/src/Filee.App/Controls/CropMath.cs b/src/Filee.App/Controls/CropMath.cs index 2ad1a721..55252f03 100644 --- a/src/Filee.App/Controls/CropMath.cs +++ b/src/Filee.App/Controls/CropMath.cs @@ -10,16 +10,22 @@ 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", "5:4", "4:3", "3:2", "16:9"]; + private static readonly string[] Modes = ["free", "original", "1:1", "5:4", "4:3", "3:2", "16:9", "custom"]; /// 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 "5:4" or "4:3" or "3:2" or "16:9"; + /// + /// A ratio with a portrait and a landscape form (not free, the picture's own or square). "custom" turns too: its + /// W × H as typed, or H × W. + /// + public static bool CanSwap(string mode) => mode is "5:4" or "4:3" or "3:2" or "16:9" or "custom"; - /// 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) + /// + /// Width / height of the mode, 0 for free. Portrait turns a swappable ratio around (4:3 → 3:4). + /// is the typed W / H of "custom" (#542). + /// + public static double Ratio(string mode, bool portrait, int originalWidth, int originalHeight, double custom = 0) { var ratio = mode switch { @@ -29,15 +35,29 @@ public static double Ratio(string mode, bool portrait, int originalWidth, int or "4:3" => 4.0 / 3.0, "3:2" => 3.0 / 2.0, "16:9" => 16.0 / 9.0, + "custom" => custom, _ => 0, }; - return portrait && CanSwap(mode) ? 1 / ratio : ratio; + return portrait && CanSwap(mode) && ratio > 0 ? 1 / ratio : ratio; + } + + /// + /// The custom ratio's numbers as small as they go ("1080 × 1350" → "4:5"), turned around in portrait. Only the ratio + /// counts: resizing to exact pixels is a preset's job. + /// + public static string CustomNumbers(int width, int height, bool portrait) + { + var divisor = Gcd(Math.Max(1, width), Math.Max(1, height)); + var (w, h) = (Math.Max(1, width) / divisor, Math.Max(1, height) / divisor); + return portrait ? $"{h}:{w}" : $"{w}:{h}"; + + static int Gcd(int a, int b) => b == 0 ? a : Gcd(b, a % b); } - /// The numbers shown for a mode: "4:3", or "3:4" in portrait. Free and original have none. + /// The numbers shown for a mode: "4:3", or "3:4" in portrait. Free, original and custom have none here. public static string Numbers(string mode, bool portrait) { - if (!CanSwap(mode) || !portrait) + if (!CanSwap(mode) || !portrait || mode == "custom") return mode; var parts = mode.Split(':'); return $"{parts[1]}:{parts[0]}"; diff --git a/src/Filee.App/ViewModels/CropOverlayViewModel.cs b/src/Filee.App/ViewModels/CropOverlayViewModel.cs index da9e1993..20a0c8c6 100644 --- a/src/Filee.App/ViewModels/CropOverlayViewModel.cs +++ b/src/Filee.App/ViewModels/CropOverlayViewModel.cs @@ -60,15 +60,52 @@ public sealed partial class CropOverlayViewModel : ObservableObject, IDisposable [NotifyPropertyChangedFor(nameof(SelectedAspectIndex))] [NotifyPropertyChangedFor(nameof(CanSwapOrientation))] [NotifyPropertyChangedFor(nameof(IsAspectFree))] + [NotifyPropertyChangedFor(nameof(IsCustomAspect))] private string _selectedAspect = "free"; - /// The fixed ratios (5:4, 4:3, 3:2, 16:9) turned upright: 4:5, 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; the custom one as H × W. [ObservableProperty] [NotifyPropertyChangedFor(nameof(AspectLabel))] [NotifyPropertyChangedFor(nameof(AspectToolTip))] [NotifyPropertyChangedFor(nameof(OrientationText))] + [NotifyPropertyChangedFor(nameof(CustomLabel))] private bool _isPortrait; + /// + /// The custom ratio's W and H (#542), typed beside the ratio button: 4 × 5 or 1080 × 1350, only the ratio counts. + /// Kept while the crop window is open, for the whole batch; no setting. + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CustomRatio), nameof(CustomLabel), nameof(AspectLabel), nameof(AspectToolTip))] + private int _customWidth = 2; // a ratio no other slice has, so the slice reads as its own + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CustomRatio), nameof(CustomLabel), nameof(AspectLabel), nameof(AspectToolTip))] + private int _customHeight = 1; + + public double CustomRatio => (double)Math.Max(1, CustomWidth) / Math.Max(1, CustomHeight); + + /// The custom slice's name: its ratio as small as it goes ("4:5"), as the other ratio slices show theirs. + public string CustomLabel => CropMath.CustomNumbers(CustomWidth, CustomHeight, IsPortrait && IsCustomAspect); + + public bool IsCustomAspect => SelectedAspect == "custom"; + + partial void OnCustomWidthChanged(int value) => CustomChanged(); + + partial void OnCustomHeightChanged(int value) => CustomChanged(); + + private void CustomChanged() + { + if (!IsCustomAspect) + return; + ApplyAspect("custom"); + // Typed a digit at a time, the ratio passes through odd shapes (1080 × 5): the frame starts over as large as + // the new ratio fits, rather than shrinking along with each of them. + var frame = CropMath.Centered(AspectRatio, OriginalWidth, OriginalHeight); + UpdateDimensions(frame); + ResetRequested?.Invoke(frame); + } + [ObservableProperty] private bool _isAspectDialOpen; [ObservableProperty] @@ -126,6 +163,7 @@ public sealed partial class CropOverlayViewModel : ObservableObject, IDisposable { "free" => _loc["crop.aspect_free"], "original" => _loc["crop.aspect_original"], + "custom" => CustomLabel, _ => CropMath.Numbers(SelectedAspect, IsPortrait), }; @@ -201,7 +239,7 @@ public async Task LoadFileAsync(int index) SelectedAspect = mode; IsPortrait = CropMath.CanSwap(mode) ? _batchPortrait : origH > origW; IsAspectDialOpen = false; - AspectRatio = CropMath.Ratio(mode, IsPortrait, origW, origH); + AspectRatio = CropMath.Ratio(mode, IsPortrait, origW, origH, CustomRatio); AspectRatioChanged?.Invoke(AspectRatio); var frame = CropMath.Centered(AspectRatio, origW, origH); UpdateDimensions(frame); @@ -336,6 +374,9 @@ public void Cancel() public void SetAspect(string mode) { IsAspectDialOpen = false; + // The custom ratio is W × H as typed, whatever way the picture stands; the hub turns it. + if (mode == "custom" && !IsCustomAspect) + IsPortrait = false; ApplyAspect(mode); } @@ -354,6 +395,8 @@ public void CycleAspect(int step) { var modes = CropMath.AspectModes; var next = ((SelectedAspectIndex + step) % modes.Count + modes.Count) % modes.Count; + if (modes[next] == "custom") + IsPortrait = false; // as typed, see SetAspect ApplyAspect(modes[next]); } @@ -368,8 +411,9 @@ private void ApplyAspect(string mode) { (_batchAspect, _batchPortrait) = (mode == "free" ? null : mode, IsPortrait); SelectedAspect = mode; - AspectRatio = CropMath.Ratio(mode, IsPortrait, OriginalWidth, OriginalHeight); + AspectRatio = CropMath.Ratio(mode, IsPortrait, OriginalWidth, OriginalHeight, CustomRatio); AspectRatioChanged?.Invoke(AspectRatio); + OnPropertyChanged(nameof(CustomLabel)); } private void AddFailure(string path, string errorKey, string? detail) diff --git a/src/Filee.App/Views/CropOverlayWindow.axaml b/src/Filee.App/Views/CropOverlayWindow.axaml index 4c8c86a7..c52e3c47 100644 --- a/src/Filee.App/Views/CropOverlayWindow.axaml +++ b/src/Filee.App/Views/CropOverlayWindow.axaml @@ -309,6 +309,16 @@ + + + + + + @@ -403,7 +413,8 @@ + SelectedMode="{Binding SelectedAspect}" IsPortrait="{Binding IsPortrait}" OriginalRatio="{Binding OriginalRatio}" + CustomRatio="{Binding CustomRatio}"> @@ -411,6 +422,10 @@ + + +