From bb54c21731a93eb64978dde7f5574d3f6c78ac0d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:22:26 +0000 Subject: [PATCH 1/4] feat(presets): Edit pictures, size, format and turn chosen each time (#584) A tool preset on the images donut (after Half size) opens a small window on the dropped pictures: format, resize, rotate, flip, grayscale and quality, with the first picture's size before and after. Convert starts ordinary conversions, so the toast, cancelling, output rules and history work as usual, and the choices are kept in the tool for the next time. Settings schema 49 adds it to existing libraries and repairs one an older Filee saved as a crop. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0164CyGuw3uFmErecYsXqr26 --- src/Filee.App/Assets/i18n/ar.json | 4 + src/Filee.App/Assets/i18n/de.json | 4 + src/Filee.App/Assets/i18n/en.json | 4 + src/Filee.App/Assets/i18n/es.json | 4 + src/Filee.App/Assets/i18n/fr.json | 4 + src/Filee.App/Assets/i18n/hi.json | 4 + src/Filee.App/Assets/i18n/ja.json | 4 + src/Filee.App/Assets/i18n/ko.json | 4 + src/Filee.App/Assets/i18n/ne.json | 4 + src/Filee.App/Assets/i18n/pl.json | 4 + src/Filee.App/Assets/i18n/pt-BR.json | 4 + src/Filee.App/Assets/i18n/pt-PT.json | 4 + src/Filee.App/Assets/i18n/ru.json | 4 + src/Filee.App/Assets/i18n/th.json | 4 + src/Filee.App/Assets/i18n/tr.json | 4 + src/Filee.App/Assets/i18n/zh-CN.json | 4 + src/Filee.App/Assets/i18n/zh-TW.json | 4 + src/Filee.App/Services/PresetAvailability.cs | 6 + src/Filee.App/Services/WindowService.cs | 48 +++++ .../ViewModels/ImageEditViewModel.cs | 176 ++++++++++++++++++ src/Filee.App/Views/ImageEditWindow.axaml | 78 ++++++++ src/Filee.App/Views/ImageEditWindow.axaml.cs | 25 +++ src/Filee.Core/Presets/BuiltInData.cs | 17 +- src/Filee.Core/Presets/ImageEditTool.cs | 70 +++++++ src/Filee.Core/Presets/Preset.cs | 13 +- src/Filee.Core/Settings/AppSettings.cs | 2 +- src/Filee.Core/Settings/UserDataStore.cs | 40 ++++ tests/Filee.App.Tests/ImageEditWindowTests.cs | 139 ++++++++++++++ tests/Filee.Core.Tests/BackFaceTests.cs | 4 +- tests/Filee.Core.Tests/ImageEditToolTests.cs | 159 ++++++++++++++++ 30 files changed, 840 insertions(+), 5 deletions(-) create mode 100644 src/Filee.App/ViewModels/ImageEditViewModel.cs create mode 100644 src/Filee.App/Views/ImageEditWindow.axaml create mode 100644 src/Filee.App/Views/ImageEditWindow.axaml.cs create mode 100644 src/Filee.Core/Presets/ImageEditTool.cs create mode 100644 tests/Filee.App.Tests/ImageEditWindowTests.cs create mode 100644 tests/Filee.Core.Tests/ImageEditToolTests.cs diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 3eacdea4..cfbc8d45 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -310,6 +310,10 @@ "clip.no_silence": "لم يُعثر على صمت", "clip.cannot_play": "لا يمكن تشغيل المقطع", "preset.half_size": "نصف الحجم", + "preset.image_edit": "تعديل الصور", + "image_edit.count": "{0} صور", + "image_edit.hint": "اختر الحجم والتنسيق والتدوير لهذه الصور. يحتفظ Filee باختياراتك للمرة القادمة.", + "image_edit.convert": "تحويل", "preset.half_size.donut": "نصف الحجم", "preset.grayscale": "تدرّج رمادي", "preset.remove_background": "إزالة الخلفية", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index 022a6164..4192f054 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -310,6 +310,10 @@ "clip.no_silence": "Keine Stille gefunden", "clip.cannot_play": "Der Clip lässt sich nicht abspielen", "preset.half_size": "Halbe Größe", + "preset.image_edit": "Bilder bearbeiten", + "image_edit.count": "{0} Bilder", + "image_edit.hint": "Wähle Größe, Format und Drehung für diese Bilder. Filee merkt sich deine Wahl für das nächste Mal.", + "image_edit.convert": "Konvertieren", "preset.half_size.donut": "Halbe Größe", "preset.grayscale": "Graustufen", "preset.remove_background": "Hintergrund entfernen", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index 6ece0638..9dcd8f42 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -320,6 +320,10 @@ "clip.cannot_play": "The clip can't be played", "preset.half_size": "Half size", + "preset.image_edit": "Edit pictures", + "image_edit.count": "{0} pictures", + "image_edit.hint": "Choose the size, format and turn for these pictures. Filee keeps your choices for the next time.", + "image_edit.convert": "Convert", "preset.half_size.donut": "Half size", "preset.grayscale": "Grayscale", "preset.remove_background": "Remove background", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index 8d34dfcf..0c77c94f 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -310,6 +310,10 @@ "clip.no_silence": "No se encontraron silencios", "clip.cannot_play": "No se puede reproducir el clip", "preset.half_size": "Reducir 50 %", + "preset.image_edit": "Editar imágenes", + "image_edit.count": "{0} imágenes", + "image_edit.hint": "Elige el tamaño, el formato y el giro de estas imágenes. Filee recuerda tu elección para la próxima vez.", + "image_edit.convert": "Convertir", "preset.half_size.donut": "Reducir 50 %", "preset.grayscale": "Grises", "preset.remove_background": "Quitar fondo", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index 9d89e5bf..ff43a8a3 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -320,6 +320,10 @@ "clip.cannot_play": "Impossible de lire l'extrait", "preset.half_size": "Taille 50 %", + "preset.image_edit": "Modifier les images", + "image_edit.count": "{0} images", + "image_edit.hint": "Choisissez la taille, le format et la rotation de ces images. Filee garde vos choix pour la prochaine fois.", + "image_edit.convert": "Convertir", "preset.half_size.donut": "Taille 50 %", "preset.grayscale": "Niveaux de gris", "preset.remove_background": "Détourer", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index 431d514a..1ea9136a 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -310,6 +310,10 @@ "clip.no_silence": "कोई चुप्पी नहीं मिली", "clip.cannot_play": "क्लिप नहीं चल सकती", "preset.half_size": "आधा आकार", + "preset.image_edit": "चित्र संपादित करें", + "image_edit.count": "{0} चित्र", + "image_edit.hint": "इन चित्रों का आकार, फ़ॉर्मेट और घुमाव चुनें। Filee अगली बार के लिए आपकी पसंद याद रखता है।", + "image_edit.convert": "कन्वर्ट करें", "preset.half_size.donut": "आधा आकार", "preset.grayscale": "ग्रेस्केल", "preset.remove_background": "बैकग्राउंड हटाएँ", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index a3411659..e22f8544 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -310,6 +310,10 @@ "clip.no_silence": "無音は見つかりませんでした", "clip.cannot_play": "クリップを再生できません", "preset.half_size": "縮小 50%", + "preset.image_edit": "画像を編集", + "image_edit.count": "{0} 枚の画像", + "image_edit.hint": "この画像のサイズ、形式、回転を選んでください。選んだ内容は次回も使われます。", + "image_edit.convert": "変換", "preset.half_size.donut": "縮小 50%", "preset.grayscale": "グレースケール", "preset.remove_background": "背景を削除", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 46ccf6cc..f564d1ad 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -320,6 +320,10 @@ "clip.cannot_play": "클립을 재생할 수 없어요", "preset.half_size": "50% 축소", + "preset.image_edit": "사진 편집", + "image_edit.count": "사진 {0}장", + "image_edit.hint": "이 사진들의 크기, 형식, 회전을 고르세요. 고른 값은 다음에도 그대로 쓰입니다.", + "image_edit.convert": "변환", "preset.half_size.donut": "50% 축소", "preset.grayscale": "흑백", "preset.remove_background": "배경 제거", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index a891cba3..4bcc2212 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -310,6 +310,10 @@ "clip.no_silence": "मौनता भेटिएन", "clip.cannot_play": "क्लिप बजाउन सकिँदैन", "preset.half_size": "आधा आकार", + "preset.image_edit": "तस्बिरहरू सम्पादन गर्नुहोस्", + "image_edit.count": "{0} तस्बिरहरू", + "image_edit.hint": "यी तस्बिरहरूको आकार, ढाँचा र घुमाइ छान्नुहोस्। Filee ले अर्को पटकका लागि तपाईंको छनोट सम्झन्छ।", + "image_edit.convert": "रूपान्तरण गर्नुहोस्", "preset.half_size.donut": "आधा आकार", "preset.grayscale": "ग्रेस्केल", "preset.remove_background": "पृष्ठभूमि हटाउनुहोस्", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index e350005d..adaf8dd8 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -320,6 +320,10 @@ "clip.cannot_play": "Nie można odtworzyć klipu", "preset.half_size": "Połowa rozmiaru", + "preset.image_edit": "Edytuj obrazy", + "image_edit.count": "Obrazy: {0}", + "image_edit.hint": "Wybierz rozmiar, format i obrót tych obrazów. Filee zapamięta wybór na następny raz.", + "image_edit.convert": "Konwertuj", "preset.half_size.donut": "Połowa rozmiaru", "preset.grayscale": "Skala szarości", "preset.remove_background": "Usuń tło", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index c5a198ff..6c8de554 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -320,6 +320,10 @@ "clip.cannot_play": "Não foi possível reproduzir o clipe", "preset.half_size": "Reduzir 50%", + "preset.image_edit": "Editar imagens", + "image_edit.count": "{0} imagens", + "image_edit.hint": "Escolha o tamanho, o formato e a rotação dessas imagens. O Filee guarda suas escolhas para a próxima vez.", + "image_edit.convert": "Converter", "preset.half_size.donut": "Tamanho 50%", "preset.grayscale": "Tons de cinza", "preset.remove_background": "Remover fundo", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index 9752ee85..64acd694 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -320,6 +320,10 @@ "clip.cannot_play": "Não é possível reproduzir o excerto", "preset.half_size": "Metade do tamanho", + "preset.image_edit": "Editar imagens", + "image_edit.count": "{0} imagens", + "image_edit.hint": "Escolha o tamanho, o formato e a rotação destas imagens. O Filee guarda as suas escolhas para a próxima vez.", + "image_edit.convert": "Converter", "preset.half_size.donut": "Tamanho 50%", "preset.grayscale": "Escala de cinzentos", "preset.remove_background": "Remover fundo", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 8f9feddc..b44e5446 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -320,6 +320,10 @@ "clip.cannot_play": "Клип не воспроизводится", "preset.half_size": "Вдвое меньше", + "preset.image_edit": "Изменить изображения", + "image_edit.count": "Изображений: {0}", + "image_edit.hint": "Выберите размер, формат и поворот для этих изображений. Filee запомнит выбор на следующий раз.", + "image_edit.convert": "Преобразовать", "preset.half_size.donut": "Вдвое меньше", "preset.grayscale": "Оттенки серого", "preset.remove_background": "Убрать фон", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index c186c2b0..8e44f84a 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -310,6 +310,10 @@ "clip.no_silence": "ไม่พบช่วงเงียบ", "clip.cannot_play": "เล่นคลิปนี้ไม่ได้", "preset.half_size": "ขนาดครึ่งหนึ่ง", + "preset.image_edit": "แก้ไขรูปภาพ", + "image_edit.count": "รูปภาพ {0} รูป", + "image_edit.hint": "เลือกขนาด รูปแบบ และการหมุนของรูปภาพเหล่านี้ Filee จะจำตัวเลือกไว้ใช้ครั้งหน้า", + "image_edit.convert": "แปลง", "preset.half_size.donut": "ขนาดครึ่งหนึ่ง", "preset.grayscale": "ขาวดำ (เกรย์สเกล)", "preset.remove_background": "ลบพื้นหลัง", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 5d85ce3f..81ecc10a 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -310,6 +310,10 @@ "clip.no_silence": "Sessiz kısım bulunamadı", "clip.cannot_play": "Klip oynatılamıyor", "preset.half_size": "Yarı boyut", + "preset.image_edit": "Resimleri düzenle", + "image_edit.count": "{0} resim", + "image_edit.hint": "Bu resimlerin boyutunu, biçimini ve dönüşünü seçin. Filee seçimlerinizi bir dahaki sefere hatırlar.", + "image_edit.convert": "Dönüştür", "preset.half_size.donut": "Yarı boyut", "preset.grayscale": "Gri tonlama", "preset.remove_background": "Arka planı kaldır", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 45f8b7d4..08a2cf90 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -320,6 +320,10 @@ "clip.cannot_play": "无法播放片段", "preset.half_size": "缩小 50%", + "preset.image_edit": "编辑图片", + "image_edit.count": "{0} 张图片", + "image_edit.hint": "为这些图片选择尺寸、格式和旋转。Filee 会记住你的选择,下次继续使用。", + "image_edit.convert": "转换", "preset.half_size.donut": "缩小 50%", "preset.grayscale": "灰度", "preset.remove_background": "去除背景", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 78743df1..e7428768 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -320,6 +320,10 @@ "clip.cannot_play": "無法播放片段", "preset.half_size": "縮小 50%", + "preset.image_edit": "編輯圖片", + "image_edit.count": "{0} 張圖片", + "image_edit.hint": "為這些圖片選擇尺寸、格式和旋轉。Filee 會記住你的選擇,下次繼續使用。", + "image_edit.convert": "轉換", "preset.half_size.donut": "縮小 50%", "preset.grayscale": "灰階", "preset.remove_background": "去除背景", diff --git a/src/Filee.App/Services/PresetAvailability.cs b/src/Filee.App/Services/PresetAvailability.cs index 687c59bd..d417dded 100644 --- a/src/Filee.App/Services/PresetAvailability.cs +++ b/src/Filee.App/Services/PresetAvailability.cs @@ -102,6 +102,12 @@ private PresetJudgement Evaluate(Preset preset, IReadOnlyList format : new(PresetUse.Unusable, _loc["error.no_route"]); } + // Any picture among the files: the others are left out when the window opens. + if (preset.IsImageEditTool) + return formats.Any(f => ImageEditTool.CanEdit(f.Id)) + ? new(PresetUse.Usable, null) + : new(PresetUse.Unusable, _loc["error.images_only"]); + if (preset.IsTool) { if (preset.Id == "tool-sheet-studio") diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs index 90a21e0f..bfff8855 100644 --- a/src/Filee.App/Services/WindowService.cs +++ b/src/Filee.App/Services/WindowService.cs @@ -36,8 +36,10 @@ public sealed class WindowService(ILocalizer loc, IServiceProvider services, Mem private CropOverlayWindow? _cropOverlay; private ClipWindow? _clipWindow; + private ImageEditWindow? _imageEdit; internal ClipWindow? ClipWindow => _clipWindow; + internal ImageEditWindow? ImageEdit => _imageEdit; private readonly HashSet _spreadsheetStudios = []; internal IReadOnlyCollection SpreadsheetStudios => _spreadsheetStudios; @@ -128,6 +130,8 @@ public void ShowTool(IReadOnlyList files, Preset preset, PixelPoint? nea { if (preset.IsClipTool) ShowClipWindow(files, preset, near); + else if (preset.IsImageEditTool) + ShowImageEditWindow(files, preset); // Before the crop: the print studio and the PDF page tools have no Tool of their own, which reads as a crop // ("Crop PDF" is one and opens the crop overlay). else if (preset.Id == "tool-sheet-studio") @@ -173,6 +177,50 @@ public void ShowClipWindow(IReadOnlyList files, Preset preset, PixelPoin win.Activate(); } + /// + /// Opens the "Edit pictures" window on the pictures among the files. Its Convert starts ordinary conversions, shown + /// in the toast. + /// + public void ShowImageEditWindow(IReadOnlyList files, Preset preset) + { + var pictures = files.Where(f => FormatRegistry.Detect(f) is { } format && ImageEditTool.CanEdit(format.Id)).ToList(); + if (pictures.Count == 0) + return; + _imageEdit?.Close(); + var store = services.GetRequiredService(); + var catalog = services.GetRequiredService(); + var conversions = services.GetRequiredService(); + var log = services.GetService>(); + var planner = catalog.CreatePlanner(); + var vm = new ImageEditViewModel(pictures, preset, loc, store, + (path, chosen) => ImageEditTool.TargetFor(FormatRegistry.Detect(path)?.Id ?? "", chosen, planner), + (paths, conversion) => conversions.Start(paths, conversion), PictureSize, log); + var win = new ImageEditWindow { DataContext = vm }; + PrepareWindow(win); + win.Closed += (_, _) => + { + if (_imageEdit == win) + _imageEdit = null; + }; + _imageEdit = win; + win.Show(); + win.Activate(); + } + + /// The pixel size of a picture, read from its header; null when ImageMagick can't read it. + private static (int Width, int Height)? PictureSize(string path) + { + try + { + var info = new ImageMagick.MagickImageInfo(path); + return ((int)info.Width, (int)info.Height); + } + catch (Exception ex) when (ex is ImageMagick.MagickException or IOException or UnauthorizedAccessException) + { + return null; + } + } + /// Opens the interactive crop overlay for the files. public void ShowCropOverlay(IReadOnlyList files, Preset preset, PixelPoint? near = null) { diff --git a/src/Filee.App/ViewModels/ImageEditViewModel.cs b/src/Filee.App/ViewModels/ImageEditViewModel.cs new file mode 100644 index 00000000..3a52442f --- /dev/null +++ b/src/Filee.App/ViewModels/ImageEditViewModel.cs @@ -0,0 +1,176 @@ +// "Edit pictures" (#584): the size, format, turn and colour of the dropped pictures, chosen right before they are +// converted instead of in a preset made for each size. The window opens with the tool's last choices; Convert keeps them +// in the tool and starts ordinary conversions (ImageEditTool), so the toast, cancelling and the output rules work as usual. + +using CommunityToolkit.Mvvm.ComponentModel; +using Filee.Core.Formats; +using Filee.Core.Localization; +using Filee.Core.Presets; +using Filee.Core.Settings; +using Filee.Engines.Magick; +using Microsoft.Extensions.Logging; + +namespace Filee.App.ViewModels; + +public sealed partial class ImageEditViewModel : ObservableObject +{ + private readonly Preset _tool; + private readonly ILocalizer _loc; + private readonly UserDataStore _store; + private readonly Func _targetFor; + private readonly Action, Preset> _start; + private readonly ILogger? _log; + private readonly (int Width, int Height)? _firstSize; + + /// The pictures to convert (other files are left out by the caller). + /// The "Edit pictures" tool: its last choices, name and output rule. + /// The format a picture (path) is written in for the chosen one (). + /// Starts the conversion of some of the pictures. + /// The pixel size of a picture, null when it can't be read; for the size shown in the window. + public ImageEditViewModel(IReadOnlyList pictures, Preset tool, ILocalizer loc, UserDataStore store, + Func targetFor, Action, Preset> start, + Func? sizeOf = null, ILogger? log = null) + { + Pictures = pictures; + _tool = tool; + _loc = loc; + _store = store; + _targetFor = targetFor; + _start = start; + _log = log; + _firstSize = pictures.Count > 0 ? sizeOf?.Invoke(pictures[0]) : null; + + Targets = + [ + new Choice(BuiltInData.SameAsSource, loc["presets.same_as_source"]), + .. ImageEditTool.Targets.Select(id => new Choice(id, FormatRegistry.Get(id).DisplayName)), + ]; + ResizeModes = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.resize.{v}"])).ToList(); + Rotations = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.rotation.{v}"])).ToList(); + Flips = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.flip.{v}"])).ToList(); + + var image = tool.Image; + _target = Targets.FirstOrDefault(t => t.Value == tool.TargetFormat) ?? Targets[0]; + _resize = ResizeModes.FirstOrDefault(r => r.Value == image.Resize) ?? ResizeModes[0]; + _rotation = Rotations.FirstOrDefault(r => r.Value == image.Rotation) ?? Rotations[0]; + _flip = Flips.FirstOrDefault(f => f.Value == image.Flip) ?? Flips[0]; + _resizePercent = image.ResizePercent; + _longEdge = image.LongEdge; + _width = image.Width; + _grayscale = image.Grayscale; + _quality = image.Quality; + } + + public IReadOnlyList Pictures { get; } + + public IReadOnlyList> Targets { get; } + public IReadOnlyList> ResizeModes { get; } + public IReadOnlyList> Rotations { get; } + public IReadOnlyList> Flips { get; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowQuality))] + private Choice _target; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowPercent), nameof(ShowLongEdge), nameof(ShowWidth), nameof(SizeChange))] + private Choice _resize; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(SizeChange))] + private Choice _rotation; + + [ObservableProperty] private Choice _flip; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(SizeChange))] + private int _resizePercent; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(SizeChange))] + private int _longEdge; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(SizeChange))] + private int _width; + + [ObservableProperty] private bool _grayscale; + [ObservableProperty] private int _quality; + + partial void OnTargetChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Target = v); + partial void OnResizeChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Resize = v); + partial void OnRotationChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Rotation = v); + partial void OnFlipChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Flip = v); + + public bool ShowPercent => Resize.Value == ResizeMode.Percent; + public bool ShowLongEdge => Resize.Value == ResizeMode.LongEdge; + public bool ShowWidth => Resize.Value == ResizeMode.Width; + + /// JPG, WEBP, AVIF and HEIC, or the source's own format (which may be one of them). + public bool ShowQuality => Target.Value == BuiltInData.SameAsSource || ImageEditTool.LossyTargets.Contains(Target.Value); + + /// The first picture's name, or how many pictures there are. + public string Heading => Pictures.Count == 1 ? Path.GetFileName(Pictures[0]) : _loc.Format("image_edit.count", Pictures.Count); + + /// "512 × 512 → 32 × 32" for the first picture; empty when its size can't be read. + public string SizeChange + { + get + { + if (_firstSize is not { } size || size.Width <= 0 || size.Height <= 0) + return ""; + var (width, height) = size; + var options = Options(); + var scale = ImageEncoder.ResizeScale(width, height, options); + var turned = options.Rotation is ImageRotation.Left90 or ImageRotation.Right90; + var (outWidth, outHeight) = (Math.Max(1, (int)Math.Round(width * scale)), Math.Max(1, (int)Math.Round(height * scale))); + if (turned) + (outWidth, outHeight) = (outHeight, outWidth); + return $"{width} × {height} → {outWidth} × {outHeight}"; + } + } + + /// The tool's picture options with the window's choices. + internal ImageOptions Options() + { + var image = _tool.Image.Clone(); + image.Resize = Resize.Value; + image.ResizePercent = Math.Clamp(ResizePercent, 1, 1000); + image.LongEdge = Math.Clamp(LongEdge, 16, 20000); + image.Width = Math.Clamp(Width, 16, 20000); + image.Rotation = Rotation.Value; + image.Flip = Flip.Value; + image.Grayscale = Grayscale; + image.Quality = Math.Clamp(Quality, 1, 100); + return image; + } + + /// + /// Keeps the choices in the tool for the next time and starts the conversions: one for each format the pictures are + /// written in (a camera RAW kept in "its own format" becomes PNG). + /// + public void Convert() + { + var image = Options(); + Remember(image); + foreach (var group in Pictures.GroupBy(path => _targetFor(path, Target.Value))) + _start([.. group], ImageEditTool.ConversionOf(_tool, group.Key, image)); + } + + private void Remember(ImageOptions image) + { + // In the tool as saved, and in the one given to this window (maybe the same). + ImageEditTool.Remember(_tool, Target.Value, image); + if (_store.FindPreset(_tool.Id) is not { IsImageEditTool: true } saved) + return; + ImageEditTool.Remember(saved, Target.Value, image); + try + { + _store.SaveLibrary(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _log?.LogWarning(ex, "Could not remember the picture edits"); + } + } +} diff --git a/src/Filee.App/Views/ImageEditWindow.axaml b/src/Filee.App/Views/ImageEditWindow.axaml new file mode 100644 index 00000000..a9a0f74f --- /dev/null +++ b/src/Filee.App/Views/ImageEditWindow.axaml @@ -0,0 +1,78 @@ + + + + + +