From bf6f9aad7fb16aa21b82b6cdd3b1cd00d11ac39a Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Wed, 28 Oct 2026 03:05:07 +0900 Subject: [PATCH 1/2] feat(images): pixelate or blur parts of a picture before sharing it A "Pixelate / blur" tool on the images donut opens the pictures one at a time in a window drawn like the crop overlay: drag frames over names, faces or numbers, each one showing the picture already pixelated or blurred, move and size them, pick Pixelate or Blur and a strength, then Hide & Save (or Hide & Next). Each picture is written by an ordinary conversion in its own format (PNG when it can't be written), so the toast, the output rules and the history work as usual; the frames carry on to the next picture of a batch. Pixelate averages squares on a grid laid from the picture's corner; blur averages into smaller squares first and then blurs about a line of text wide, so a light, reversible blur is never used. Both are sized from the picture's shorter side. The job queue covers the parts once, in the first step whose engine applies image options, refuses a route that can't, never puts the original back for "only if smaller", and keeps no Retry for them. The previews of the uncovered picture a file carries (EXIF, Photoshop, XMP thumbnails) are dropped. Settings schema 52 adds the tool after Crop and on the images donut after Half size (or on the back face when the front is full). Pixelate or blur and the strength are remembered in the tool, in fields of their own. Co-Authored-By: Claude Opus 5.5 --- src/Filee.App/Assets/i18n/ar.json | 17 + src/Filee.App/Assets/i18n/de.json | 17 + src/Filee.App/Assets/i18n/en.json | 17 + src/Filee.App/Assets/i18n/es.json | 17 + src/Filee.App/Assets/i18n/fr.json | 17 + src/Filee.App/Assets/i18n/hi.json | 17 + src/Filee.App/Assets/i18n/ja.json | 17 + src/Filee.App/Assets/i18n/ko.json | 17 + src/Filee.App/Assets/i18n/ne.json | 17 + src/Filee.App/Assets/i18n/pl.json | 17 + src/Filee.App/Assets/i18n/pt-BR.json | 17 + src/Filee.App/Assets/i18n/pt-PT.json | 17 + src/Filee.App/Assets/i18n/ru.json | 17 + src/Filee.App/Assets/i18n/th.json | 17 + src/Filee.App/Assets/i18n/tr.json | 17 + src/Filee.App/Assets/i18n/zh-CN.json | 17 + src/Filee.App/Assets/i18n/zh-TW.json | 17 + src/Filee.App/Controls/CropCanvas.cs | 51 +- src/Filee.App/Controls/HideAreaSet.cs | 205 ++++++ src/Filee.App/Controls/HideCanvas.cs | 644 ++++++++++++++++++ src/Filee.App/Services/HideService.cs | 93 +++ src/Filee.App/Services/PresetAvailability.cs | 6 + src/Filee.App/Services/WindowService.cs | 40 +- src/Filee.App/Styles/Icons.axaml | 2 + .../ViewModels/CropOverlayViewModel.cs | 10 +- .../ViewModels/HideWindowViewModel.cs | 495 ++++++++++++++ src/Filee.App/Views/HideWindow.axaml | 430 ++++++++++++ src/Filee.App/Views/HideWindow.axaml.cs | 264 +++++++ src/Filee.Core/Conversion/JobQueue.cs | 31 +- src/Filee.Core/History/HistoryEntry.cs | 3 +- src/Filee.Core/Presets/BuiltInData.cs | 17 +- src/Filee.Core/Presets/HideTool.cs | 122 ++++ src/Filee.Core/Presets/Preset.cs | 68 +- src/Filee.Core/Settings/AppSettings.cs | 2 +- src/Filee.Core/Settings/UserDataStore.cs | 54 ++ src/Filee.Engines/Magick/AreaHider.cs | 195 ++++++ src/Filee.Engines/Magick/ImageEncoder.cs | 4 + tests/Filee.App.Tests/HideWindowTests.cs | 464 +++++++++++++ tests/Filee.App.Tests/RenderTests.cs | 2 +- .../ToolbarPaletteViewTests.cs | 7 +- tests/Filee.Core.Tests/BackFaceTests.cs | 4 +- tests/Filee.Core.Tests/HideToolTests.cs | 404 +++++++++++ tests/Filee.Engines.Tests/HideAreasTests.cs | 237 +++++++ 43 files changed, 4093 insertions(+), 50 deletions(-) create mode 100644 src/Filee.App/Controls/HideAreaSet.cs create mode 100644 src/Filee.App/Controls/HideCanvas.cs create mode 100644 src/Filee.App/Services/HideService.cs create mode 100644 src/Filee.App/ViewModels/HideWindowViewModel.cs create mode 100644 src/Filee.App/Views/HideWindow.axaml create mode 100644 src/Filee.App/Views/HideWindow.axaml.cs create mode 100644 src/Filee.Core/Presets/HideTool.cs create mode 100644 src/Filee.Engines/Magick/AreaHider.cs create mode 100644 tests/Filee.App.Tests/HideWindowTests.cs create mode 100644 tests/Filee.Core.Tests/HideToolTests.cs create mode 100644 tests/Filee.Engines.Tests/HideAreasTests.cs diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 8738fcda..f1631f3b 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -251,6 +251,23 @@ "crop.state_done": "تم الاقتصاص", "crop.state_skipped": "تم التخطي", "crop.state_failed": "فشل", + "preset.hide": "بكسلة / تمويه", + "preset.hide.donut": "بكسلة", + "error.cannot_hide": "البكسلة والتمويه متاحان للصور فقط", + "hide.save": "إخفاء وحفظ", + "hide.save_next": "إخفاء والتالي", + "hide.pixelate": "بكسلة", + "hide.pixelate_hint": "التغطية بمربعات بمتوسط الألوان (P)", + "hide.blur": "تمويه", + "hide.blur_hint": "التغطية بتمويه قوي (B)", + "hide.strength": "الشدة", + "hide.strength_value": "الشدة {0} من 10 (− / +)", + "hide.remove": "إزالة الإطار", + "hide.remove_hint": "إزالة الإطار المحدد (Del)", + "hide.clear": "إزالة الكل", + "hide.empty_hint": "اسحب فوق ما تريد إخفاءه", + "hide.hint_keys": "يختار Tab إطارًا وتحرّكه مفاتيح الأسهم، ومع Shift تكون الخطوة أكبر بعشر مرات · Del يزيله · Ctrl+Z للتراجع · Esc للإغلاق", + "hide.state_done": "تم", "preset.clip": "قص مقطع", "clip.start": "البداية", "clip.end": "النهاية", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index 154a2512..9c5050f0 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -251,6 +251,23 @@ "crop.state_done": "Zugeschnitten", "crop.state_skipped": "Übersprungen", "crop.state_failed": "Fehlgeschlagen", + "preset.hide": "Verpixeln / Weichzeichnen", + "preset.hide.donut": "Verpixeln", + "error.cannot_hide": "Verpixeln und Weichzeichnen ist nur bei Bildern möglich", + "hide.save": "Verbergen & Speichern", + "hide.save_next": "Verbergen & Weiter", + "hide.pixelate": "Verpixeln", + "hide.pixelate_hint": "Mit Quadraten in der Durchschnittsfarbe abdecken (P)", + "hide.blur": "Weichzeichnen", + "hide.blur_hint": "Mit starker Unschärfe abdecken (B)", + "hide.strength": "Stärke", + "hide.strength_value": "Stärke {0} von 10 (− / +)", + "hide.remove": "Rahmen entfernen", + "hide.remove_hint": "Ausgewählten Rahmen entfernen (Entf)", + "hide.clear": "Alle entfernen", + "hide.empty_hint": "Ziehe über das, was verborgen werden soll", + "hide.hint_keys": "Tab wählt einen Rahmen, Pfeiltasten verschieben ihn, Umschalt × 10 · Entf entfernt ihn · Strg+Z macht rückgängig · Esc schließt", + "hide.state_done": "Fertig", "preset.clip": "Clip schneiden", "clip.start": "Beginn", "clip.end": "Ende", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index 4cd5e0eb..9220cdbc 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -260,6 +260,23 @@ "crop.state_done": "Cropped", "crop.state_skipped": "Skipped", "crop.state_failed": "Failed", + "preset.hide": "Pixelate / blur", + "preset.hide.donut": "Pixelate", + "error.cannot_hide": "Only pictures can be pixelated or blurred", + "hide.save": "Hide & Save", + "hide.save_next": "Hide & Next", + "hide.pixelate": "Pixelate", + "hide.pixelate_hint": "Cover with squares of the average colour (P)", + "hide.blur": "Blur", + "hide.blur_hint": "Cover with a strong blur (B)", + "hide.strength": "Strength", + "hide.strength_value": "Strength {0} of 10 (− / +)", + "hide.remove": "Remove frame", + "hide.remove_hint": "Remove the selected frame (Del)", + "hide.clear": "Clear all", + "hide.empty_hint": "Drag over what you want to hide", + "hide.hint_keys": "Tab picks a frame, arrow keys move it, Shift × 10 · Del removes it · Ctrl+Z undoes · Esc closes", + "hide.state_done": "Done", "preset.clip": "Cut a clip", "clip.start": "Start", "clip.end": "End", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index 02dab483..d6e636a5 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -251,6 +251,23 @@ "crop.state_done": "Recortado", "crop.state_skipped": "Omitido", "crop.state_failed": "Error", + "preset.hide": "Pixelar / difuminar", + "preset.hide.donut": "Pixelar", + "error.cannot_hide": "Solo se pueden pixelar o difuminar imágenes", + "hide.save": "Ocultar y guardar", + "hide.save_next": "Ocultar y seguir", + "hide.pixelate": "Pixelar", + "hide.pixelate_hint": "Cubrir con cuadros del color medio (P)", + "hide.blur": "Difuminar", + "hide.blur_hint": "Cubrir con un desenfoque fuerte (B)", + "hide.strength": "Intensidad", + "hide.strength_value": "Intensidad {0} de 10 (− / +)", + "hide.remove": "Quitar marco", + "hide.remove_hint": "Quitar el marco seleccionado (Supr)", + "hide.clear": "Quitar todos", + "hide.empty_hint": "Arrastra sobre lo que quieras ocultar", + "hide.hint_keys": "Tab elige un marco, las flechas lo mueven, Mayús × 10 · Supr lo quita · Ctrl+Z deshace · Esc cierra", + "hide.state_done": "Hecho", "preset.clip": "Cortar un clip", "clip.start": "Inicio", "clip.end": "Fin", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index 36e31d0f..5bf94e50 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -260,6 +260,23 @@ "crop.state_done": "Recadré", "crop.state_skipped": "Ignoré", "crop.state_failed": "Échec", + "preset.hide": "Pixeliser / flouter", + "preset.hide.donut": "Pixeliser", + "error.cannot_hide": "Seules les images peuvent être pixelisées ou floutées", + "hide.save": "Masquer et enregistrer", + "hide.save_next": "Masquer et suivant", + "hide.pixelate": "Pixeliser", + "hide.pixelate_hint": "Couvrir de carrés de la couleur moyenne (P)", + "hide.blur": "Flouter", + "hide.blur_hint": "Couvrir d'un flou intense (B)", + "hide.strength": "Intensité", + "hide.strength_value": "Intensité {0} sur 10 (− / +)", + "hide.remove": "Supprimer le cadre", + "hide.remove_hint": "Supprimer le cadre sélectionné (Suppr)", + "hide.clear": "Tout effacer", + "hide.empty_hint": "Faites glisser sur ce que vous voulez masquer", + "hide.hint_keys": "Tab choisit un cadre, les flèches le déplacent, Maj × 10 · Suppr le supprime · Ctrl+Z annule · Échap ferme", + "hide.state_done": "Terminé", "preset.clip": "Couper un extrait", "clip.start": "Début", "clip.end": "Fin", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index 11dad241..6d1c00a3 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -251,6 +251,23 @@ "crop.state_done": "क्रॉप हो गया", "crop.state_skipped": "छोड़ा गया", "crop.state_failed": "विफल", + "preset.hide": "पिक्सलेट / धुंधला", + "preset.hide.donut": "पिक्सलेट", + "error.cannot_hide": "पिक्सलेट और धुंधला केवल इमेज पर काम करता है", + "hide.save": "छिपाकर सहेजें", + "hide.save_next": "छिपाकर अगला", + "hide.pixelate": "पिक्सलेट", + "hide.pixelate_hint": "औसत रंग के वर्गों से ढकें (P)", + "hide.blur": "धुंधला", + "hide.blur_hint": "गहरे धुंधलेपन से ढकें (B)", + "hide.strength": "तीव्रता", + "hide.strength_value": "तीव्रता {0} / 10 (− / +)", + "hide.remove": "फ़्रेम हटाएँ", + "hide.remove_hint": "चुना गया फ़्रेम हटाएँ (Del)", + "hide.clear": "सभी हटाएँ", + "hide.empty_hint": "जो छिपाना है उस पर खींचें", + "hide.hint_keys": "Tab से फ़्रेम चुनें, ऐरो कुंजियों से खिसकाएँ, Shift से 10 गुना · Del से हटाएँ · Ctrl+Z से पूर्ववत करें · Esc से बंद करें", + "hide.state_done": "हो गया", "preset.clip": "क्लिप काटें", "clip.start": "शुरुआत", "clip.end": "अंत", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 5298e5df..c53dc15a 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -251,6 +251,23 @@ "crop.state_done": "トリミング済み", "crop.state_skipped": "スキップ", "crop.state_failed": "失敗", + "preset.hide": "モザイク・ぼかし", + "preset.hide.donut": "モザイク", + "error.cannot_hide": "モザイクとぼかしは画像でのみ使えます", + "hide.save": "隠して保存", + "hide.save_next": "隠して次へ", + "hide.pixelate": "モザイク", + "hide.pixelate_hint": "平均色のブロックで隠す (P)", + "hide.blur": "ぼかし", + "hide.blur_hint": "強いぼかしで隠す (B)", + "hide.strength": "強さ", + "hide.strength_value": "強さ {0} / 10(− / +)", + "hide.remove": "枠を削除", + "hide.remove_hint": "選択した枠を削除(Del)", + "hide.clear": "すべて消去", + "hide.empty_hint": "隠したい部分をドラッグしてください", + "hide.hint_keys": "Tab で枠を選択、矢印キーで移動、Shift で 10 倍 · Del で削除 · Ctrl+Z で元に戻す · Esc で閉じる", + "hide.state_done": "完了", "preset.clip": "クリップを切り出す", "clip.start": "開始", "clip.end": "終了", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index bb6eb21d..4559cb0c 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -260,6 +260,23 @@ "crop.state_done": "자르기 완료", "crop.state_skipped": "건너뜀", "crop.state_failed": "실패", + "preset.hide": "모자이크·흐림", + "preset.hide.donut": "모자이크", + "error.cannot_hide": "모자이크와 흐림은 이미지에만 할 수 있어요", + "hide.save": "가리고 저장", + "hide.save_next": "가리고 다음으로", + "hide.pixelate": "모자이크", + "hide.pixelate_hint": "평균 색 네모로 가리기 (P)", + "hide.blur": "흐림", + "hide.blur_hint": "강하게 흐려서 가리기 (B)", + "hide.strength": "세기", + "hide.strength_value": "세기 {0} / 10 (− / +)", + "hide.remove": "틀 지우기", + "hide.remove_hint": "선택한 틀 지우기 (Del)", + "hide.clear": "모두 지우기", + "hide.empty_hint": "가릴 부분을 드래그하세요", + "hide.hint_keys": "Tab으로 틀 선택, 화살표 키로 이동, Shift는 10배 · Del로 지우기 · Ctrl+Z로 실행 취소 · Esc로 닫기", + "hide.state_done": "완료", "preset.clip": "클립 자르기", "clip.start": "시작", "clip.end": "끝", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index 0716ee27..3ede86f2 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -251,6 +251,23 @@ "crop.state_done": "क्रप गरियो", "crop.state_skipped": "छोडियो", "crop.state_failed": "असफल भयो", + "preset.hide": "पिक्सलेट / धमिलो", + "preset.hide.donut": "पिक्सलेट", + "error.cannot_hide": "पिक्सलेट र धमिलो पार्ने कार्य केवल छविहरूमा मात्र काम गर्छ", + "hide.save": "लुकाएर सुरक्षित गर्नुहोस्", + "hide.save_next": "लुकाएर अर्कोमा जानुहोस्", + "hide.pixelate": "पिक्सलेट", + "hide.pixelate_hint": "औसत रङका वर्गहरूले ढाक्नुहोस् (P)", + "hide.blur": "धमिलो", + "hide.blur_hint": "गाढा धमिलोपनले ढाक्नुहोस् (B)", + "hide.strength": "तीव्रता", + "hide.strength_value": "तीव्रता {0} / 10 (− / +)", + "hide.remove": "फ्रेम हटाउनुहोस्", + "hide.remove_hint": "चयन गरिएको फ्रेम हटाउनुहोस् (Del)", + "hide.clear": "सबै हटाउनुहोस्", + "hide.empty_hint": "लुकाउन चाहेको भागमाथि ड्र्याग गर्नुहोस्", + "hide.hint_keys": "Tab ले फ्रेम छान्छ, एरो कीहरूले सार्छन्, Shift थिच्दा १० गुणा · Del ले हटाउँछ · Ctrl+Z ले पूर्वावस्थामा फर्काउँछ · Esc ले बन्द गर्छ", + "hide.state_done": "सम्पन्न", "preset.clip": "क्लिप काट्नुहोस्", "clip.start": "सुरु", "clip.end": "अन्त्य", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 7ea3d913..7d816cdc 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -260,6 +260,23 @@ "crop.state_done": "Wykadrowany", "crop.state_skipped": "Pominięty", "crop.state_failed": "Błąd", + "preset.hide": "Pikselizacja / rozmycie", + "preset.hide.donut": "Mozaika", + "error.cannot_hide": "Pikselizować i rozmywać można tylko obrazy", + "hide.save": "Ukryj i zapisz", + "hide.save_next": "Ukryj i dalej", + "hide.pixelate": "Pikselizacja", + "hide.pixelate_hint": "Zakryj kwadratami w średnim kolorze (P)", + "hide.blur": "Rozmycie", + "hide.blur_hint": "Zakryj mocnym rozmyciem (B)", + "hide.strength": "Siła", + "hide.strength_value": "Siła {0} z 10 (− / +)", + "hide.remove": "Usuń ramkę", + "hide.remove_hint": "Usuń zaznaczoną ramkę (Del)", + "hide.clear": "Usuń wszystkie", + "hide.empty_hint": "Przeciągnij po tym, co chcesz ukryć", + "hide.hint_keys": "Tab wybiera ramkę, strzałki ją przesuwają, Shift × 10 · Del ją usuwa · Ctrl+Z cofa · Esc zamyka", + "hide.state_done": "Gotowe", "preset.clip": "Wytnij klip", "clip.start": "Początek", "clip.end": "Koniec", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 140aa954..795657c3 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -261,6 +261,23 @@ "crop.state_done": "Recortado", "crop.state_skipped": "Pulado", "crop.state_failed": "Falhou", + "preset.hide": "Pixelizar / desfocar", + "preset.hide.donut": "Pixelizar", + "error.cannot_hide": "Só é possível pixelizar ou desfocar imagens", + "hide.save": "Ocultar e salvar", + "hide.save_next": "Ocultar e avançar", + "hide.pixelate": "Pixelizar", + "hide.pixelate_hint": "Cobrir com quadrados da cor média (P)", + "hide.blur": "Desfocar", + "hide.blur_hint": "Cobrir com um desfoque forte (B)", + "hide.strength": "Intensidade", + "hide.strength_value": "Intensidade {0} de 10 (− / +)", + "hide.remove": "Remover moldura", + "hide.remove_hint": "Remover a moldura selecionada (Del)", + "hide.clear": "Remover todas", + "hide.empty_hint": "Arraste sobre o que você quer ocultar", + "hide.hint_keys": "Tab escolhe uma moldura, as setas a movem, Shift × 10 · Del a remove · Ctrl+Z desfaz · Esc fecha", + "hide.state_done": "Concluído", "clip.start": "Início", "clip.end": "Fim", "clip.length": "Duração", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index 05327916..6c8a6f9d 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -260,6 +260,23 @@ "crop.state_done": "Recortado", "crop.state_skipped": "Ignorado", "crop.state_failed": "Falhou", + "preset.hide": "Pixelizar / desfocar", + "preset.hide.donut": "Pixelizar", + "error.cannot_hide": "Só é possível pixelizar ou desfocar imagens", + "hide.save": "Ocultar e guardar", + "hide.save_next": "Ocultar e avançar", + "hide.pixelate": "Pixelizar", + "hide.pixelate_hint": "Cobrir com quadrados da cor média (P)", + "hide.blur": "Desfocar", + "hide.blur_hint": "Cobrir com uma desfocagem forte (B)", + "hide.strength": "Intensidade", + "hide.strength_value": "Intensidade {0} de 10 (− / +)", + "hide.remove": "Remover moldura", + "hide.remove_hint": "Remover a moldura selecionada (Del)", + "hide.clear": "Remover todas", + "hide.empty_hint": "Arraste sobre o que pretende ocultar", + "hide.hint_keys": "Tab escolhe uma moldura, as teclas de direção movem-na, Shift × 10 · Del remove-a · Ctrl+Z anula · Esc fecha", + "hide.state_done": "Concluído", "preset.clip": "Cortar um excerto", "clip.start": "Início", "clip.end": "Fim", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index c6b23149..7be0da3f 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -260,6 +260,23 @@ "crop.state_done": "Обрезан", "crop.state_skipped": "Пропущен", "crop.state_failed": "Ошибка", + "preset.hide": "Пикселизация / размытие", + "preset.hide.donut": "Мозаика", + "error.cannot_hide": "Пикселизировать и размывать можно только изображения", + "hide.save": "Скрыть и сохранить", + "hide.save_next": "Скрыть и дальше", + "hide.pixelate": "Пикселизация", + "hide.pixelate_hint": "Закрыть квадратами среднего цвета (P)", + "hide.blur": "Размытие", + "hide.blur_hint": "Закрыть сильным размытием (B)", + "hide.strength": "Сила", + "hide.strength_value": "Сила {0} из 10 (− / +)", + "hide.remove": "Удалить рамку", + "hide.remove_hint": "Удалить выбранную рамку (Del)", + "hide.clear": "Удалить все", + "hide.empty_hint": "Выделите мышью то, что нужно скрыть", + "hide.hint_keys": "Tab — выбрать рамку · стрелки — двигать, Shift × 10 · Del — удалить · Ctrl+Z — отменить · Esc — закрыть", + "hide.state_done": "Готово", "preset.clip": "Вырезать клип", "clip.start": "Начало", "clip.end": "Конец", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index bd33fbba..c2d901e7 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -251,6 +251,23 @@ "crop.state_done": "ครอบตัดแล้ว", "crop.state_skipped": "ข้ามแล้ว", "crop.state_failed": "ล้มเหลว", + "preset.hide": "โมเสก / เบลอ", + "preset.hide.donut": "โมเสก", + "error.cannot_hide": "ใส่โมเสกหรือเบลอได้เฉพาะไฟล์รูปภาพเท่านั้น", + "hide.save": "ซ่อนและบันทึก", + "hide.save_next": "ซ่อนและต่อไป", + "hide.pixelate": "โมเสก", + "hide.pixelate_hint": "ปิดด้วยช่องสี่เหลี่ยมสีเฉลี่ย (P)", + "hide.blur": "เบลอ", + "hide.blur_hint": "ปิดด้วยการเบลออย่างแรง (B)", + "hide.strength": "ความแรง", + "hide.strength_value": "ความแรง {0} / 10 (− / +)", + "hide.remove": "ลบกรอบ", + "hide.remove_hint": "ลบกรอบที่เลือก (Del)", + "hide.clear": "ลบทั้งหมด", + "hide.empty_hint": "ลากคลุมส่วนที่ต้องการซ่อน", + "hide.hint_keys": "Tab เพื่อเลือกกรอบ, ปุ่มลูกศรเพื่อเลื่อน, Shift × 10 · Del เพื่อลบ · Ctrl+Z เพื่อเลิกทำ · Esc เพื่อปิด", + "hide.state_done": "เสร็จแล้ว", "preset.clip": "ตัดคลิป", "clip.start": "เริ่ม", "clip.end": "สิ้นสุด", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 0a0ddf6c..73f14542 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -251,6 +251,23 @@ "crop.state_done": "Kırpıldı", "crop.state_skipped": "Atlandı", "crop.state_failed": "Başarısız", + "preset.hide": "Pikselleştir / bulanıklaştır", + "preset.hide.donut": "Mozaik", + "error.cannot_hide": "Yalnızca görseller pikselleştirilebilir veya bulanıklaştırılabilir", + "hide.save": "Gizle ve kaydet", + "hide.save_next": "Gizle ve ilerle", + "hide.pixelate": "Pikselleştir", + "hide.pixelate_hint": "Ortalama renkteki karelerle kapat (P)", + "hide.blur": "Bulanıklaştır", + "hide.blur_hint": "Güçlü bir bulanıklıkla kapat (B)", + "hide.strength": "Güç", + "hide.strength_value": "Güç {0} / 10 (− / +)", + "hide.remove": "Çerçeveyi sil", + "hide.remove_hint": "Seçili çerçeveyi sil (Del)", + "hide.clear": "Tümünü sil", + "hide.empty_hint": "Gizlemek istediğiniz yerin üzerine sürükleyin", + "hide.hint_keys": "Tab çerçeve seçer, ok tuşları taşır, Shift × 10 · Del siler · Ctrl+Z geri alır · Esc kapatır", + "hide.state_done": "Tamamlandı", "preset.clip": "Klip kes", "clip.start": "Başlangıç", "clip.end": "Bitiş", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 7f250fbd..72ba92d0 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -260,6 +260,23 @@ "crop.state_done": "已裁剪", "crop.state_skipped": "已跳过", "crop.state_failed": "失败", + "preset.hide": "马赛克/模糊", + "preset.hide.donut": "马赛克", + "error.cannot_hide": "只能对图片打马赛克或模糊", + "hide.save": "遮盖并保存", + "hide.save_next": "遮盖并下一张", + "hide.pixelate": "马赛克", + "hide.pixelate_hint": "用平均色方块遮盖 (P)", + "hide.blur": "模糊", + "hide.blur_hint": "用强模糊遮盖 (B)", + "hide.strength": "强度", + "hide.strength_value": "强度 {0} / 10(− / +)", + "hide.remove": "删除选框", + "hide.remove_hint": "删除所选选框(Del)", + "hide.clear": "全部清除", + "hide.empty_hint": "拖动框选要隐藏的部分", + "hide.hint_keys": "Tab 选择选框,方向键移动,Shift 为 10 倍 · Del 删除 · Ctrl+Z 撤销 · Esc 关闭", + "hide.state_done": "完成", "preset.clip": "剪切片段", "clip.start": "开始", "clip.end": "结束", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 2d8f1978..76a52b46 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -260,6 +260,23 @@ "crop.state_done": "已裁剪", "crop.state_skipped": "已跳過", "crop.state_failed": "失敗", + "preset.hide": "馬賽克/模糊", + "preset.hide.donut": "馬賽克", + "error.cannot_hide": "只能對圖片加上馬賽克或模糊", + "hide.save": "遮蓋並儲存", + "hide.save_next": "遮蓋並下一張", + "hide.pixelate": "馬賽克", + "hide.pixelate_hint": "以平均色方塊遮蓋 (P)", + "hide.blur": "模糊", + "hide.blur_hint": "以強烈模糊遮蓋 (B)", + "hide.strength": "強度", + "hide.strength_value": "強度 {0} / 10(− / +)", + "hide.remove": "刪除選框", + "hide.remove_hint": "刪除所選選框(Del)", + "hide.clear": "全部清除", + "hide.empty_hint": "拖曳框選要隱藏的部分", + "hide.hint_keys": "Tab 選擇選框,方向鍵移動,Shift 為 10 倍 · Del 刪除 · Ctrl+Z 復原 · Esc 關閉", + "hide.state_done": "完成", "preset.clip": "剪輯片段", "clip.start": "開始", "clip.end": "結束", diff --git a/src/Filee.App/Controls/CropCanvas.cs b/src/Filee.App/Controls/CropCanvas.cs index 9b7cfad6..3b0f358b 100644 --- a/src/Filee.App/Controls/CropCanvas.cs +++ b/src/Filee.App/Controls/CropCanvas.cs @@ -140,13 +140,13 @@ public double AspectRatio private static readonly Cursor TopRightCursor = new(StandardCursorType.TopRightCorner); private static readonly Cursor CrossCursor = new(StandardCursorType.Cross); - private static readonly IPen FrameShadowPen = new Pen(new SolidColorBrush(Color.FromArgb(70, 0, 0, 0)), 4); + internal static readonly IPen FrameShadowPen = new Pen(new SolidColorBrush(Color.FromArgb(70, 0, 0, 0)), 4); private static readonly IPen GridPen = new Pen(new SolidColorBrush(Color.FromArgb(170, 255, 255, 255)), 1); private static readonly IPen GridShadowPen = new Pen(new SolidColorBrush(Color.FromArgb(60, 0, 0, 0)), 1); private static readonly IPen HandleRingPen = new Pen(Brushes.White, 2.5); private static readonly IBrush HandleShadowBrush = new SolidColorBrush(Color.FromArgb(60, 0, 0, 0)); - private static readonly BoxShadows PictureShadow = BoxShadows.Parse("0 10 30 0 #38000000"); - private static readonly BoxShadows ChipShadow = BoxShadows.Parse("0 2 8 0 #26000000"); + internal static readonly BoxShadows PictureShadow = BoxShadows.Parse("0 10 30 0 #38000000"); + internal static readonly BoxShadows ChipShadow = BoxShadows.Parse("0 2 8 0 #26000000"); private static double SafeClamp(double val, double min, double max) => min > max ? Math.Clamp(val, max, min) : Math.Clamp(val, min, max); @@ -420,16 +420,18 @@ public override void Render(DrawingContext context) private bool IsDark => ActualThemeVariant == ThemeVariant.Dark; - /// The backdrop: the page colour dimmed, with two soft glows of the accent colour. - private void DrawStage(DrawingContext context, Rect bounds) + private void DrawStage(DrawingContext context, Rect bounds) => DrawStage(context, bounds, StageBrush, AccentBrush, IsDark); + + /// The backdrop: the page colour dimmed, with two soft glows of the accent colour (also the hide window's). + internal static void DrawStage(DrawingContext context, Rect bounds, IBrush? stageBrush, IBrush? accentBrush, bool dark) { - if (StageBrush is { } stage) + if (stageBrush is { } stage) context.FillRectangle(stage, bounds); - context.FillRectangle(new SolidColorBrush(Colors.Black, IsDark ? 0.28 : 0.06), bounds); + context.FillRectangle(new SolidColorBrush(Colors.Black, dark ? 0.28 : 0.06), bounds); - if (AccentBrush is ISolidColorBrush { Color: var accent }) + if (accentBrush is ISolidColorBrush { Color: var accent }) { - var strength = IsDark ? 0.16 : 0.22; + var strength = dark ? 0.16 : 0.22; Glow(context, accent, new Point(bounds.Width * 0.12, bounds.Height * 0.10), bounds.Width * 0.45, strength); Glow(context, accent, new Point(bounds.Width * 0.92, bounds.Height * 0.95), bounds.Width * 0.38, strength * 0.7); } @@ -499,18 +501,23 @@ private void DrawFrame(DrawingContext context, Rect crop, long now) if ((horizontal ? crop.Width : crop.Height) < 90) continue; var grow = handle == _poppedHandle ? pop : 0; - var length = 22 + 8 * grow; - const double thickness = 7; - var c = HandlePoint(crop, handle); - var rect = horizontal - ? new Rect(c.X - length / 2, c.Y - thickness / 2, length, thickness) - : new Rect(c.X - thickness / 2, c.Y - length / 2, thickness, length); - context.DrawRectangle(HandleShadowBrush, null, new RoundedRect(rect.Translate(new Vector(0, 1)).Inflate(1.5), thickness)); - context.DrawRectangle(accent, HandleRingPen, new RoundedRect(rect, thickness / 2)); + DrawPill(context, accent, HandlePoint(crop, handle), horizontal, 22 + 8 * grow); } } - private static void DrawDot(DrawingContext context, IBrush accent, Point center, double radius) + /// A handle on the middle of an edge: a pill in the accent colour with a white ring. + internal static void DrawPill(DrawingContext context, IBrush accent, Point c, bool horizontal, double length) + { + const double thickness = 7; + var rect = horizontal + ? new Rect(c.X - length / 2, c.Y - thickness / 2, length, thickness) + : new Rect(c.X - thickness / 2, c.Y - length / 2, thickness, length); + context.DrawRectangle(HandleShadowBrush, null, new RoundedRect(rect.Translate(new Vector(0, 1)).Inflate(1.5), thickness)); + context.DrawRectangle(accent, HandleRingPen, new RoundedRect(rect, thickness / 2)); + } + + /// A corner handle: a dot in the accent colour with a white ring. + internal static void DrawDot(DrawingContext context, IBrush accent, Point center, double radius) { context.DrawEllipse(HandleShadowBrush, null, center + new Vector(0, 1), radius + 2, radius + 2); context.DrawEllipse(accent, HandleRingPen, center, radius, radius); @@ -562,12 +569,12 @@ private void DrawSizeChip(DrawingContext context, Rect crop, Rect imgBounds, Rec /// internal static Func Clock { get; set; } = Stopwatch.GetTimestamp; - private static long Now() => Clock(); + internal static long Now() => Clock(); - private static double EaseOutCubic(double t) => 1 - Math.Pow(1 - Math.Clamp(t, 0, 1), 3); + internal static double EaseOutCubic(double t) => 1 - Math.Pow(1 - Math.Clamp(t, 0, 1), 3); /// Overshoots a little before settling: the "squishy" grow of a handle. - private static double EaseOutBack(double t) + internal static double EaseOutBack(double t) { t = Math.Clamp(t, 0, 1); const double s = 1.9; @@ -624,7 +631,7 @@ private void OnFrame(TimeSpan _) } /// A value easing from to over a time, read against the clock. - private readonly record struct Tween(double From, double To, long Start, double Seconds) + internal readonly record struct Tween(double From, double To, long Start, double Seconds) { public static Tween Done(double value) => new(value, value, 0, 0); diff --git a/src/Filee.App/Controls/HideAreaSet.cs b/src/Filee.App/Controls/HideAreaSet.cs new file mode 100644 index 00000000..eea6b497 --- /dev/null +++ b/src/Filee.App/Controls/HideAreaSet.cs @@ -0,0 +1,205 @@ +// The parts of a picture the "Pixelate / blur" window hides, without a window: the frames (in parts of the picture), +// the one selected, and undo / redo of adding, moving, sizing and removing them. The canvas draws and drags them, the +// keys move and remove them, the window's view model turns them into the conversion. + +using Avalonia; + +namespace Filee.App.Controls; + +public sealed class HideAreaSet +{ + /// Most parts one picture takes: more are a slip of the hand, not something to hide. + public const int MaxAreas = 100; + + /// Undo steps kept. + private const int MaxUndo = 100; + + private readonly List _areas = []; + private readonly List _undo = []; + private readonly List _redo = []; + + /// The state when a drag began (): one undo step however far it went. + private Snapshot? _gesture; + + private readonly record struct Snapshot(Rect[] Areas, int Selected); + + /// The frames, in parts of the picture (0 to 1), in the order they were drawn (the last on top). + public IReadOnlyList Areas => _areas; + + /// The selected frame, -1 for none. + public int Selected { get; private set; } = -1; + + public Rect? SelectedArea => Selected >= 0 ? _areas[Selected] : null; + public int Count => _areas.Count; + public bool CanUndo => _undo.Count > 0; + public bool CanRedo => _redo.Count > 0; + + /// Something changed: the frames, the selection or what can be undone. + public event Action? Changed; + + /// A drag starts: what it does until is undone in one step. + public void Begin() => _gesture ??= Take(); + + /// The drag ends: one undo step when it changed the frames. + public void End() + { + if (_gesture is not { } before) + return; + _gesture = null; + if (!before.Areas.SequenceEqual(_areas)) + Push(before); + Changed?.Invoke(); + } + + /// Adds a frame and selects it; false when there are already. + public bool Add(Rect area) + { + if (_areas.Count >= MaxAreas) + return false; + Record(); + _areas.Add(Fit(area)); + Selected = _areas.Count - 1; + Changed?.Invoke(); + return true; + } + + /// Moves or sizes frame (a drag; see ). + public void Set(int index, Rect area) + { + if (index < 0 || index >= _areas.Count) + return; + var fitted = Fit(area); + if (_areas[index] == fitted) + return; + Record(); + _areas[index] = fitted; + Changed?.Invoke(); + } + + public void Select(int index) + { + index = index >= 0 && index < _areas.Count ? index : -1; + if (index == Selected) + return; + Selected = index; + Changed?.Invoke(); + } + + /// + /// The next (or previous) frame in the order they were drawn, round and round: from none, the first (or the last). + /// + public void SelectNext(int step) + { + if (_areas.Count == 0) + return; + Select(Selected < 0 + ? (step >= 0 ? 0 : _areas.Count - 1) + : ((Selected + step) % _areas.Count + _areas.Count) % _areas.Count); + } + + /// Removes the selected frame; the one drawn before it is selected next. False when none is selected. + public bool RemoveSelected() + { + if (Selected < 0) + return false; + Record(); + _areas.RemoveAt(Selected); + Selected = _areas.Count == 0 ? -1 : Math.Max(0, Selected - 1); + Changed?.Invoke(); + return true; + } + + /// Removes every frame (undoable). + public void Clear() + { + if (_areas.Count == 0) + return; + Record(); + _areas.Clear(); + Selected = -1; + Changed?.Invoke(); + } + + /// Moves the selected frame by whole pixels of a × picture. + public void Nudge(int dx, int dy, int width, int height) + { + if (Selected < 0) + return; + Set(Selected, CropMath.Nudge(_areas[Selected], dx, dy, width, height)); + } + + /// + /// Starts over with (a new picture: the last one's frames carry on), none selected and + /// nothing to undo. + /// + public void Reset(IEnumerable areas) + { + _gesture = null; + _areas.Clear(); + _areas.AddRange(areas.Select(Fit).Take(MaxAreas)); + _undo.Clear(); + _redo.Clear(); + Selected = -1; + Changed?.Invoke(); + } + + public void Undo() => Step(_undo, _redo); + + public void Redo() => Step(_redo, _undo); + + /// + /// The frame on top under (in parts of the picture), -1 for none; the selected one first, so + /// a frame drawn over it later doesn't take it away while it is being worked on. + /// + public int HitTest(Point point) + { + if (Selected >= 0 && _areas[Selected].Contains(point)) + return Selected; + for (var i = _areas.Count - 1; i >= 0; i--) + { + if (_areas[i].Contains(point)) + return i; + } + return -1; + } + + private void Step(List from, List to) + { + if (from.Count == 0 || _gesture is not null) + return; + var snapshot = from[^1]; + from.RemoveAt(from.Count - 1); + to.Add(Take()); + _areas.Clear(); + _areas.AddRange(snapshot.Areas); + Selected = Math.Min(snapshot.Selected, _areas.Count - 1); + Changed?.Invoke(); + } + + /// An undo step for a change made now, unless a drag records one for itself. + private void Record() + { + if (_gesture is null) + Push(Take()); + } + + private void Push(Snapshot snapshot) + { + _undo.Add(snapshot); + if (_undo.Count > MaxUndo) + _undo.RemoveAt(0); + _redo.Clear(); + } + + private Snapshot Take() => new([.. _areas], Selected); + + /// The frame inside the picture. + private static Rect Fit(Rect area) + { + var left = Math.Clamp(Math.Min(area.Left, area.Right), 0, 1); + var top = Math.Clamp(Math.Min(area.Top, area.Bottom), 0, 1); + var right = Math.Clamp(Math.Max(area.Left, area.Right), left, 1); + var bottom = Math.Clamp(Math.Max(area.Top, area.Bottom), top, 1); + return new Rect(left, top, right - left, bottom - top); + } +} diff --git a/src/Filee.App/Controls/HideCanvas.cs b/src/Filee.App/Controls/HideCanvas.cs new file mode 100644 index 00000000..89a69418 --- /dev/null +++ b/src/Filee.App/Controls/HideCanvas.cs @@ -0,0 +1,644 @@ +// The "Pixelate / blur" window's stage, drawn as the crop overlay's: the picture on the dimmed backdrop with soft accent +// glows, and over it the frames of the parts to hide, each already showing the picture pixelated or blurred inside it +// (a copy of the whole preview covered once per choice, drawn clipped to the frames, so drawing and dragging cost +// nothing). The selected frame has the crop frame's accent outline, round corner handles and edge pills, and a size chip; +// the others a thinner, lighter accent outline that turns full under the pointer. Dragging on the picture draws a new +// frame, dragging a frame moves it (snapping to the picture's centre lines), its handles size it, the wheel grows or +// shrinks it. + +using System.Globalization; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Styling; +using static Filee.App.Controls.CropCanvas; + +namespace Filee.App.Controls; + +public sealed class HideCanvas : Control +{ + public static readonly StyledProperty SourceImageProperty = + AvaloniaProperty.Register(nameof(SourceImage)); + + /// The whole preview pixelated or blurred as chosen: what the frames show. + public static readonly StyledProperty CoverImageProperty = + AvaloniaProperty.Register(nameof(CoverImage)); + + public static readonly StyledProperty AreasProperty = + AvaloniaProperty.Register(nameof(Areas)); + + /// Room kept free around the picture for the floating chips and the toolbar. + public static readonly StyledProperty ImagePaddingProperty = + AvaloniaProperty.Register(nameof(ImagePadding)); + + /// The picture's size in pixels: the size chip, and arrow keys move frames by whole pixels of it. + public static readonly StyledProperty PixelSizeProperty = + AvaloniaProperty.Register(nameof(PixelSize)); + + public static readonly StyledProperty AccentBrushProperty = + AvaloniaProperty.Register(nameof(AccentBrush), Brushes.MediumPurple); + + public static readonly StyledProperty StageBrushProperty = + AvaloniaProperty.Register(nameof(StageBrush)); + + public static readonly StyledProperty ChipBackgroundProperty = + AvaloniaProperty.Register(nameof(ChipBackground), Brushes.White); + + public static readonly StyledProperty ChipForegroundProperty = + AvaloniaProperty.Register(nameof(ChipForeground), Brushes.Black); + + /// False with reduced motion: every change shows at once. + public static readonly StyledProperty PlayAnimationsProperty = + AvaloniaProperty.Register(nameof(PlayAnimations), true); + + public Bitmap? SourceImage { get => GetValue(SourceImageProperty); set => SetValue(SourceImageProperty, value); } + public Bitmap? CoverImage { get => GetValue(CoverImageProperty); set => SetValue(CoverImageProperty, value); } + public HideAreaSet? Areas { get => GetValue(AreasProperty); set => SetValue(AreasProperty, value); } + public Thickness ImagePadding { get => GetValue(ImagePaddingProperty); set => SetValue(ImagePaddingProperty, value); } + public PixelSize PixelSize { get => GetValue(PixelSizeProperty); set => SetValue(PixelSizeProperty, value); } + public IBrush? AccentBrush { get => GetValue(AccentBrushProperty); set => SetValue(AccentBrushProperty, value); } + public IBrush? StageBrush { get => GetValue(StageBrushProperty); set => SetValue(StageBrushProperty, value); } + public IBrush? ChipBackground { get => GetValue(ChipBackgroundProperty); set => SetValue(ChipBackgroundProperty, value); } + public IBrush? ChipForeground { get => GetValue(ChipForegroundProperty); set => SetValue(ChipForegroundProperty, value); } + public bool PlayAnimations { get => GetValue(PlayAnimationsProperty); set => SetValue(PlayAnimationsProperty, value); } + + /// The smallest frame a drag makes or a handle leaves, in screen pixels. + private const double MinSizePx = 6; + + /// How far the pointer has to move before a press on the picture becomes a new frame (else it is a click). + private const double DragThreshold = 4; + + private enum Drag + { + None, + Pending, + New, + Body, + TopLeft, + Top, + TopRight, + Right, + BottomRight, + Bottom, + BottomLeft, + Left, + } + + private Drag _drag = Drag.None; + private int _dragIndex = -1; + private Point _dragStartPoint; + private Rect _dragStartFrame; // on screen + private bool _snappedX, _snappedY; + private int _hoverIndex = -1; + private Drag _hoverHandle = Drag.None; + + // ───── Motion (the crop overlay's) ───── + + private const double AppearSeconds = 0.42; // a new picture + private const double HandleSeconds = 0.26; + private Tween _appear = Tween.Done(1); + private Tween _handlePop = Tween.Done(0); + private Drag _poppedHandle = Drag.None; + private bool _looping; + + private Bitmap? _soft; // a tiny copy: the frames' fill until the covered copy is ready + private FormattedText? _chipText; + private string? _chipTextFor; + private IBrush? _chipTextBrush; + + private static readonly Cursor ArrowCursor = new(StandardCursorType.Arrow); + private static readonly Cursor SizeAllCursor = new(StandardCursorType.SizeAll); + private static readonly Cursor SizeNorthSouthCursor = new(StandardCursorType.SizeNorthSouth); + private static readonly Cursor SizeWestEastCursor = new(StandardCursorType.SizeWestEast); + private static readonly Cursor TopLeftCursor = new(StandardCursorType.TopLeftCorner); + private static readonly Cursor TopRightCursor = new(StandardCursorType.TopRightCorner); + private static readonly Cursor CrossCursor = new(StandardCursorType.Cross); + + + + static HideCanvas() + { + AffectsRender(SourceImageProperty, CoverImageProperty, ImagePaddingProperty, AccentBrushProperty, + StageBrushProperty, ChipBackgroundProperty, ChipForegroundProperty, PixelSizeProperty); + FocusableProperty.OverrideDefaultValue(false); + } + + // The picture is never mirrored, also in a right-to-left UI. + public HideCanvas() => FlowDirection = FlowDirection.LeftToRight; + + /// The picture's place: as large as fits inside , centred. + public Rect CalculateImageBounds() + { + if (SourceImage is null || Bounds.Width <= 0 || Bounds.Height <= 0) + return default; + var area = new Rect(Bounds.Size).Deflate(ImagePadding); + if (area.Width <= 0 || area.Height <= 0) + area = new Rect(Bounds.Size); + var size = SourceImage.Size; + var scale = Math.Min(area.Width / size.Width, area.Height / size.Height); + var w = size.Width * scale; + var h = size.Height * scale; + return new Rect(area.X + (area.Width - w) / 2, area.Y + (area.Height - h) / 2, w, h); + } + + /// A frame (in parts of the picture) on screen. + public static Rect ToScreen(Rect area, Rect picture) => new( + picture.X + area.X * picture.Width, picture.Y + area.Y * picture.Height, + area.Width * picture.Width, area.Height * picture.Height); + + private static Rect ToPicture(Rect screen, Rect picture) => new( + (screen.X - picture.X) / picture.Width, (screen.Y - picture.Y) / picture.Height, + screen.Width / picture.Width, screen.Height / picture.Height); + + /// Ends every running animation (tests capture the settled picture). + internal void FinishAnimations() + { + _appear = Tween.Done(1); + _handlePop = Tween.Done(_handlePop.To); + InvalidateVisual(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + if (change.Property == AreasProperty) + { + if (change.GetOldValue() is { } old) + old.Changed -= OnAreasChanged; + if (change.GetNewValue() is { } areas) + areas.Changed += OnAreasChanged; + InvalidateVisual(); + } + else if (change.Property == SourceImageProperty) + { + _soft?.Dispose(); + _soft = Soften(change.GetNewValue()); + CancelDrag(); + if (change.GetNewValue() is not null && PlayAnimations) + { + _appear = Tween.Begin(0, 1, AppearSeconds); + StartLoop(); + } + else + { + _appear = Tween.Done(1); + } + } + else if (change.Property == PlayAnimationsProperty && !change.GetNewValue()) + { + FinishAnimations(); + } + base.OnPropertyChanged(change); + } + + private void OnAreasChanged() => InvalidateVisual(); + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + _soft ??= Soften(SourceImage); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + _soft?.Dispose(); + _soft = null; + } + + /// A copy at most 24 px across: drawn as large as the picture it is a soft blur, at no cost per frame. + private static Bitmap? Soften(Bitmap? source) + { + if (source is null) + return null; + try + { + var size = source.PixelSize; + var scale = Math.Min(1, 24.0 / Math.Max(size.Width, size.Height)); + var target = new PixelSize(Math.Max(1, (int)Math.Round(size.Width * scale)), Math.Max(1, (int)Math.Round(size.Height * scale))); + return source.CreateScaledBitmap(target, BitmapInterpolationMode.HighQuality); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + return null; + } + } + + // ───────────────────────── Drawing ───────────────────────── + + private bool IsDark => ActualThemeVariant == ThemeVariant.Dark; + + public override void Render(DrawingContext context) + { + base.Render(context); + var now = CropCanvas.Now(); + var bounds = new Rect(Bounds.Size); + CropCanvas.DrawStage(context, bounds, StageBrush, AccentBrush, IsDark); + + var picture = CalculateImageBounds(); + if (SourceImage is null || picture.Width <= 0 || picture.Height <= 0) + return; + + // A new picture settles in: a little smaller and transparent at first. + var appear = EaseOutCubic(_appear.Value(now)); + var scale = 0.965 + 0.035 * appear; + var center = picture.Center; + using var appearing = context.PushTransform( + Matrix.CreateTranslation(-center.X, -center.Y) * Matrix.CreateScale(scale, scale) * Matrix.CreateTranslation(center.X, center.Y)); + using var fading = context.PushOpacity(appear); + + context.DrawRectangle(StageBrush ?? Brushes.Gray, null, new RoundedRect(picture), PictureShadow); + using (context.PushRenderOptions(new RenderOptions { BitmapInterpolationMode = BitmapInterpolationMode.HighQuality })) + context.DrawImage(SourceImage, new Rect(SourceImage.Size), picture); + + if (Areas is not { } areas) + return; + + // Inside every frame the covered picture: crisp squares for pixelate (the copy has them at the preview's size). + var cover = CoverImage ?? _soft; + for (var i = 0; i < areas.Count; i++) + { + var frame = ToScreen(areas.Areas[i], picture); + if (cover is null || frame.Width <= 0 || frame.Height <= 0) + continue; + using (context.PushClip(frame)) + using (context.PushRenderOptions(new RenderOptions + { + BitmapInterpolationMode = CoverImage is null ? BitmapInterpolationMode.HighQuality : BitmapInterpolationMode.MediumQuality, + })) + context.DrawImage(cover, new Rect(cover.Size), picture); + } + + var accent = AccentBrush ?? Brushes.MediumPurple; + for (var i = 0; i < areas.Count; i++) + { + if (i == areas.Selected) + continue; + var frame = Snap(ToScreen(areas.Areas[i], picture)); + if (i == _hoverIndex && _drag == Drag.None) + { + context.DrawRectangle(null, FrameShadowPen, frame); + context.DrawRectangle(null, new Pen(accent, 2), frame); + } + else + { + using (context.PushOpacity(0.75)) + context.DrawRectangle(null, new Pen(accent, 1.5), frame); + } + } + + DrawCentreGuides(context, picture, accent); + if (areas.SelectedArea is { } selected) + { + var frame = ToScreen(selected, picture); + DrawSelected(context, frame, accent, now); + DrawSizeChip(context, frame, selected, picture, bounds); + } + } + + /// A frame on whole screen pixels, so its outline stays sharp. + private static Rect Snap(Rect frame) => + new(Math.Round(frame.X), Math.Round(frame.Y), Math.Max(1, Math.Round(frame.Width)), Math.Max(1, Math.Round(frame.Height))); + + private void DrawSelected(DrawingContext context, Rect frame, IBrush accent, long now) + { + context.DrawRectangle(null, FrameShadowPen, frame); + context.DrawRectangle(null, new Pen(accent, 2), frame); + + var pop = EaseOutBack(_handlePop.Value(now)); + // Smaller dots on a small frame, so they don't cover it. + var dot = Math.Clamp(Math.Min(frame.Width, frame.Height) / 4, 3.5, 6.5); + foreach (var handle in new[] { Drag.TopLeft, Drag.TopRight, Drag.BottomRight, Drag.BottomLeft }) + { + var grow = handle == _poppedHandle ? pop : 0; + DrawDot(context, accent, HandlePoint(frame, handle), dot + 2.5 * grow); + } + foreach (var handle in new[] { Drag.Top, Drag.Right, Drag.Bottom, Drag.Left }) + { + var horizontal = handle is Drag.Top or Drag.Bottom; + if ((horizontal ? frame.Width : frame.Height) < 90) + continue; + var grow = handle == _poppedHandle ? pop : 0; + DrawPill(context, accent, HandlePoint(frame, handle), horizontal, 22 + 8 * grow); + } + } + + private void DrawCentreGuides(DrawingContext context, Rect picture, IBrush accent) + { + if (_drag != Drag.Body || !(_snappedX || _snappedY)) + return; + var pen = new Pen(accent, 1, DashStyle.Dash); + var x = Math.Round(picture.Center.X) + 0.5; + var y = Math.Round(picture.Center.Y) + 0.5; + if (_snappedX) + context.DrawLine(pen, new Point(x, picture.Top), new Point(x, picture.Bottom)); + if (_snappedY) + context.DrawLine(pen, new Point(picture.Left, y), new Point(picture.Right, y)); + } + + /// How far below the picture the size chip may go (the toolbar starts further down). + private const double ChipReach = 40; + + private void DrawSizeChip(DrawingContext context, Rect frame, Rect area, Rect picture, Rect bounds) + { + var pixels = PixelSize; + if (pixels.Width <= 0 || pixels.Height <= 0) + return; + var size = CropMath.PixelSize(area, pixels.Width, pixels.Height); + var text = $"{size.Width} × {size.Height} px"; + var foreground = ChipForeground ?? Brushes.Black; + if (_chipText is null || _chipTextFor != text || !ReferenceEquals(_chipTextBrush, foreground)) + { + var typeface = new Typeface(GetValue(TextElement.FontFamilyProperty), FontStyle.Normal, FontWeight.SemiBold); + _chipText = new FormattedText(text, CultureInfo.CurrentUICulture, FlowDirection.LeftToRight, typeface, 11.5, foreground); + _chipTextFor = text; + _chipTextBrush = foreground; + } + var chip = new Size(Math.Ceiling(_chipText.Width) + 20, Math.Ceiling(_chipText.Height) + 8); + var room = new Rect(picture.Left, picture.Top, picture.Width, picture.Height + ChipReach).Intersect(bounds.Deflate(6)); + var origin = CropMath.SizeChipOrigin(frame, chip, room); + context.DrawRectangle(ChipBackground, null, new RoundedRect(new Rect(origin, chip), chip.Height / 2), ChipShadow); + context.DrawText(_chipText, new Point(origin.X + 10, origin.Y + 4)); + } + + private static Point HandlePoint(Rect frame, Drag handle) => handle switch + { + Drag.TopLeft => frame.TopLeft, + Drag.TopRight => frame.TopRight, + Drag.BottomRight => frame.BottomRight, + Drag.BottomLeft => frame.BottomLeft, + Drag.Top => new Point(frame.Center.X, frame.Top), + Drag.Bottom => new Point(frame.Center.X, frame.Bottom), + Drag.Left => new Point(frame.Left, frame.Center.Y), + Drag.Right => new Point(frame.Right, frame.Center.Y), + _ => frame.Center, + }; + + // ───────────────────────── Motion ───────────────────────── + + private void PopHandle(Drag handle) + { + if (handle is Drag.None or Drag.Pending or Drag.New or Drag.Body) + handle = Drag.None; + if (handle == _poppedHandle) + return; + _poppedHandle = handle; + _handlePop = handle == Drag.None || !PlayAnimations + ? Tween.Done(handle == Drag.None ? 0 : 1) + : Tween.Begin(0, 1, HandleSeconds); + StartLoop(); + } + + private void StartLoop() + { + InvalidateVisual(); + if (_looping || TopLevel.GetTopLevel(this) is not { } top) + return; + _looping = true; + top.RequestAnimationFrame(OnFrame); + } + + private void OnFrame(TimeSpan _) + { + InvalidateVisual(); + var now = CropCanvas.Now(); + if ((_appear.IsRunning(now) || _handlePop.IsRunning(now)) && TopLevel.GetTopLevel(this) is { } top) + { + top.RequestAnimationFrame(OnFrame); + return; + } + _looping = false; + } + + // ───────────────────────── Pointer ───────────────────────── + + /// What a press at would drag, and which frame. + private (Drag Drag, int Index) HitTest(Point point, Rect picture) + { + if (Areas is not { } areas || picture.Width <= 0 || picture.Height <= 0) + return (Drag.None, -1); + if (areas.SelectedArea is { } selected) + { + var frame = ToScreen(selected, picture); + // A small frame keeps room in its middle for moving it. + var tolerance = Math.Clamp(Math.Min(frame.Width, frame.Height) / 3, 5, 14); + foreach (var corner in new[] { Drag.TopLeft, Drag.TopRight, Drag.BottomLeft, Drag.BottomRight }) + { + if (Distance(point, HandlePoint(frame, corner)) <= tolerance) + return (corner, areas.Selected); + } + var alongX = point.X >= frame.Left && point.X <= frame.Right; + var alongY = point.Y >= frame.Top && point.Y <= frame.Bottom; + if (alongX && Math.Abs(point.Y - frame.Top) <= tolerance) return (Drag.Top, areas.Selected); + if (alongX && Math.Abs(point.Y - frame.Bottom) <= tolerance) return (Drag.Bottom, areas.Selected); + if (alongY && Math.Abs(point.X - frame.Left) <= tolerance) return (Drag.Left, areas.Selected); + if (alongY && Math.Abs(point.X - frame.Right) <= tolerance) return (Drag.Right, areas.Selected); + } + var index = areas.HitTest(ToPicture(new Rect(point, new Size(0, 0)), picture).TopLeft); + if (index >= 0) + return (Drag.Body, index); + return picture.Contains(point) ? (Drag.Pending, -1) : (Drag.None, -1); + } + + private static double Distance(Point a, Point b) => Math.Sqrt(Math.Pow(a.X - b.X, 2) + Math.Pow(a.Y - b.Y, 2)); + + private static Cursor CursorFor(Drag drag) => drag switch + { + Drag.TopLeft or Drag.BottomRight => TopLeftCursor, + Drag.TopRight or Drag.BottomLeft => TopRightCursor, + Drag.Top or Drag.Bottom => SizeNorthSouthCursor, + Drag.Left or Drag.Right => SizeWestEastCursor, + Drag.Body => SizeAllCursor, + Drag.Pending or Drag.New => CrossCursor, + _ => ArrowCursor, + }; + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + if (_drag != Drag.None) + return; + _hoverHandle = Drag.None; + _hoverIndex = -1; + PopHandle(Drag.None); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + var picture = CalculateImageBounds(); + if (Areas is not { } areas || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed || picture.Width <= 0) + return; + var point = e.GetPosition(this); + var (drag, index) = HitTest(point, picture); + if (drag == Drag.None) + return; + + _drag = drag; + _dragIndex = index; + _dragStartPoint = point; + _snappedX = _snappedY = false; + if (index >= 0) + { + areas.Select(index); + _dragStartFrame = ToScreen(areas.Areas[index], picture); + areas.Begin(); + } + e.Pointer.Capture(this); + e.Handled = true; + PopHandle(drag); + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + var picture = CalculateImageBounds(); + if (Areas is not { } areas || picture.Width <= 0 || picture.Height <= 0) + return; + var point = e.GetPosition(this); + + if (_drag == Drag.None) + { + var (hover, index) = HitTest(point, picture); + Cursor = CursorFor(hover); + if (index != _hoverIndex) + { + _hoverIndex = index; + InvalidateVisual(); + } + _hoverHandle = hover; + PopHandle(hover); + return; + } + + var start = Clamp(_dragStartPoint, picture); + var at = Clamp(point, picture); + switch (_drag) + { + case Drag.Pending: + if (Distance(point, _dragStartPoint) < DragThreshold) + return; + areas.Begin(); + if (!areas.Add(ToPicture(FrameBetween(start, at, picture), picture))) + { + areas.End(); + _drag = Drag.None; + return; + } + _drag = Drag.New; + _dragIndex = areas.Selected; + break; + case Drag.New: + areas.Set(_dragIndex, ToPicture(FrameBetween(start, at, picture), picture)); + break; + case Drag.Body: + { + var x = SafeClamp(_dragStartFrame.X + point.X - _dragStartPoint.X, picture.Left, picture.Right - _dragStartFrame.Width); + var y = SafeClamp(_dragStartFrame.Y + point.Y - _dragStartPoint.Y, picture.Top, picture.Bottom - _dragStartFrame.Height); + (x, _snappedX) = CropMath.SnapToCentre(x, _dragStartFrame.Width, picture.Left, picture.Right); + (y, _snappedY) = CropMath.SnapToCentre(y, _dragStartFrame.Height, picture.Top, picture.Bottom); + var moved = new Rect(x, y, _dragStartFrame.Width, _dragStartFrame.Height); + // Kept at its size in parts of the picture: rounding on the way to the screen and back must not resize it. + var old = areas.Areas[_dragIndex]; + var next = ToPicture(moved, picture); + areas.Set(_dragIndex, new Rect(next.X, next.Y, old.Width, old.Height)); + break; + } + default: + areas.Set(_dragIndex, ToPicture(Resize(_drag, _dragStartFrame, point - _dragStartPoint, picture), picture)); + break; + } + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (_drag == Drag.None) + return; + // A click on the picture beside every frame: nothing selected. + if (_drag == Drag.Pending) + Areas?.Select(-1); + EndDrag(); + e.Pointer.Capture(null); + e.Handled = true; + PopHandle(_hoverHandle); + } + + protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e) + { + base.OnPointerCaptureLost(e); + if (_drag != Drag.None) + { + EndDrag(); + PopHandle(Drag.None); + } + } + + private void EndDrag() + { + var wasDragging = _drag is not (Drag.None or Drag.Pending); + _drag = Drag.None; + _dragIndex = -1; + _snappedX = _snappedY = false; + if (wasDragging) + Areas?.End(); + InvalidateVisual(); + } + + private void CancelDrag() + { + if (_drag != Drag.None) + EndDrag(); + _hoverIndex = -1; + } + + /// The wheel grows or shrinks the selected frame about its centre. + protected override void OnPointerWheelChanged(PointerWheelEventArgs e) + { + base.OnPointerWheelChanged(e); + var picture = CalculateImageBounds(); + if (Areas is not { SelectedArea: { } selected } areas || picture.Width <= 0 || Math.Abs(e.Delta.Y) < 0.001 || _drag != Drag.None) + return; + var frame = ToScreen(selected, picture); + var factor = e.Delta.Y > 0 ? 1.05 : 0.95; + var w = SafeClamp(frame.Width * factor, MinSizePx, picture.Width); + var h = SafeClamp(frame.Height * factor, MinSizePx, picture.Height); + var x = SafeClamp(frame.Center.X - w / 2, picture.Left, picture.Right - w); + var y = SafeClamp(frame.Center.Y - h / 2, picture.Top, picture.Bottom - h); + areas.Set(areas.Selected, ToPicture(new Rect(x, y, w, h), picture)); + e.Handled = true; + } + + private static Point Clamp(Point point, Rect picture) => + new(Math.Clamp(point.X, picture.Left, picture.Right), Math.Clamp(point.Y, picture.Top, picture.Bottom)); + + private static double SafeClamp(double value, double min, double max) => + min > max ? Math.Clamp(value, max, min) : Math.Clamp(value, min, max); + + /// The frame between where a drag began and the pointer, at least , on the picture. + private static Rect FrameBetween(Point a, Point b, Rect picture) + { + var w = Math.Max(MinSizePx, Math.Abs(b.X - a.X)); + var h = Math.Max(MinSizePx, Math.Abs(b.Y - a.Y)); + var x = SafeClamp(b.X < a.X ? a.X - w : a.X, picture.Left, picture.Right - w); + var y = SafeClamp(b.Y < a.Y ? a.Y - h : a.Y, picture.Top, picture.Bottom - h); + return new Rect(x, y, w, h); + } + + /// A frame sized by one of its handles, its other sides staying put, within the picture. + private static Rect Resize(Drag handle, Rect start, Vector delta, Rect picture) + { + double left = start.Left, top = start.Top, right = start.Right, bottom = start.Bottom; + if (handle is Drag.TopLeft or Drag.Left or Drag.BottomLeft) + left = SafeClamp(start.Left + delta.X, picture.Left, right - MinSizePx); + if (handle is Drag.TopRight or Drag.Right or Drag.BottomRight) + right = SafeClamp(start.Right + delta.X, left + MinSizePx, picture.Right); + if (handle is Drag.TopLeft or Drag.Top or Drag.TopRight) + top = SafeClamp(start.Top + delta.Y, picture.Top, bottom - MinSizePx); + if (handle is Drag.BottomLeft or Drag.Bottom or Drag.BottomRight) + bottom = SafeClamp(start.Bottom + delta.Y, top + MinSizePx, picture.Bottom); + return new Rect(left, top, right - left, bottom - top); + } +} diff --git a/src/Filee.App/Services/HideService.cs b/src/Filee.App/Services/HideService.cs new file mode 100644 index 00000000..022c0735 --- /dev/null +++ b/src/Filee.App/Services/HideService.cs @@ -0,0 +1,93 @@ +// Pictures for the "Pixelate / blur" window: the preview, read as the image engine reads the file (so the frames land on +// the picture the conversion covers), and copies of it pixelated or blurred as chosen, which the frames show. + +using System.Runtime.InteropServices; +using Avalonia; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Filee.Core.Formats; +using Filee.Core.Presets; +using Filee.Engines.Magick; +using ImageMagick; + +namespace Filee.App.Services; + +public static class HideService +{ + /// Longest side of the preview: huge pictures are shown smaller, for a fluid window. + public const int MaxPreviewSide = 2560; + + /// True when the window can open a file: a picture the image engine reads. + public static bool CanOpen(string formatId) => + HideTool.CanHide(formatId) && ImageEncoder.Sources.Contains(formatId); + + /// + /// Reads as the conversion will (the composite of a PSD, the largest size of an icon, turned + /// as its EXIF orientation says) and makes a preview of it at most across. The size is + /// the picture's own. Animations and multi-page files are refused: the frames drawn on the first page would hide + /// nothing on the others. + /// + public static Task<(MagickImage Preview, int Width, int Height)> LoadPreviewAsync(string path, Preset tool) => + Task.Run(() => + { + var format = FormatRegistry.Detect(path)?.Id ?? throw new NotSupportedException("Unknown picture format."); + if (format == "png" && Apng.IsAnimated(path)) + throw new NotSupportedException("Animated PNG files can't have parts hidden."); + using var images = ImageReader.Read(path, format, tool); + var frames = ImageEncoder.SelectFrames(images, format, format); + if (frames.Count != 1) + throw new NotSupportedException("Animated or multi-page pictures can't have parts hidden."); + var preview = (MagickImage)frames[0].Clone(); + try + { + preview.AutoOrient(); + var (width, height) = ((int)preview.Width, (int)preview.Height); + if (preview.Width > MaxPreviewSide || preview.Height > MaxPreviewSide) + preview.Resize(new MagickGeometry(MaxPreviewSide, MaxPreviewSide) { Greater = true }); + preview.ResetPage(); + return (preview, width, height); + } + catch + { + preview.Dispose(); + throw; + } + }); + + /// + /// The size of the squares (or the blur) on the preview, for a picture of × + /// shown at pixels across: the conversion's, scaled. A square is a whole number of + /// the picture's pixels, as the conversion makes it. + /// + public static double PreviewEffectSize(HideStyle style, int strength, int width, int height, int previewWidth) + { + var size = HideTool.EffectSize(strength, width, height); + if (style == HideStyle.Pixelate) + size = Math.Round(size); + return size * previewWidth / Math.Max(1, width); + } + + /// A copy of covered all over: what a frame shows inside it. + public static Bitmap Cover(MagickImage preview, HideStyle style, double size) + { + using var copy = preview.Clone(); + AreaHider.Cover(copy, style, size); + return ToBitmap(copy); + } + + /// The picture as a bitmap the window draws. + public static Bitmap ToBitmap(IMagickImage image) + { + var pixels = image.ToByteArray(MagickFormat.Bgra); + var handle = GCHandle.Alloc(pixels, GCHandleType.Pinned); + try + { + return new Bitmap(PixelFormat.Bgra8888, AlphaFormat.Unpremul, handle.AddrOfPinnedObject(), + new PixelSize((int)image.Width, (int)image.Height), new Vector(96, 96), (int)image.Width * 4); + } + finally + { + handle.Free(); + } + } +} diff --git a/src/Filee.App/Services/PresetAvailability.cs b/src/Filee.App/Services/PresetAvailability.cs index b4ee3947..6184fcb5 100644 --- a/src/Filee.App/Services/PresetAvailability.cs +++ b/src/Filee.App/Services/PresetAvailability.cs @@ -119,6 +119,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.IsHideTool) + return formats.Any(f => HideService.CanOpen(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 c6c23879..98dec687 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 HideWindow? _hideWindow; internal ClipWindow? ClipWindow => _clipWindow; + internal HideWindow? HideWindow => _hideWindow; private readonly HashSet _spreadsheetStudios = []; internal IReadOnlyCollection SpreadsheetStudios => _spreadsheetStudios; @@ -121,13 +123,15 @@ public sealed class WindowService(ILocalizer loc, IServiceProvider services, Mem public MainWindow? Main => _main; /// - /// Opens the window of a tool preset (crop, cut a clip, the spreadsheet print studio: one per workbook, the PDF page - /// editor) on the files. A tool this version doesn't know opens nothing. + /// Opens the window of a tool preset (crop, cut a clip, pixelate / blur, the spreadsheet print studio: one per + /// workbook, the PDF page editor) on the files. A tool this version doesn't know opens nothing. /// public void ShowTool(IReadOnlyList files, Preset preset, PixelPoint? near = null) { if (preset.IsClipTool) ShowClipWindow(files, preset, near); + else if (preset.IsHideTool) + ShowHideWindow(files, preset, near); // 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,38 @@ public void ShowClipWindow(IReadOnlyList files, Preset preset, PixelPoin win.Activate(); } + /// + /// Opens the "Pixelate / blur" window on the pictures among the files. Each picture is written by an ordinary + /// conversion, shown in the toast. + /// + public void ShowHideWindow(IReadOnlyList files, Preset preset, PixelPoint? near = null) + { + var pictures = files.Where(f => FormatRegistry.Detect(f) is { } format && HideService.CanOpen(format.Id)).ToList(); + if (pictures.Count == 0) + return; + _hideWindow?.Close(); + var store = services.GetRequiredService(); + var catalog = services.GetRequiredService(); + var conversions = services.GetRequiredService(); + var log = services.GetService>(); + var planner = catalog.CreatePlanner(); + var vm = new HideWindowViewModel(pictures, preset, loc, store, + path => HideTool.TargetFor(FormatRegistry.Detect(path)?.Id ?? "", planner), + (path, conversion) => conversions.Start([path], conversion), log); + var win = new HideWindow { DataContext = vm }; + PrepareWindow(win); + win.PositionOnScreen(near); + win.Closed += (_, _) => + { + if (_hideWindow == win) + _hideWindow = null; + trimmer.Request(); + }; + _hideWindow = win; + win.Show(); + win.Activate(); + } + /// Opens the interactive crop overlay for the files. public void ShowCropOverlay(IReadOnlyList files, Preset preset, PixelPoint? near = null) { diff --git a/src/Filee.App/Styles/Icons.axaml b/src/Filee.App/Styles/Icons.axaml index c8c0588b..ac9152fe 100644 --- a/src/Filee.App/Styles/Icons.axaml +++ b/src/Filee.App/Styles/Icons.axaml @@ -36,6 +36,8 @@ M12.5,8C9.85,8 7.45,9 5.6,10.6L2,7V16H11L7.38,12.38C8.77,11.22 10.54,10.5 12.5,10.5C16.04,10.5 19.05,12.81 20.1,16L22.47,15.22C21.08,11.03 17.15,8 12.5,8Z M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,7A5,5 0 0,1 17,12A5,5 0 0,1 12,17A5,5 0 0,1 7,12A5,5 0 0,1 12,7Z M7,17V1H5V5H1V7H5V17A2,2 0 0,0 7,19H17V23H19V19H23V17M17,15H19V7C19,5.89 18.1,5 17,5H9V7H17V15Z + + M3,3H9V9H3Z M15,3H21V9H15Z M9,9H15V15H9Z M3,15H9V21H3Z M15,15H21V21H15Z M9.64,7.64C9.87,7.14 10,6.59 10,6A4,4 0 0,0 6,2A4,4 0 0,0 2,6A4,4 0 0,0 6,10C6.59,10 7.14,9.87 7.64,9.64L10,12L7.64,14.36C7.14,14.13 6.59,14 6,14A4,4 0 0,0 2,18A4,4 0 0,0 6,22A4,4 0 0,0 10,18C10,17.41 9.87,16.86 9.64,16.36L12,14L19,21H22V20L9.64,7.64M6,8A2,2 0 0,1 4,6A2,2 0 0,1 6,4A2,2 0 0,1 8,6A2,2 0 0,1 6,8M6,20A2,2 0 0,1 4,18A2,2 0 0,1 6,16A2,2 0 0,1 8,18A2,2 0 0,1 6,20M19,3L13,9L15,11L22,4V3H19Z M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z diff --git a/src/Filee.App/ViewModels/CropOverlayViewModel.cs b/src/Filee.App/ViewModels/CropOverlayViewModel.cs index 20a0c8c6..8a9ef5ed 100644 --- a/src/Filee.App/ViewModels/CropOverlayViewModel.cs +++ b/src/Filee.App/ViewModels/CropOverlayViewModel.cs @@ -520,8 +520,12 @@ public enum CropItemState Failed, } -/// One file of the crop overlay's strip: a small thumbnail once it has been opened, and how it went. -public sealed partial class CropQueueItem(ILocalizer loc, string name, int index) : ObservableObject, IDisposable +/// +/// One file of the strip of the crop overlay (and of the "Pixelate / blur" window): a small thumbnail once it has been +/// opened, and how it went. names a file that is done. +/// +public sealed partial class CropQueueItem(ILocalizer loc, string name, int index, string doneKey = "crop.state_done") + : ObservableObject, IDisposable { /// Longest side of a thumbnail in the strip (at twice its size on screen, for high-DPI displays). private const int ThumbnailSide = 72; @@ -553,7 +557,7 @@ public sealed partial class CropQueueItem(ILocalizer loc, string name, int index public string StateText => loc[State switch { CropItemState.Current => "crop.state_current", - CropItemState.Done => "crop.state_done", + CropItemState.Done => doneKey, CropItemState.Skipped => "crop.state_skipped", CropItemState.Failed => "crop.state_failed", _ => "crop.state_waiting", diff --git a/src/Filee.App/ViewModels/HideWindowViewModel.cs b/src/Filee.App/ViewModels/HideWindowViewModel.cs new file mode 100644 index 00000000..25e6cd24 --- /dev/null +++ b/src/Filee.App/ViewModels/HideWindowViewModel.cs @@ -0,0 +1,495 @@ +// State of the "Pixelate / blur" window: one picture at a time, as in the crop overlay, with frames drawn over the parts +// to hide, each showing them already pixelated or blurred; the choice of the two and how strongly (kept in the tool for +// the next time); and the conversion that writes the picture with those parts covered, which runs in the toast like +// any other. The frames carry on to the next picture of a batch (screenshots of one app put names in the same place). + +using System.Collections.ObjectModel; +using System.IO; +using Avalonia; +using Avalonia.Media.Imaging; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Filee.App.Controls; +using Filee.App.Services; +using Filee.Core.Localization; +using Filee.Core.Presets; +using Filee.Core.Settings; +using ImageMagick; +using Microsoft.Extensions.Logging; + +namespace Filee.App.ViewModels; + +public sealed partial class HideWindowViewModel : ObservableObject, IDisposable +{ + private readonly IReadOnlyList _files; + private readonly Preset _tool; + private readonly ILocalizer _loc; + private readonly UserDataStore _store; + private readonly Func _targetFor; + private readonly Action _export; + private readonly ILogger? _log; + + private MagickImage? _preview; + private int _coverVersion; + private int _coverShown; + private bool _closed; + + /// The frames of the last picture saved, for the next one. + private List _carried = []; + + [ObservableProperty] private int _currentIndex; + [ObservableProperty] private string _currentFileName = ""; + [ObservableProperty] private string _counterText = ""; + [ObservableProperty] private bool _hasMultipleFiles; + [ObservableProperty] private Bitmap? _previewImage; + [ObservableProperty] private Bitmap? _coverImage; + [ObservableProperty] private string _saveText = ""; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(PixelSize))] + private int _originalWidth; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(PixelSize))] + private int _originalHeight; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanSave), nameof(ShowEmptyHint))] + [NotifyCanExecuteChangedFor(nameof(SaveCommand))] + private bool _isBusy; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsPixelate), nameof(IsBlur))] + private HideStyle _style; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(StrengthText))] + private int _strength; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasStatusNotice))] + private string _statusNotice = ""; + + /// + /// All files are done and some could not be opened: the window stays open on and + /// until the user closes it. + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsEditing), nameof(CanSave), nameof(ShowEmptyHint))] + [NotifyCanExecuteChangedFor(nameof(SaveCommand))] + private bool _isFinished; + + [ObservableProperty] private string _summary = ""; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasDone))] + private int _doneCount; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasSkipped))] + private int _skippedCount; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasFailed))] + private int _failedCount; + + /// The parts of the current picture to hide. + public HideAreaSet Areas { get; } = new(); + + /// "file: reason" for each file that could not be opened. + public ObservableCollection Problems { get; } = []; + + /// Every file of the run with how it went, for the strip of thumbnails. + public ObservableCollection Queue { get; } = []; + + public PixelSize PixelSize => new(OriginalWidth, OriginalHeight); + public bool IsEditing => !IsFinished; + public bool HasStatusNotice => !string.IsNullOrWhiteSpace(StatusNotice); + public bool HasDone => DoneCount > 0; + public bool HasSkipped => SkippedCount > 0; + public bool HasFailed => FailedCount > 0; + public bool HasAreas => Areas.Count > 0; + public bool HasSelection => Areas.Selected >= 0; + public bool CanUndo => Areas.CanUndo; + public bool CanRedo => Areas.CanRedo; + + /// Saving needs a part to hide: without one the copy would only be the picture again. + public bool CanSave => IsEditing && !IsBusy && HasAreas; + + /// "Drag over what you want to hide", on a picture without frames. + public bool ShowEmptyHint => IsEditing && !IsBusy && !HasAreas && PreviewImage is not null; + + public bool IsPixelate + { + get => Style == HideStyle.Pixelate; + set + { + if (value) + Style = HideStyle.Pixelate; + } + } + + public bool IsBlur + { + get => Style == HideStyle.Blur; + set + { + if (value) + Style = HideStyle.Blur; + } + } + + /// "Strength: 5", for the slider's tooltip and screen readers. + public string StrengthText => _loc.Format("hide.strength_value", Strength); + + public int MinStrength => HideTool.MinStrength; + public int MaxStrength => HideTool.MaxStrength; + + public event Action? CloseRequested; + + /// The pictures, one after the other (other files are left out by the caller). + /// The "Pixelate / blur" tool: the last choice, its name and output rule. + /// The format a picture at a path is written in (). + /// Starts the conversion that writes a picture (the file, the conversion's preset). + public HideWindowViewModel(IReadOnlyList files, Preset tool, ILocalizer loc, UserDataStore store, + Func targetFor, Action export, ILogger? log = null) + { + _files = files; + _tool = tool; + _loc = loc; + _store = store; + _targetFor = targetFor; + _export = export; + _log = log; + _style = tool.Image.HideStyle; + _strength = HideTool.ClampStrength(tool.Image.HideStrength); + _hasMultipleFiles = files.Count > 1; + for (var i = 0; i < files.Count; i++) + Queue.Add(new CropQueueItem(loc, Path.GetFileName(files[i]), i, "hide.state_done")); + Areas.Changed += OnAreasChanged; + loc.LanguageChanged += OnLanguageChanged; + _ = LoadFileAsync(0); + } + + partial void OnStyleChanged(HideStyle value) => RefreshCover(); + + partial void OnStrengthChanged(int value) + { + var clamped = HideTool.ClampStrength(value); + if (clamped != value) + { + Strength = clamped; + return; + } + RefreshCover(); + } + + partial void OnPreviewImageChanged(Bitmap? value) => OnPropertyChanged(nameof(ShowEmptyHint)); + + private void OnAreasChanged() + { + OnPropertyChanged(nameof(HasAreas)); + OnPropertyChanged(nameof(HasSelection)); + OnPropertyChanged(nameof(CanUndo)); + OnPropertyChanged(nameof(CanRedo)); + OnPropertyChanged(nameof(CanSave)); + OnPropertyChanged(nameof(ShowEmptyHint)); + SaveCommand.NotifyCanExecuteChanged(); + } + + /// Opens picture ; past the last one, the end. + public async Task LoadFileAsync(int index) + { + if (_closed) + return; + if (index < 0 || index >= _files.Count) + { + Finish(); + return; + } + + IsBusy = true; + CurrentIndex = index; + var path = _files[index]; + CurrentFileName = Path.GetFileName(path); + CounterText = _loc.Format("crop.page_counter", index + 1, _files.Count); + SaveText = _loc[index < _files.Count - 1 ? "hide.save_next" : "hide.save"]; + Queue[index].State = CropItemState.Current; + + try + { + var (preview, width, height) = await HideService.LoadPreviewAsync(path, _tool); + var bitmap = await Task.Run(() => HideService.ToBitmap(preview)); + if (_closed) + { + preview.Dispose(); + bitmap.Dispose(); + return; + } + ReplacePreview(preview); + var old = PreviewImage; + OriginalWidth = width; + OriginalHeight = height; + PreviewImage = bitmap; + old?.Dispose(); + Queue[index].SetThumbnail(bitmap); + Areas.Reset(_carried); + RefreshCover(); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + _log?.LogInformation(ex, "Could not open {File} for hiding parts", path); + if (_closed) + return; + // The animated / multi-page check throws NotSupportedException: its English message stays in the log. + var unsupported = ex is NotSupportedException; + AddProblem(path, unsupported ? "error.unsupported_source" : "error.conversion_failed"); + Queue[index].State = CropItemState.Failed; + IsBusy = false; + await LoadFileAsync(index + 1); + return; + } + finally + { + if (!_closed && CurrentIndex == index) + IsBusy = false; + } + } + + /// + /// Covers the preview again as chosen, off the window's thread: the frames show the picture as it is until then + /// (softly blurred). Only the newest result is kept, so dragging the strength never queues up work. + /// + private void RefreshCover() + { + if (_preview is not { } preview || _closed) + return; + var version = ++_coverVersion; + var style = Style; + var size = HideService.PreviewEffectSize(style, Strength, OriginalWidth, OriginalHeight, (int)preview.Width); + _ = Task.Run(() => + { + Bitmap? cover = null; + try + { + lock (preview) + { + // A newer choice, or another picture (this one disposed): nothing to do. + if (version != Volatile.Read(ref _coverVersion) || !ReferenceEquals(preview, Volatile.Read(ref _preview))) + return; + cover = HideService.Cover(preview, style, size); + } + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + _log?.LogDebug(ex, "Could not cover the preview"); + return; + } + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + if (version != _coverVersion || _closed || !ReferenceEquals(preview, _preview)) + { + cover.Dispose(); + return; + } + var old = CoverImage; + CoverImage = cover; + _coverShown = version; + old?.Dispose(); + }); + }); + } + + /// Waits for the covered preview of the current choice (tests capture it). + internal bool IsCoverCurrent => CoverImage is not null && _coverShown == _coverVersion; + + private void ReplacePreview(MagickImage? preview) + { + var old = _preview; + _preview = preview; + if (old is not null) + { + lock (old) + old.Dispose(); + } + var cover = CoverImage; + CoverImage = null; + cover?.Dispose(); + } + + // ───────────────────────── Commands ───────────────────────── + + /// + /// Starts the conversion that writes the picture with its parts covered (its progress shows in the toast), keeps the + /// choice in the tool, then opens the next picture or closes. + /// + [RelayCommand(CanExecute = nameof(CanSave))] + public async Task SaveAsync() + { + if (!CanSave || _closed || CurrentIndex >= _files.Count) + return; + var path = _files[CurrentIndex]; + var areas = Areas.Areas.Select(a => new HiddenArea { X = a.X, Y = a.Y, Width = a.Width, Height = a.Height }).ToList(); + Remember(); + try + { + _export(path, HideTool.ConversionOf(_tool, _targetFor(path), areas)); + Queue[CurrentIndex].State = CropItemState.Done; + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + _log?.LogError(ex, "Could not start hiding parts of {File}", path); + AddProblem(path, "error.conversion_failed"); + Queue[CurrentIndex].State = CropItemState.Failed; + } + _carried = [.. Areas.Areas]; + await LoadFileAsync(CurrentIndex + 1); + } + + /// Leaves this picture as it is and opens the next one. + [RelayCommand] + public async Task SkipAsync() + { + if (IsBusy || IsFinished || _closed) + return; + Queue[CurrentIndex].State = CropItemState.Skipped; + // The frames stay for the next picture, as drawn. + _carried = [.. Areas.Areas]; + await LoadFileAsync(CurrentIndex + 1); + } + + /// Close (Esc). Pictures already started go on in the toast. + [RelayCommand] + public void Cancel() + { + Shutdown(); + CloseRequested?.Invoke(); + } + + [RelayCommand] + public void SetStyle(HideStyle style) + { + if (IsEditing) + Style = style; + } + + /// Removes every frame (R); Ctrl+Z brings them back. + [RelayCommand] + public void Clear() + { + if (IsEditing && !IsBusy) + Areas.Clear(); + } + + [RelayCommand] + public void RemoveSelected() + { + if (IsEditing && !IsBusy) + Areas.RemoveSelected(); + } + + [RelayCommand] + public void Undo() + { + if (IsEditing && !IsBusy) + Areas.Undo(); + } + + [RelayCommand] + public void Redo() + { + if (IsEditing && !IsBusy) + Areas.Redo(); + } + + /// A step stronger or weaker (+ / −). + public void StepStrength(int step) + { + if (IsEditing) + Strength = HideTool.ClampStrength(Strength + step); + } + + // ───────────────────────── Files, messages and the end ───────────────────────── + + /// Keeps pixelate or blur and the strength in the tool, as saved and as given to this window. + private void Remember() + { + HideTool.Remember(_tool, Style, Strength); + if (_store.FindPreset(_tool.Id) is not { IsHideTool: true } saved + || (saved.Image.HideStyle == Style && saved.Image.HideStrength == Strength)) + return; + HideTool.Remember(saved, Style, Strength); + try + { + _store.SaveLibrary(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _log?.LogWarning(ex, "Could not remember how parts are hidden"); + } + } + + private void AddProblem(string path, string key) + { + var line = $"{Path.GetFileName(path)}: {_loc[key]}"; + Problems.Add(line); + StatusNotice = line; + } + + /// Closes after the last picture, or stays open on the summary when a picture could not be opened. + private void Finish() + { + if (Problems.Count == 0) + { + Cancel(); + return; + } + Summary = _loc.Format("toast.done_errors", Problems.Count, Queue.Count); + DoneCount = Queue.Count(q => q.State == CropItemState.Done); + SkippedCount = Queue.Count(q => q.State == CropItemState.Skipped); + FailedCount = Queue.Count(q => q.State == CropItemState.Failed); + StatusNotice = ""; + HasMultipleFiles = false; + IsBusy = false; + Areas.Reset([]); + ReplacePreview(null); + var old = PreviewImage; + PreviewImage = null; + old?.Dispose(); + IsFinished = true; + } + + private void Shutdown() + { + if (_closed) + return; + // A choice made without saving is kept too: the window opens with it next time. + if (IsEditing && PreviewImage is not null) + Remember(); + _closed = true; + } + + private void OnLanguageChanged(object? sender, EventArgs e) + { + if (!IsFinished && CurrentIndex < _files.Count) + { + CounterText = _loc.Format("crop.page_counter", CurrentIndex + 1, _files.Count); + SaveText = _loc[CurrentIndex < _files.Count - 1 ? "hide.save_next" : "hide.save"]; + } + OnPropertyChanged(nameof(StrengthText)); + foreach (var item in Queue) + item.RefreshTexts(); + } + + public void Dispose() + { + Shutdown(); + _loc.LanguageChanged -= OnLanguageChanged; + Areas.Changed -= OnAreasChanged; + ReplacePreview(null); + PreviewImage?.Dispose(); + PreviewImage = null; + foreach (var item in Queue) + item.Dispose(); + } +} diff --git a/src/Filee.App/Views/HideWindow.axaml b/src/Filee.App/Views/HideWindow.axaml new file mode 100644 index 00000000..f46641ed --- /dev/null +++ b/src/Filee.App/Views/HideWindow.axaml @@ -0,0 +1,430 @@ + + + + + M12,2.6C12.3,2.6 12.55,2.73 12.74,2.96C14.9,5.6 19,10.4 19,14.2A7,7 0 0,1 5,14.2C5,10.4 9.1,5.6 11.26,2.96A0.95,0.95 0 0,1 12,2.6Z + M11.5,8C14.15,8 16.55,9 18.4,10.6L22,7V16H13L16.62,12.38C15.23,11.22 13.46,10.5 11.5,10.5C7.96,10.5 4.95,12.81 3.9,16L1.53,15.22C2.92,11.03 6.85,8 11.5,8Z + M4 7a3 3 0 0 1 3-3h10a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3z M7 6.2a.8.8 0 0 0-.8.8v10c0 .44.36.8.8.8h10a.8.8 0 0 0 .8-.8V7a.8.8 0 0 0-.8-.8z + M18 10a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3z M18 16a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3z M12 16a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3z + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +