diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json
index 3b796162..f9d69b60 100644
--- a/src/Filee.App/Assets/i18n/ar.json
+++ b/src/Filee.App/Assets/i18n/ar.json
@@ -253,6 +253,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 f0767159..7565e168 100644
--- a/src/Filee.App/Assets/i18n/de.json
+++ b/src/Filee.App/Assets/i18n/de.json
@@ -253,6 +253,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 ef7b293f..c9adb47e 100644
--- a/src/Filee.App/Assets/i18n/en.json
+++ b/src/Filee.App/Assets/i18n/en.json
@@ -262,6 +262,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 b6b294c4..326dd4f4 100644
--- a/src/Filee.App/Assets/i18n/es.json
+++ b/src/Filee.App/Assets/i18n/es.json
@@ -253,6 +253,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 4e85b4ca..5840f7a9 100644
--- a/src/Filee.App/Assets/i18n/fr.json
+++ b/src/Filee.App/Assets/i18n/fr.json
@@ -262,6 +262,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 0467d2b3..9cd166ed 100644
--- a/src/Filee.App/Assets/i18n/hi.json
+++ b/src/Filee.App/Assets/i18n/hi.json
@@ -253,6 +253,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 92037f1d..07a49406 100644
--- a/src/Filee.App/Assets/i18n/ja.json
+++ b/src/Filee.App/Assets/i18n/ja.json
@@ -253,6 +253,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 2a07274e..d23a1557 100644
--- a/src/Filee.App/Assets/i18n/ko.json
+++ b/src/Filee.App/Assets/i18n/ko.json
@@ -262,6 +262,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 007ea0a2..fdf89234 100644
--- a/src/Filee.App/Assets/i18n/ne.json
+++ b/src/Filee.App/Assets/i18n/ne.json
@@ -253,6 +253,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 2ea2ddd2..f3c21194 100644
--- a/src/Filee.App/Assets/i18n/pl.json
+++ b/src/Filee.App/Assets/i18n/pl.json
@@ -262,6 +262,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 8719a456..7922b90a 100644
--- a/src/Filee.App/Assets/i18n/pt-BR.json
+++ b/src/Filee.App/Assets/i18n/pt-BR.json
@@ -263,6 +263,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 67c2a047..1ff3ae5a 100644
--- a/src/Filee.App/Assets/i18n/pt-PT.json
+++ b/src/Filee.App/Assets/i18n/pt-PT.json
@@ -262,6 +262,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 7e6e77dc..159afced 100644
--- a/src/Filee.App/Assets/i18n/ru.json
+++ b/src/Filee.App/Assets/i18n/ru.json
@@ -262,6 +262,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 5a3466a9..ef504b68 100644
--- a/src/Filee.App/Assets/i18n/th.json
+++ b/src/Filee.App/Assets/i18n/th.json
@@ -253,6 +253,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 59d187aa..ea4977f6 100644
--- a/src/Filee.App/Assets/i18n/tr.json
+++ b/src/Filee.App/Assets/i18n/tr.json
@@ -253,6 +253,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 b75eec28..03429250 100644
--- a/src/Filee.App/Assets/i18n/zh-CN.json
+++ b/src/Filee.App/Assets/i18n/zh-CN.json
@@ -262,6 +262,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 c2c7027d..6045ece2 100644
--- a/src/Filee.App/Assets/i18n/zh-TW.json
+++ b/src/Filee.App/Assets/i18n/zh-TW.json
@@ -262,6 +262,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 1763d469..ed826112 100644
--- a/src/Filee.App/Services/PresetAvailability.cs
+++ b/src/Filee.App/Services/PresetAvailability.cs
@@ -124,6 +124,10 @@ private PresetJudgement Evaluate(Preset preset, IReadOnlyList format
return formats.Any(f => ImageEditTool.CanEdit(f.Id))
? new(PresetUse.Usable, null)
: new(PresetUse.Unusable, _loc["error.images_only"]);
+ 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)
{
diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs
index f9acdb40..6c289ae6 100644
--- a/src/Filee.App/Services/WindowService.cs
+++ b/src/Filee.App/Services/WindowService.cs
@@ -36,9 +36,11 @@ public sealed class WindowService(ILocalizer loc, IServiceProvider services, Mem
private CropOverlayWindow? _cropOverlay;
private ClipWindow? _clipWindow;
+ private HideWindow? _hideWindow;
private ImageEditWindow? _imageEdit;
internal ClipWindow? ClipWindow => _clipWindow;
+ internal HideWindow? HideWindow => _hideWindow;
internal ImageEditWindow? ImageEdit => _imageEdit;
private readonly HashSet _spreadsheetStudios = [];
@@ -123,13 +125,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, edit pictures, 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, edit pictures, 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);
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
@@ -208,6 +212,38 @@ public void ShowImageEditWindow(IReadOnlyList files, Preset preset)
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();
+ }
+
///
/// The pixel size of a picture as it is seen, read from its headers: turned as its EXIF orientation says, and the
/// largest of an icon's sizes (the one converted). Null when ImageMagick can't read it.
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..bc5430a2
--- /dev/null
+++ b/src/Filee.App/Views/HideWindow.axaml
@@ -0,0 +1,431 @@
+
+
+
+
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Filee.App/Views/HideWindow.axaml.cs b/src/Filee.App/Views/HideWindow.axaml.cs
new file mode 100644
index 00000000..b625c7d3
--- /dev/null
+++ b/src/Filee.App/Views/HideWindow.axaml.cs
@@ -0,0 +1,286 @@
+using System.ComponentModel;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+using Filee.App.Controls;
+using Filee.App.Services;
+using Filee.App.ViewModels;
+using Filee.Core.Presets;
+
+namespace Filee.App.Views;
+
+///
+/// The "Pixelate / blur" window: placement, keys (Enter saves, Esc closes, Delete removes the selected frame, Tab and
+/// Shift+Tab go from frame to frame, the arrow keys move it (Shift × 10), R clears every frame, P pixelates, B blurs,
+/// + and − change the strength, Ctrl+Z / Ctrl+Y undo and redo) and moving, sizing and maximizing the frameless window,
+/// as the crop overlay's.
+///
+public partial class HideWindow : Window
+{
+ private HideWindowViewModel? _vm;
+
+ /// The focus came from the keyboard (Tab, arrows), not from a click.
+ private bool _focusByKeyboard;
+
+ public HideWindow()
+ {
+ InitializeComponent();
+ Motion.Track(this);
+ Canvas.PlayAnimations = Motion.Enabled;
+ Motion.Changed += OnMotionChanged;
+ DataContextChanged += OnDataContextChanged;
+ AddHandler(GotFocusEvent, (_, e) => _focusByKeyboard = e.NavigationMethod is NavigationMethod.Tab or NavigationMethod.Directional,
+ RoutingStrategies.Bubble, handledEventsToo: true);
+ AddHandler(KeyDownEvent, OnKeyDownFirst, RoutingStrategies.Tunnel);
+ // Enter is the window's own (save), so a button that was clicked can't be pressed by it later; nor by its key-up.
+ AddHandler(KeyUpEvent, (_, e) =>
+ {
+ if (e.Key is Key.Enter or Key.Space && !ControlTakes(e))
+ e.Handled = true;
+ }, RoutingStrategies.Tunnel);
+ ResizeGrip.PointerPressed += OnGripPressed;
+ Stage.SizeChanged += (_, _) => FitToolbar();
+ Closed += (_, _) => Motion.Changed -= OnMotionChanged;
+ }
+
+ ///
+ /// How much of the toolbar's names it gives up, in turn, until it fits the window: none; tight drops the names
+ /// of Clear all and Skip and the keycaps (the tooltips and the line of keys below keep them); compact also
+ /// the names of Pixelate and Blur, with a shorter strength slider.
+ ///
+ internal static readonly string?[] ToolbarLevels = [null, "tight", "compact"];
+
+ /// The level in use (see ).
+ internal string? ToolbarLevel { get; private set; }
+
+ /// Room for the toolbar: the stage less the pill's margin.
+ internal double ToolbarRoom => Stage.Bounds.Width - 32;
+
+ ///
+ /// The width the toolbar asks for at , measured with the fonts in use (they differ from one
+ /// system to another, so no width is assumed).
+ ///
+ internal double ToolbarWidthAt(string? level)
+ {
+ SetToolbarLevel(level);
+ // Measured afresh all the way down: a button keeps the size it measured before its name was hidden or shown.
+ foreach (var part in ToolbarContent.GetVisualDescendants().OfType())
+ part.InvalidateMeasure();
+ ToolbarContent.InvalidateMeasure();
+ ToolbarContent.Measure(Size.Infinity);
+ return ToolbarContent.DesiredSize.Width + Toolbar.Padding.Left + Toolbar.Padding.Right + Toolbar.BorderThickness.Left
+ + Toolbar.BorderThickness.Right;
+ }
+
+ /// The first level, from all the names to icons only, at which the toolbar fits the window.
+ internal void FitToolbar()
+ {
+ var room = ToolbarRoom;
+ if (room <= 0)
+ return;
+ foreach (var level in ToolbarLevels)
+ {
+ if (level == ToolbarLevels[^1] || ToolbarWidthAt(level) <= room)
+ {
+ SetToolbarLevel(level);
+ return;
+ }
+ }
+ }
+
+ private void SetToolbarLevel(string? level)
+ {
+ ToolbarLevel = level;
+ foreach (var each in ToolbarLevels.OfType())
+ Classes.Set(each, each == level || (each == "tight" && level == "compact"));
+ }
+
+ /// The names changed (language, "Hide & Next" → "Hide & Save", Skip shown): fitted again once they show.
+ private void RefitToolbar() => Dispatcher.UIThread.Post(FitToolbar, DispatcherPriority.Render);
+
+ internal HideCanvas HideCanvas => Canvas;
+
+ ///
+ /// Opens near (the screen of the donut), else on the primary screen, centred in its working
+ /// area and no larger than most of it.
+ ///
+ public void PositionOnScreen(PixelPoint? near = null)
+ {
+ var screen = (near.HasValue ? Screens.ScreenFromPoint(near.Value) : null)
+ ?? Screens.ScreenFromPoint(Screens.Primary?.Bounds.Center ?? default)
+ ?? Screens.Primary;
+ if (screen is null)
+ return;
+ var scaling = screen.Scaling;
+ var area = screen.WorkingArea;
+ Width = Math.Max(MinWidth, Math.Min(Width, area.Width / scaling * 0.94));
+ Height = Math.Max(MinHeight, Math.Min(Height, area.Height / scaling * 0.92));
+ var x = area.X + Math.Max(0, (area.Width - (int)(Width * scaling)) / 2);
+ var y = area.Y + Math.Max(0, (area.Height - (int)(Height * scaling)) / 2);
+ Position = new PixelPoint(x, y);
+ }
+
+ /// Settles every animation and transition (tests capture the end state).
+ internal void SettleForCapture()
+ {
+ Classes.Set("reduced-motion", true);
+ Canvas.PlayAnimations = false;
+ Canvas.FinishAnimations();
+ }
+
+ private void OnDataContextChanged(object? sender, EventArgs e)
+ {
+ if (DataContext is HideWindowViewModel vm && !ReferenceEquals(vm, _vm))
+ {
+ _vm = vm;
+ vm.CloseRequested += Close;
+ vm.PropertyChanged += OnViewModelChanged;
+ Closed += (_, _) => vm.Dispose();
+ }
+ }
+
+ private void OnViewModelChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (_vm is null)
+ return;
+ if (e.PropertyName == nameof(HideWindowViewModel.CurrentIndex))
+ Dispatcher.UIThread.Post(() => Strip.ContainerFromIndex(_vm.CurrentIndex)?.BringIntoView(), DispatcherPriority.Background);
+ else if (e.PropertyName is nameof(HideWindowViewModel.SaveText) or nameof(HideWindowViewModel.HasMultipleFiles))
+ RefitToolbar();
+ }
+
+ private void OnMotionChanged(object? sender, EventArgs e) => Canvas.PlayAnimations = Motion.Enabled;
+
+ // ───────────────────────── Keys ─────────────────────────
+
+ ///
+ /// The control that has the focus, reached with the keyboard, takes 's key as it would anywhere
+ /// else: Space and Enter press a button, the arrows move the strength slider.
+ ///
+ private bool ControlTakes(KeyEventArgs e)
+ {
+ if (!_focusByKeyboard || e.KeyModifiers != KeyModifiers.None)
+ return false;
+ return FocusManager?.GetFocusedElement() switch
+ {
+ Button => e.Key is Key.Space or Key.Enter,
+ Slider => e.Key is Key.Left or Key.Right or Key.Up or Key.Down or Key.Home or Key.End or Key.PageUp or Key.PageDown,
+ _ => false,
+ };
+ }
+
+ /// Before any focused control: the window's keys, unless a control reached with the keyboard takes it.
+ private void OnKeyDownFirst(object? sender, KeyEventArgs e)
+ {
+ if (_vm is not { } vm || ControlTakes(e))
+ return;
+ var plain = e.KeyModifiers == KeyModifiers.None;
+ var command = e.KeyModifiers == KeyModifiers.Control || e.KeyModifiers == KeyModifiers.Meta;
+ var commandShift = e.KeyModifiers == (KeyModifiers.Control | KeyModifiers.Shift) || e.KeyModifiers == (KeyModifiers.Meta | KeyModifiers.Shift);
+ var editing = vm.IsEditing && !vm.IsBusy;
+ switch (e.Key)
+ {
+ case Key.Escape:
+ vm.Cancel();
+ e.Handled = true;
+ break;
+ case Key.Enter:
+ if (vm.IsFinished)
+ vm.Cancel();
+ else
+ _ = vm.SaveAsync();
+ e.Handled = true;
+ break;
+ case Key.Delete or Key.Back when plain && editing:
+ vm.RemoveSelected();
+ e.Handled = true;
+ break;
+ // Tab goes from frame to frame while there are frames; without, it moves the focus as usual.
+ case Key.Tab when editing && vm.HasAreas && (plain || e.KeyModifiers == KeyModifiers.Shift):
+ vm.Areas.SelectNext(e.KeyModifiers == KeyModifiers.Shift ? -1 : 1);
+ e.Handled = true;
+ break;
+ case Key.Z when command && editing:
+ vm.Undo();
+ e.Handled = true;
+ break;
+ case Key.Y when command && editing:
+ case Key.Z when commandShift && editing:
+ vm.Redo();
+ e.Handled = true;
+ break;
+ case Key.R when plain && editing:
+ vm.Clear();
+ e.Handled = true;
+ break;
+ case Key.P when plain && editing:
+ vm.SetStyle(HideStyle.Pixelate);
+ e.Handled = true;
+ break;
+ case Key.B when plain && editing:
+ vm.SetStyle(HideStyle.Blur);
+ e.Handled = true;
+ break;
+ case Key.OemPlus or Key.Add when (plain || e.KeyModifiers == KeyModifiers.Shift) && editing:
+ vm.StepStrength(1);
+ e.Handled = true;
+ break;
+ case Key.OemMinus or Key.Subtract when plain && editing:
+ vm.StepStrength(-1);
+ e.Handled = true;
+ break;
+ // The picture is never mirrored, also in right-to-left languages: Left is always left.
+ case Key.Left or Key.Right or Key.Up or Key.Down when editing && vm.HasSelection
+ && (e.KeyModifiers & ~KeyModifiers.Shift) == KeyModifiers.None:
+ var step = e.KeyModifiers.HasFlag(KeyModifiers.Shift) ? 10 : 1;
+ vm.Areas.Nudge(e.Key switch { Key.Left => -step, Key.Right => step, _ => 0 },
+ e.Key switch { Key.Up => -step, Key.Down => step, _ => 0 }, vm.OriginalWidth, vm.OriginalHeight);
+ e.Handled = true;
+ break;
+ }
+ }
+
+ // ───────────────────────── Moving and sizing the window ─────────────────────────
+
+ /// The stage beside the picture moves the window; a double click maximizes it.
+ protected override void OnPointerPressed(PointerPressedEventArgs e)
+ {
+ base.OnPointerPressed(e);
+ if (e.Handled || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+ return;
+ if (e.ClickCount == 2)
+ ToggleMaximized();
+ else if (WindowState == WindowState.Normal)
+ BeginMoveDrag(e);
+ }
+
+ private void OnGripPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (WindowState == WindowState.Normal && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+ BeginResizeDrag(WindowEdge.SouthEast, e);
+ e.Handled = true;
+ }
+
+ private void OnMaximize(object? sender, RoutedEventArgs e) => ToggleMaximized();
+
+ private void ToggleMaximized() =>
+ WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+ if (change.Property == WindowStateProperty)
+ {
+ var maximized = WindowState == WindowState.Maximized;
+ Classes.Set("maximized", maximized);
+ ResizeGrip.IsVisible = !maximized;
+ var key = maximized ? "crop.restore" : "crop.maximize";
+ MaximizeButton.Bind(ToolTip.TipProperty, MaximizeButton.GetResourceObservable(key));
+ MaximizeButton.Bind(Avalonia.Automation.AutomationProperties.NameProperty, MaximizeButton.GetResourceObservable(key));
+ }
+ }
+}
diff --git a/src/Filee.Core/Conversion/JobQueue.cs b/src/Filee.Core/Conversion/JobQueue.cs
index f826f669..2c4d352b 100644
--- a/src/Filee.Core/Conversion/JobQueue.cs
+++ b/src/Filee.Core/Conversion/JobQueue.cs
@@ -265,8 +265,8 @@ await Parallel.ForEachAsync(Enumerable.Range(0, job.Files.Count),
// one whose text is recognized first.
if (FormatRegistry.Detect(file.SourcePath)?.Id == "pdf" && !IsEmptyFile(file.SourcePath) && !job.Preset.RecognizesTextOf("pdf"))
{
- // Already a PDF, merged as it is: its pages can't be turned (only images going into it can).
- if (job.Preset.Image.TurnsOrMirrors)
+ // Already a PDF, merged as it is: its pages can't be turned or covered (only images going into it can).
+ if (job.Preset.Image.ReworksPixels)
FailUnturned(job, file, "pdf → pdf");
else
parts[i] = file.SourcePath;
@@ -323,8 +323,8 @@ await Parallel.ForEachAsync(Enumerable.Range(0, job.Files.Count),
var file = job.Files[i];
if (Stopped(file))
return;
- // A TIFF joins as it is, unless it is to be turned: then it goes through the engine like the rest.
- parts[i] = FormatRegistry.Detect(file.SourcePath)?.Id == "tiff" && !job.Preset.Image.TurnsOrMirrors
+ // A TIFF joins as it is, unless it is to be turned or covered: then it goes through the engine like the rest.
+ parts[i] = FormatRegistry.Detect(file.SourcePath)?.Id == "tiff" && !job.Preset.Image.ReworksPixels
&& !IsEmptyFile(file.SourcePath)
? [file.SourcePath]
: await RunFileAsync(job, file, i + 1, planner, workDir, finalOutput: false, intermediate: "tiff");
@@ -403,7 +403,7 @@ await Parallel.ForEachAsync(Enumerable.Range(0, job.Files.Count),
}
if (slideshow)
{
- parts[i] = source.Id == "png" && !job.Preset.Image.TurnsOrMirrors
+ parts[i] = source.Id == "png" && !job.Preset.Image.ReworksPixels
? [file.SourcePath]
: await RunFileAsync(job, file, i + 1, planner, workDir, finalOutput: false, intermediate: "png",
ceiling: SlidesReadyShare);
@@ -500,11 +500,11 @@ private void FailFile(ConversionJob job, FileResult file, string key, string? de
Notify(job);
}
- /// A file that would be used as it is although the preset turns or mirrors it.
+ /// A file that would be used as it is although the preset turns, mirrors or covers parts of it.
private void FailUnturned(ConversionJob job, FileResult file, string detail)
{
file.State = FileState.Failed;
- file.ErrorKey = "error.cannot_turn";
+ file.ErrorKey = job.Preset.Image.HidesAreas ? "error.cannot_hide" : "error.cannot_turn";
file.ErrorDetail = detail;
file.Progress = 1;
Notify(job);
@@ -721,9 +721,10 @@ private async Task> RunFileAsync(
// Rotate / flip: only an engine that turns images does it. Without one the file would come out unturned
// (a PDF or a video re-encoded "as rotated"); with two in the route (EPS → PNG → JPG) it would turn twice.
- var turnAt = job.Preset.Image.TurnsOrMirrors ? route.FirstTransformStep() : -1;
- if (job.Preset.Image.TurnsOrMirrors && turnAt < 0)
- return Fail(file, "error.cannot_turn", $"{source.Id} → {target}");
+ // Parts of a picture to hide likewise, once: a route that can't cover them must not write the picture uncovered.
+ var turnAt = job.Preset.Image.ReworksPixels ? route.FirstTransformStep() : -1;
+ if (job.Preset.Image.ReworksPixels && turnAt < 0)
+ return Fail(file, job.Preset.Image.HidesAreas ? "error.cannot_hide" : "error.cannot_turn", $"{source.Id} → {target}");
var unturned = turnAt < 0 ? job.Preset : Unturned(job.Preset);
file.State = FileState.Running;
@@ -767,8 +768,9 @@ private async Task> RunFileAsync(
await gate.WaitAsync(ct);
try
{
- // Turned once, in step turnAt: every other step gets the preset without rotation and flip,
- // also one before it whose engine applies image options but doesn't say it turns.
+ // Turned (and covered) once, in step turnAt: every other step gets the preset without rotation,
+ // flip and hidden parts, also one before it whose engine applies image options but doesn't say
+ // it turns.
var preset = s == turnAt ? job.Preset : unturned;
// Playback speed changes once, in the step that writes the output: an animation passed from one
// engine to another would otherwise change speed in both.
@@ -797,7 +799,9 @@ private async Task> RunFileAsync(
}
// Under "Save as ZIP" the output is still in the work folder: the source copied over it goes into the ZIP.
- var copiedSource = finalOutput && job.Preset.Output.OnlyIfSmaller && inputs.Count == 1 && CopyIfNotSmaller(file.SourcePath, inputs[0]);
+ // Never with hidden parts: the source copied over the result would show them again.
+ var copiedSource = finalOutput && job.Preset.Output.OnlyIfSmaller && !job.Preset.Image.HidesAreas && inputs.Count == 1
+ && CopyIfNotSmaller(file.SourcePath, inputs[0]);
if (copiedSource)
file.NoticeKey = "notice.pdf_not_smaller";
// Watermark, page numbers, password: on every PDF this file became, from whichever engine.
@@ -856,6 +860,7 @@ static Preset Unturned(Preset preset)
var copy = preset.Clone();
copy.Image.Rotation = ImageRotation.None;
copy.Image.Flip = ImageFlip.None;
+ copy.Image.HiddenAreas = null;
return copy;
}
diff --git a/src/Filee.Core/History/HistoryEntry.cs b/src/Filee.Core/History/HistoryEntry.cs
index 5a606c2e..9efa654a 100644
--- a/src/Filee.Core/History/HistoryEntry.cs
+++ b/src/Filee.Core/History/HistoryEntry.cs
@@ -61,7 +61,8 @@ public static HistoryEntry From(ConversionJob job)
Outputs = [.. job.Outputs],
Failures = failures,
// history.json isn't the place for a PDF password: a retry takes it from the preset again.
- Preset = failures.Count > 0 ? job.Preset.Clone().WithoutPassword(mark: true) : null,
+ // No Retry for parts of a picture to hide: a Filee from before them would write the picture as it is.
+ Preset = failures.Count > 0 && !job.Preset.Image.HidesAreas ? job.Preset.Clone().WithoutPassword(mark: true) : null,
Undoable = UndoableOutputs(job),
OriginalsInTrash = job.Files.Any(f => f.OriginalInTrash),
};
diff --git a/src/Filee.Core/Presets/BuiltInData.cs b/src/Filee.Core/Presets/BuiltInData.cs
index 230fa63d..ef437e67 100644
--- a/src/Filee.Core/Presets/BuiltInData.cs
+++ b/src/Filee.Core/Presets/BuiltInData.cs
@@ -50,6 +50,7 @@ .. TrimPresets(),
Kind = PresetKind.Tool, Output = { FileNamePattern = "{name}_cropped" },
},
.. ImageEditPresets(),
+ .. HidePresets(),
new()
{
Id = "tool-sheet-studio", NameKey = "preset.sheet_studio", TargetFormat = "pdf",
@@ -207,6 +208,19 @@ public static List ImageEditPresets() =>
},
];
+ ///
+ /// "Pixelate / blur" (added in settings schema 52): parts of a picture (names, faces, numbers) covered before it is
+ /// shared. On the front of the images donut after "Edit pictures".
+ ///
+ public static List HidePresets() =>
+ [
+ new()
+ {
+ Id = "tool-hide", NameKey = "preset.hide", TargetFormat = SameAsSource,
+ Kind = PresetKind.Tool, Tool = Preset.HideTool, Output = { FileNamePattern = "{name}_hidden" },
+ },
+ ];
+
///
/// "Trim transparent borders" (issue #332, added in settings schema 33): a copy cropped to the visible pixels, in the
/// same format. On the images donut's back face after "Remove background".
@@ -542,7 +556,7 @@ public static List CreateProfiles() =>
Id = "images", NameKey = "profile.images",
Extensions = ExtensionsOf(FormatCategory.Image),
PresetIds = ["to-png", "to-jpg", "to-webp", "to-pdf", "trace-svg", "trace-line-art", "to-tiff", "to-bmp", "to-ico", "half-size",
- "tool-image-edit"],
+ "tool-image-edit", "tool-hide"],
// Full (ToolbarProfile.MaxSlices): "Extract Live Photo video" stays in the library, on no donut.
BackPresetIds = ["tool-crop", "rotate", "flip", "remove-background", "trim-transparent", "grayscale", "to-avif", "to-gif", "merge-pdf",
"compress-image", "remove-metadata", "zip-all"],
diff --git a/src/Filee.Core/Presets/HideTool.cs b/src/Filee.Core/Presets/HideTool.cs
new file mode 100644
index 00000000..2a275090
--- /dev/null
+++ b/src/Filee.Core/Presets/HideTool.cs
@@ -0,0 +1,122 @@
+// "Pixelate / blur" runs as an ordinary conversion once its window has the parts of a picture to hide: the tool preset
+// becomes a conversion into the picture's own format with those parts (ImageOptions.HiddenAreas), so the job queue, the
+// toast, cancelling and the output rules work as for any other file. How they are covered (pixelate or blur, and how
+// strongly) is kept in the tool, so the window opens with the last choice. The arithmetic shared by the window's
+// preview and the image engine is here too: where a part lies in pixels, how large the squares or the blur are, and
+// which square a pixel falls in.
+
+using Filee.Core.Conversion;
+using Filee.Core.Formats;
+
+namespace Filee.Core.Presets;
+
+/// Turns the "Pixelate / blur" tool into the conversion that writes a picture with parts of it covered.
+public static class HideTool
+{
+ /// Where a picture goes when its own format can't be written (a camera RAW, a DICOM …).
+ public const string FallbackTarget = "png";
+
+ /// The range of .
+ public const int MinStrength = 1, MaxStrength = 10, DefaultStrength = 5;
+
+ /// The smallest square (or blur) in pixels, whatever the picture's size.
+ public const double MinEffectPixels = 8;
+
+ ///
+ /// Parts smaller than this (in parts of the picture's side) are a click, not a drag: none is added for them.
+ ///
+ public const double MinAreaSide = 0.004;
+
+ /// True when a file is a picture the tool can open.
+ public static bool CanHide(string sourceFormat) =>
+ FormatRegistry.FindById(sourceFormat)?.Category == FormatCategory.Image;
+
+ ///
+ /// The format a picture is written in: its own when it can be written from itself,
+ /// else .
+ ///
+ public static string TargetFor(string sourceFormat, RoutePlanner planner) =>
+ FormatRegistry.FindById(sourceFormat) is { Writable: true } && planner.Plan(sourceFormat, sourceFormat) is not null
+ ? BuiltInData.SameAsSource
+ : FallbackTarget;
+
+ ///
+ /// The conversion that writes a picture as with covered as
+ /// the tool says, with the tool's name and output rule. The tool itself is left as it is.
+ ///
+ public static Preset ConversionOf(Preset tool, string targetFormat, IEnumerable areas)
+ {
+ var preset = tool.Clone();
+ preset.Kind = PresetKind.Convert;
+ preset.Tool = null;
+ preset.SubPresetIds = [];
+ preset.TargetFormat = targetFormat;
+ preset.Image.HiddenAreas = [.. areas.Select(Clamp).OfType()];
+ preset.Image.HideStrength = ClampStrength(preset.Image.HideStrength);
+ // "Only if smaller" would keep the original, every part of it in view, when the covered copy came out larger.
+ preset.Output.OnlyIfSmaller = false;
+ return preset;
+ }
+
+ /// Keeps the window's choice in for the next time.
+ public static void Remember(Preset tool, HideStyle style, int strength)
+ {
+ tool.Image.HideStyle = style;
+ tool.Image.HideStrength = ClampStrength(strength);
+ }
+
+ /// within … .
+ public static int ClampStrength(int strength) => Math.Clamp(strength, MinStrength, MaxStrength);
+
+ ///
+ /// The side of a square (pixelate) or the blur's sigma, in pixels of a ×
+ /// picture: 2 % of its shorter side at the lowest strength to 6.5 % at the highest, never under
+ /// .
+ ///
+ ///
+ /// Tied to the picture rather than to the part being hidden: a screenshot's line of text is about 1.2 % of a desktop
+ /// screen's shorter side and 4 % of a phone's, so at the default strength (4 %) a square is as tall as a line of
+ /// text or taller. Squares much smaller than the letters leave them guessable: text in a known font can be drawn,
+ /// pixelated the same way and compared square by square until it matches.
+ ///
+ public static double EffectSize(int strength, int width, int height) =>
+ Math.Max(MinEffectPixels, Math.Min(Math.Max(1, width), Math.Max(1, height)) * (0.015 + 0.005 * ClampStrength(strength)));
+
+ ///
+ /// within the picture, or null when nothing of it is left (or it is not a number).
+ ///
+ public static HiddenArea? Clamp(HiddenArea area)
+ {
+ if (!double.IsFinite(area.X) || !double.IsFinite(area.Y) || !double.IsFinite(area.Width) || !double.IsFinite(area.Height))
+ return null;
+ var left = Math.Clamp(Math.Min(area.X, area.X + area.Width), 0, 1);
+ var right = Math.Clamp(Math.Max(area.X, area.X + area.Width), 0, 1);
+ var top = Math.Clamp(Math.Min(area.Y, area.Y + area.Height), 0, 1);
+ var bottom = Math.Clamp(Math.Max(area.Y, area.Y + area.Height), 0, 1);
+ return right > left && bottom > top ? new HiddenArea { X = left, Y = top, Width = right - left, Height = bottom - top } : null;
+ }
+
+ ///
+ /// in whole pixels of a × picture, rounded
+ /// outwards (a pixel the part touches is covered), at least one pixel; null when nothing of it is on the picture.
+ ///
+ public static (int X, int Y, int Width, int Height)? PixelBounds(HiddenArea area, int width, int height)
+ {
+ if (width <= 0 || height <= 0 || Clamp(area) is not { } inside)
+ return null;
+ var left = Math.Clamp((int)Math.Floor(inside.X * width + 1e-9), 0, width - 1);
+ var top = Math.Clamp((int)Math.Floor(inside.Y * height + 1e-9), 0, height - 1);
+ var right = Math.Clamp((int)Math.Ceiling((inside.X + inside.Width) * width - 1e-9), left + 1, width);
+ var bottom = Math.Clamp((int)Math.Ceiling((inside.Y + inside.Height) * height - 1e-9), top + 1, height);
+ return (left, top, right - left, bottom - top);
+ }
+
+ ///
+ /// The square a pixel column (or row) falls in, on a grid of -pixel squares from the picture's
+ /// top-left corner: by the pixel's centre, so a cell of 2.5 pixels takes pixels 0–1, 2–4, 5–6 …
+ ///
+ public static int CellOf(int pixel, double cell) => (int)Math.Floor((pixel + 0.5) / cell);
+
+ /// The first pixel column (or row) of square (see ).
+ public static int CellStart(int index, double cell) => (int)Math.Ceiling(index * cell - 0.5);
+}
diff --git a/src/Filee.Core/Presets/Preset.cs b/src/Filee.Core/Presets/Preset.cs
index 78446690..7d92bdf1 100644
--- a/src/Filee.Core/Presets/Preset.cs
+++ b/src/Filee.Core/Presets/Preset.cs
@@ -213,11 +213,15 @@ public bool RecognizesTextOf(string sourceFormat) =>
///
public const string ImageEditTool = "image-edit";
+ ///
+ /// of "Pixelate / blur": parts of a picture covered before it is shared (names, faces, numbers).
+ ///
+ public const string HideTool = "hide";
+
///
/// : which window it opens, (or null, as saved before there were
- /// other tools), or . A tool this version doesn't know (from a newer
- /// Filee) opens nothing.
- /// Ignored for other kinds.
+ /// other tools), , or . A tool this version
+ /// doesn't know (from a newer Filee) opens nothing. Ignored for other kinds.
///
public string? Tool { get; set; }
@@ -233,6 +237,10 @@ public bool RecognizesTextOf(string sourceFormat) =>
[JsonIgnore]
public bool IsImageEditTool => IsTool && Tool == ImageEditTool;
+ /// True for "Pixelate / blur".
+ [JsonIgnore]
+ public bool IsHideTool => IsTool && Tool == HideTool;
+
///
/// True if converting a file cuts a clip of it (,
/// set by the clip window on the conversion it starts): the file is a video. Other files convert as usual.
@@ -342,6 +350,29 @@ public enum FitsTiffMode
Stretched,
}
+/// How "Pixelate / blur" covers the parts of a picture it hides ().
+public enum HideStyle
+{
+ /// Squares of the average colour of what is under them.
+ Pixelate,
+ /// A strong blur.
+ Blur,
+}
+
+///
+/// A part of a picture to hide, in parts of the picture as it is seen (EXIF orientation applied): 0 to 1 from its left
+/// and top edges.
+///
+public sealed class HiddenArea
+{
+ public double X { get; set; }
+ public double Y { get; set; }
+ public double Width { get; set; }
+ public double Height { get; set; }
+
+ public HiddenArea Clone() => (HiddenArea)MemberwiseClone();
+}
+
/// Raster image encoding options.
public sealed class ImageOptions
{
@@ -375,6 +406,13 @@ public sealed class ImageOptions
[JsonIgnore]
public bool TurnsOrMirrors => Rotation != ImageRotation.None || Flip != ImageFlip.None;
+ ///
+ /// Turns, mirrors or covers parts of the image (): done once, in the first step of a route whose
+ /// engine applies image options (); a file is never passed on as it is.
+ ///
+ [JsonIgnore]
+ public bool ReworksPixels => TurnsOrMirrors || HidesAreas;
+
/// Whether the preset edits picture content, size, or playback speed.
[JsonIgnore]
public bool EditsPicture => Resize != ResizeMode.None || Grayscale || TrimTransparent || RemoveBackground || MaxCompression || ChangesSpeed || CustomSize is not null || MinShortEdge is not null;
@@ -492,6 +530,30 @@ public sealed class ImageOptions
/// Icon sizes embedded in an ICO file.
public List IcoSizes { get; set; } = [256, 128, 64, 48, 32, 16];
+ ///
+ /// "Pixelate / blur": how are covered. Kept in the tool, so its window opens with the last
+ /// one. A field of its own (not a value added to another option), read leniently: an older Filee skips it.
+ ///
+ [JsonConverter(typeof(LenientEnumJsonConverter))]
+ public HideStyle HideStyle { get; set; }
+
+ ///
+ /// How large the squares or how wide the blur over is,
+ /// to (see ). Kept in the tool, as
+ /// .
+ ///
+ public int HideStrength { get; set; } = HideTool.DefaultStrength;
+
+ ///
+ /// The parts of the picture "Pixelate / blur" covers. Set by its window on the conversion it starts, never on a
+ /// saved preset.
+ ///
+ public List? HiddenAreas { get; set; }
+
+ /// The picture gets parts of it covered ().
+ [JsonIgnore]
+ public bool HidesAreas => HiddenAreas is { Count: > 0 };
+
/// The playback speeds the preset editor offers ().
public static readonly double[] AnimationSpeeds = [0.25, 0.5, 0.75, 1, 1.5, 2, 4];
@@ -516,6 +578,7 @@ public ImageOptions Clone()
{
var copy = (ImageOptions)MemberwiseClone();
copy.IcoSizes = [.. IcoSizes];
+ copy.HiddenAreas = HiddenAreas?.Select(area => area.Clone()).ToList();
copy.CustomSize = CustomSize is { } size
? new CustomImageSize { Width = size.Width, Height = size.Height, LockAspect = size.LockAspect }
: null;
diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs
index b37c84c4..f38249a0 100644
--- a/src/Filee.Core/Settings/AppSettings.cs
+++ b/src/Filee.Core/Settings/AppSettings.cs
@@ -123,7 +123,7 @@ public static bool IsFlipKeyAllowed(string key, IEnumerable gest
public sealed class AppSettings
{
/// Current schema version. Increase when the format changes and add a migration.
- public const int CurrentSchemaVersion = 51;
+ public const int CurrentSchemaVersion = 52;
public int SchemaVersion { get; set; } = CurrentSchemaVersion;
diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs
index aaf4be19..e47cd2ca 100644
--- a/src/Filee.Core/Settings/UserDataStore.cs
+++ b/src/Filee.Core/Settings/UserDataStore.cs
@@ -913,6 +913,14 @@ public static bool ApplyToLibrary(int from, List presets, List presets, List
return changed;
}
+ private static bool AddHideTool(List presets, List profiles)
+ {
+ var changed = false;
+ var hide = BuiltInData.HidePresets().Single();
+ if (presets.FirstOrDefault(p => p.Id == hide.Id) is { } existing)
+ {
+ if (!existing.IsHideTool)
+ {
+ existing.Kind = PresetKind.Tool;
+ existing.Tool = Preset.HideTool;
+ existing.SubPresetIds = [];
+ if (existing.TargetFormat == Preset.GroupTarget)
+ existing.TargetFormat = hide.TargetFormat;
+ changed = true;
+ }
+ }
+ else
+ {
+ var at = presets.FindIndex(p => p.Id == "tool-image-edit") is >= 0 and var edit
+ ? edit
+ : presets.FindIndex(p => p.Id == "tool-crop");
+ presets.Insert(at < 0 ? presets.Count : at + 1, hide);
+ changed = true;
+ }
+ if (profiles.FirstOrDefault(p => p.Id == "images") is not { } images
+ || images.PresetIds.Contains(hide.Id) || images.BackPresetIds?.Contains(hide.Id) == true)
+ return changed;
+ var front = images.PresetIds;
+ // Next to the other everyday edits: "Half size", and "Edit pictures" when it follows it. A front without "Half
+ // size" was laid out by the user and stays so, with the back face.
+ var after = front.IndexOf("half-size");
+ if (after < 0)
+ return changed;
+ if (after + 1 < front.Count && front[after + 1] == "tool-image-edit")
+ after++;
+ if (front.Count < ToolbarProfile.MaxSlices)
+ {
+ front.Insert(after + 1, hide.Id);
+ return true;
+ }
+ if (images.BackPresetIds is { } back && back.Count < ToolbarProfile.MaxSlices)
+ {
+ InsertBefore(back, hide.Id, "zip-all");
+ return true;
+ }
+ return changed;
+ }
+
private static bool AddGeo(List presets, List profiles)
{
var changed = false;
diff --git a/src/Filee.Engines/Magick/AreaHider.cs b/src/Filee.Engines/Magick/AreaHider.cs
new file mode 100644
index 00000000..fb6aab06
--- /dev/null
+++ b/src/Filee.Engines/Magick/AreaHider.cs
@@ -0,0 +1,195 @@
+// "Pixelate / blur": covers parts of a picture (ImageOptions.HiddenAreas) so what was there can't be read back. Each
+// part is changed on its own; the rest of the picture keeps its pixels. Also used on the window's preview, so what the
+// user sees while drawing the parts is what the file gets.
+
+using Filee.Core.Presets;
+using ImageMagick;
+
+namespace Filee.Engines.Magick;
+
+/// Pixelates or blurs parts of a picture for good.
+public static class AreaHider
+{
+ ///
+ /// Covers of (upright, as the parts were drawn) as
+ /// say. Nothing happens without parts.
+ ///
+ public static void Apply(IMagickImage image, ImageOptions options)
+ {
+ if (!options.HidesAreas)
+ return;
+ int width = (int)image.Width, height = (int)image.Height;
+ var size = HideTool.EffectSize(options.HideStrength, width, height);
+ foreach (var area in options.HiddenAreas!)
+ {
+ if (HideTool.PixelBounds(area, width, height) is not { } bounds)
+ continue;
+ if (options.HideStyle == HideStyle.Blur)
+ Blur(image, bounds, size);
+ else
+ Pixelate(image, bounds, Math.Round(size));
+ }
+ DropPreviews(image);
+ }
+
+ ///
+ /// The whole of covered with an effect pixels large (a square's side
+ /// or the blur's sigma): the window's preview, of which it shows the parts inside the frames.
+ ///
+ public static void Cover(IMagickImage image, HideStyle style, double size)
+ {
+ var bounds = (0, 0, (int)image.Width, (int)image.Height);
+ if (style == HideStyle.Blur)
+ Blur(image, bounds, size);
+ else
+ Pixelate(image, bounds, size);
+ }
+
+ ///
+ /// Every pixel of becomes the average colour of its square on a grid of
+ /// -pixel squares laid from the picture's top-left corner ().
+ ///
+ ///
+ /// Averaging is many-to-one: a square's worth of pixels becomes one colour, and what made it up is gone. Squares on
+ /// the edge of a part are averaged over all of their pixels, those beside the part too (which stay in view anyway):
+ /// the grid stays the same wherever a part is moved, and two parts that meet look like one. How large a square has
+ /// to be to make text unreadable is 's concern.
+ ///
+ public static void Pixelate(IMagickImage image, (int X, int Y, int Width, int Height) bounds, double cell)
+ {
+ cell = Math.Max(1, cell);
+ int width = (int)image.Width, height = (int)image.Height;
+ var (x, y, w, h) = bounds;
+ if (w <= 0 || h <= 0)
+ return;
+ // The squares the part touches, whole: their pixels beside the part count towards their colour.
+ int firstColumn = HideTool.CellOf(x, cell), lastColumn = HideTool.CellOf(x + w - 1, cell);
+ int firstRow = HideTool.CellOf(y, cell), lastRow = HideTool.CellOf(y + h - 1, cell);
+ var left = Math.Max(0, HideTool.CellStart(firstColumn, cell));
+ var right = Math.Min(width, HideTool.CellStart(lastColumn + 1, cell));
+ var top = Math.Max(0, HideTool.CellStart(firstRow, cell));
+ var bottom = Math.Min(height, HideTool.CellStart(lastRow + 1, cell));
+ int regionWidth = right - left, regionHeight = bottom - top;
+ // RGB(A) whatever the picture holds: the squares are written back as a picture of their own (see below).
+ var mapping = image.HasAlpha ? PixelMapping.RGBA : PixelMapping.RGB;
+ var channels = image.HasAlpha ? 4 : 3;
+ int columns = lastColumn - firstColumn + 1, rows = lastRow - firstRow + 1;
+
+ byte[] data;
+ using (var pixels = image.GetPixels())
+ data = pixels.ToByteArray(left, top, (uint)regionWidth, (uint)regionHeight, mapping)
+ ?? throw new InvalidOperationException("The picture's pixels could not be read.");
+
+ var columnOf = new int[regionWidth];
+ for (var i = 0; i < regionWidth; i++)
+ columnOf[i] = Math.Clamp(HideTool.CellOf(left + i, cell) - firstColumn, 0, columns - 1);
+ var rowOf = new int[regionHeight];
+ for (var j = 0; j < regionHeight; j++)
+ rowOf[j] = Math.Clamp(HideTool.CellOf(top + j, cell) - firstRow, 0, rows - 1);
+
+ var sums = new long[columns * rows * channels];
+ var counts = new int[columns * rows];
+ for (var j = 0; j < regionHeight; j++)
+ {
+ var rowBase = rowOf[j] * columns;
+ var offset = j * regionWidth * channels;
+ for (var i = 0; i < regionWidth; i++, offset += channels)
+ {
+ var square = rowBase + columnOf[i];
+ counts[square]++;
+ var sum = square * channels;
+ for (var c = 0; c < channels; c++)
+ sums[sum + c] += data[offset + c];
+ }
+ }
+ var averages = new byte[sums.Length];
+ for (var square = 0; square < counts.Length; square++)
+ {
+ var count = counts[square];
+ if (count == 0)
+ continue;
+ for (var c = 0; c < channels; c++)
+ averages[square * channels + c] = (byte)((sums[square * channels + c] + count / 2) / count);
+ }
+
+ // Only the part takes the squares' colours.
+ for (var j = y - top; j < y - top + h; j++)
+ {
+ var rowBase = rowOf[j] * columns;
+ for (var i = x - left; i < x - left + w; i++)
+ {
+ var square = (rowBase + columnOf[i]) * channels;
+ Array.Copy(averages, square, data, (j * regionWidth + i) * channels, channels);
+ }
+ }
+ // Copied over the picture as a picture of their own: writing the area into the pixels of a picture as the image
+ // engine reads it left the rest of that picture black.
+ using var squares = new MagickImage();
+ squares.ReadPixels(data, new PixelReadSettings((uint)regionWidth, (uint)regionHeight, StorageType.Char, mapping));
+ image.Composite(squares, left, top, CompositeOperator.Copy);
+ }
+
+ ///
+ /// blurred with a Gaussian of pixels, after its pixels are
+ /// averaged into squares of about a third of it.
+ ///
+ ///
+ /// A light blur can be undone: it is a known convolution, and deconvolving it (sharpening with the inverse) brings
+ /// back text whose strokes it only smeared. Here the part is first scaled down by averaging, which throws pixels
+ /// away for good, and then blurred so widely (a sigma about a line of text, see )
+ /// that the detail of a letter is far below what an 8-bit pixel can hold. Pixels beside the part are blurred with
+ /// it, so its edge doesn't show a seam, but only the part is written back.
+ ///
+ public static void Blur(IMagickImage image, (int X, int Y, int Width, int Height) bounds, double sigma)
+ {
+ sigma = Math.Max(1, sigma);
+ int width = (int)image.Width, height = (int)image.Height;
+ var (x, y, w, h) = bounds;
+ if (w <= 0 || h <= 0)
+ return;
+ var margin = (int)Math.Ceiling(2 * sigma);
+ var left = Math.Max(0, x - margin);
+ var top = Math.Max(0, y - margin);
+ var right = Math.Min(width, x + w + margin);
+ var bottom = Math.Min(height, y + h + margin);
+ int regionWidth = right - left, regionHeight = bottom - top;
+
+ using var part = image.CloneArea(new MagickGeometry(left, top, (uint)regionWidth, (uint)regionHeight));
+ part.ResetPage();
+ var step = Math.Max(1, (int)Math.Floor(sigma / 3));
+ if (step > 1)
+ {
+ part.Scale(new MagickGeometry((uint)Math.Max(1, (regionWidth + step - 1) / step), (uint)Math.Max(1, (regionHeight + step - 1) / step))
+ {
+ IgnoreAspectRatio = true,
+ });
+ }
+ part.GaussianBlur(0, sigma / step);
+ if (step > 1)
+ {
+ part.FilterType = FilterType.Triangle;
+ part.Resize(new MagickGeometry((uint)regionWidth, (uint)regionHeight) { IgnoreAspectRatio = true });
+ }
+ part.Crop(new MagickGeometry(x - left, y - top, (uint)w, (uint)h));
+ part.ResetPage();
+ image.Composite(part, x, y, CompositeOperator.Copy);
+ }
+
+ ///
+ /// Drops the small previews a file may carry of the picture as it was: EXIF's thumbnail, Photoshop's (in its 8BIM
+ /// resources) and XMP's. Viewers that show them would show the hidden parts.
+ ///
+ internal static void DropPreviews(IMagickImage image)
+ {
+ if (image.GetExifProfile() is { } exif && exif.ThumbnailLength > 0)
+ {
+ exif.RemoveThumbnail();
+ image.SetProfile(exif);
+ }
+ if (image.HasProfile("8bim"))
+ image.RemoveProfile("8bim");
+ if (image.GetXmpProfile() is { } xmp
+ && System.Text.Encoding.UTF8.GetString(xmp.ToByteArray()).Contains("Thumbnails", StringComparison.Ordinal))
+ image.RemoveProfile("xmp");
+ }
+}
diff --git a/src/Filee.Engines/Magick/ImageEncoder.cs b/src/Filee.Engines/Magick/ImageEncoder.cs
index 9864c138..a043e529 100644
--- a/src/Filee.Engines/Magick/ImageEncoder.cs
+++ b/src/Filee.Engines/Magick/ImageEncoder.cs
@@ -293,6 +293,10 @@ public static void ApplyOptions(IMagickImage image, ImageOptions options,
var turned = options.TurnsOrMirrors || image.Orientation is not (OrientationType.TopLeft or OrientationType.Undefined);
image.AutoOrient();
+ // "Pixelate / blur": the parts were drawn on the picture as it is seen, before it is turned or resized. It also
+ // drops the small previews of the uncovered picture a file can carry.
+ AreaHider.Apply(image, options);
+
// After AutoOrient, so a phone photo turns the way it looks, not the way its pixels are stored.
Turn(image, options);
// The small preview kept in the EXIF data still shows the picture as it was: Explorer and photo apps that
diff --git a/tests/Filee.App.Tests/HideWindowTests.cs b/tests/Filee.App.Tests/HideWindowTests.cs
new file mode 100644
index 00000000..5cc3dcce
--- /dev/null
+++ b/tests/Filee.App.Tests/HideWindowTests.cs
@@ -0,0 +1,493 @@
+// The "Pixelate / blur" window: the empty picture's hint, frames showing the picture already pixelated or blurred, the
+// strip of several files and the end summary (screenshots in light, dark, Material You and a theme.json, short, long
+// and right-to-left languages), drawing and dragging frames with the mouse, its keys, the conversion it starts and the
+// choice it remembers.
+
+using System.IO;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Headless;
+using Avalonia.Headless.XUnit;
+using Avalonia.Input;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+using Filee.App.Controls;
+using Filee.App.Services;
+using Filee.App.ViewModels;
+using Filee.App.Views;
+using Filee.Core.Localization;
+using Filee.Core.Presets;
+using Filee.Core.Settings;
+using ImageMagick;
+using ImageMagick.Drawing;
+using static Filee.App.Tests.RenderTests;
+
+namespace Filee.App.Tests;
+
+public class HideWindowTests
+{
+ public static TheoryData Looks => new()
+ {
+ { "en", "light" }, { "en", "dark" }, { "ko", "light" }, { "ko", "dark" }, { "de", "light" }, { "de", "dark" },
+ { "ar", "light" }, { "ar", "dark" }, { "ru", "light" }, { "hi", "light" }, { "ne", "dark" }, { "ja", "light" },
+ { "en", "material" }, { "en", "custom" },
+ };
+
+ /// The frames of the screenshots: a name, a phone number, a face.
+ private static readonly Rect[] Frames = [new(0.062, 0.148, 0.16, 0.07), new(0.07, 0.62, 0.38, 0.08), new(0.63, 0.19, 0.24, 0.39)];
+
+ [AvaloniaTheory]
+ [MemberData(nameof(Looks))]
+ public void A_picture_shows_the_hint_then_its_frames_pixelated_or_blurred(string language, string look)
+ {
+ WithWindow(language, look, ["chat.png"], (window, vm, suffix, _) =>
+ {
+ Assert.False(vm.HasMultipleFiles);
+ Assert.Equal(AppHost.Get()["hide.save"], vm.SaveText);
+ Assert.True(vm.ShowEmptyHint);
+ Assert.False(vm.CanSave);
+ Assert.False(vm.SaveCommand.CanExecute(null));
+ Settle(window);
+ Save(window, $"hide-empty-{suffix}.png");
+
+ vm.Strength = 5;
+ vm.SetStyle(HideStyle.Pixelate);
+ foreach (var frame in Frames)
+ vm.Areas.Add(frame);
+ vm.Areas.Select(1);
+ WaitForCover(vm);
+ Settle(window);
+ Assert.False(vm.ShowEmptyHint);
+ Assert.True(vm.CanSave);
+ Save(window, $"hide-pixelate-{suffix}.png");
+
+ vm.SetStyle(HideStyle.Blur);
+ vm.Strength = 7;
+ vm.Areas.Select(2);
+ WaitForCover(vm);
+ Settle(window);
+ Save(window, $"hide-blur-{suffix}.png");
+ });
+ }
+
+ [AvaloniaTheory]
+ [InlineData("en", "light")]
+ [InlineData("en", "dark")]
+ [InlineData("de", "light")]
+ [InlineData("ar", "dark")]
+ [InlineData("ko", "light")]
+ [InlineData("ru", "dark")]
+ public void Several_files_show_a_strip_and_the_frames_carry_on_to_the_next(string language, string look)
+ {
+ WithWindow(language, look, ["one.png", "two.png", "spinner.gif", "four.png"], (window, vm, suffix, exported) =>
+ {
+ Assert.True(vm.HasMultipleFiles);
+ Assert.Equal(AppHost.Get()["hide.save_next"], vm.SaveText);
+
+ vm.Areas.Add(Frames[0]);
+ vm.Areas.Add(Frames[2]);
+ _ = vm.SaveAsync();
+ WaitFor(() => vm.CurrentIndex == 1 && !vm.IsBusy && vm.CoverImage is not null);
+
+ // The second picture starts with the first one's frames, none selected.
+ Assert.Equal(2, vm.Areas.Count);
+ AssertClose(Frames[0], vm.Areas.Areas[0]);
+ AssertClose(Frames[2], vm.Areas.Areas[1]);
+ Assert.Equal(-1, vm.Areas.Selected);
+ Assert.False(vm.CanUndo);
+ vm.Areas.Select(0);
+ WaitForCover(vm);
+ Settle(window);
+ Save(window, $"hide-multi-{suffix}.png");
+
+ // Skipped; the animated GIF can't be opened and fails on its own; the last one is saved.
+ _ = vm.SkipAsync();
+ WaitFor(() => vm.CurrentIndex == 3 && !vm.IsBusy && vm.PreviewImage is not null);
+ Assert.Equal(
+ [CropItemState.Done, CropItemState.Skipped, CropItemState.Failed, CropItemState.Current],
+ vm.Queue.Select(q => q.State));
+ _ = vm.SaveAsync();
+ WaitFor(() => vm.IsFinished);
+ Assert.Equal((2, 1, 1), (vm.DoneCount, vm.SkippedCount, vm.FailedCount));
+ Assert.Equal(2, exported.Count);
+ Settle(window);
+ Save(window, $"hide-summary-{suffix}.png");
+ });
+ }
+
+ [AvaloniaFact]
+ public void Dragging_on_the_picture_draws_a_frame_that_moves_sizes_and_shows_the_cover_at_once()
+ {
+ WithWindow("en", "light", ["chat.png"], (window, vm, _, _) =>
+ {
+ var canvas = window.HideCanvas;
+ var picture = canvas.CalculateImageBounds();
+ var origin = (Vector)canvas.TranslatePoint(default, window)!.Value;
+ Point At(double x, double y) => new Point(picture.X + picture.Width * x, picture.Y + picture.Height * y) + origin;
+
+ // A click beside every frame draws nothing.
+ window.MouseDown(At(0.5, 0.5), MouseButton.Left);
+ window.MouseUp(At(0.5, 0.5), MouseButton.Left);
+ Assert.Equal(0, vm.Areas.Count);
+
+ // A drag draws one, selected, already covered while the button is still down.
+ window.MouseDown(At(0.1, 0.1), MouseButton.Left);
+ window.MouseMove(At(0.2, 0.15));
+ window.MouseMove(At(0.4, 0.3));
+ Pump();
+ Assert.Equal(1, vm.Areas.Count);
+ Assert.Equal(0, vm.Areas.Selected);
+ WaitForCover(vm);
+ Settle(window);
+ Save(window, "hide-drag-en.png");
+ window.MouseUp(At(0.4, 0.3), MouseButton.Left);
+ AssertClose(new Rect(0.1, 0.1, 0.3, 0.2), vm.Areas.Areas[0]);
+
+ // Moved by its middle, sized by its corner; each drag is one undo step.
+ window.MouseDown(At(0.25, 0.2), MouseButton.Left);
+ window.MouseMove(At(0.3, 0.25));
+ window.MouseMove(At(0.35, 0.3));
+ window.MouseUp(At(0.35, 0.3), MouseButton.Left);
+ AssertClose(new Rect(0.2, 0.2, 0.3, 0.2), vm.Areas.Areas[0]);
+ var corner = At(0.5, 0.4);
+ window.MouseDown(corner, MouseButton.Left);
+ window.MouseMove(corner + new Vector(10, 10));
+ window.MouseMove(At(0.6, 0.5));
+ window.MouseUp(At(0.6, 0.5), MouseButton.Left);
+ AssertClose(new Rect(0.2, 0.2, 0.4, 0.3), vm.Areas.Areas[0]);
+ vm.Undo();
+ AssertClose(new Rect(0.2, 0.2, 0.3, 0.2), vm.Areas.Areas[0]);
+ vm.Undo();
+ AssertClose(new Rect(0.1, 0.1, 0.3, 0.2), vm.Areas.Areas[0]);
+ vm.Redo();
+ AssertClose(new Rect(0.2, 0.2, 0.3, 0.2), vm.Areas.Areas[0]);
+
+ // A second frame; a click on the first selects it again, a click beside both selects none.
+ window.MouseDown(At(0.7, 0.7), MouseButton.Left);
+ window.MouseMove(At(0.8, 0.8));
+ window.MouseMove(At(0.9, 0.9));
+ window.MouseUp(At(0.9, 0.9), MouseButton.Left);
+ Assert.Equal((2, 1), (vm.Areas.Count, vm.Areas.Selected));
+ window.MouseDown(At(0.3, 0.3), MouseButton.Left);
+ window.MouseUp(At(0.3, 0.3), MouseButton.Left);
+ Assert.Equal(0, vm.Areas.Selected);
+ window.MouseDown(At(0.05, 0.9), MouseButton.Left);
+ window.MouseUp(At(0.05, 0.9), MouseButton.Left);
+ Assert.Equal(-1, vm.Areas.Selected);
+ Assert.False(vm.HasSelection);
+
+ // Under the pointer a frame that isn't selected shows its full outline.
+ vm.Areas.Select(1);
+ window.MouseMove(At(0.3, 0.3));
+ Settle(window);
+ Save(window, "hide-hover-en.png");
+ });
+ }
+
+ [AvaloniaFact]
+ public void Keys_pick_move_remove_and_undo_frames_change_the_cover_save_and_close()
+ {
+ WithWindow("en", "light", ["chat.png"], (window, vm, _, exported) =>
+ {
+ // Without frames Enter saves nothing, and Tab leaves the frames alone.
+ Press(window, Key.Enter);
+ Assert.Empty(exported);
+
+ vm.Areas.Add(new Rect(0.25, 0.25, 0.5, 0.5)); // 400, 250, 800 × 500 of 1600 × 1000
+ vm.Areas.Add(new Rect(0.1, 0.1, 0.1, 0.1));
+ vm.Areas.Select(-1);
+
+ // Tab goes from frame to frame, Shift+Tab back.
+ Press(window, Key.Tab);
+ Assert.Equal(0, vm.Areas.Selected);
+ Press(window, Key.Tab);
+ Assert.Equal(1, vm.Areas.Selected);
+ Press(window, Key.Tab);
+ Assert.Equal(0, vm.Areas.Selected);
+ Press(window, Key.Tab, RawInputModifiers.Shift);
+ Assert.Equal(1, vm.Areas.Selected);
+ Press(window, Key.Tab, RawInputModifiers.Shift);
+ Assert.Equal(0, vm.Areas.Selected);
+
+ // Arrows move it by pixels of the picture, Shift by ten.
+ Press(window, Key.Right);
+ Press(window, Key.Right);
+ Press(window, Key.Down, RawInputModifiers.Shift);
+ Assert.Equal(new Rect(402 / 1600.0, 260 / 1000.0, 0.5, 0.5), vm.Areas.Areas[0]);
+
+ // Delete removes it, Ctrl+Z brings it back, Ctrl+Y removes it again.
+ Press(window, Key.Delete);
+ Assert.Equal(1, vm.Areas.Count);
+ Press(window, Key.Z, RawInputModifiers.Control);
+ Assert.Equal(2, vm.Areas.Count);
+ Press(window, Key.Y, RawInputModifiers.Control);
+ Assert.Equal(1, vm.Areas.Count);
+ Press(window, Key.Z, RawInputModifiers.Control);
+
+ // R clears every frame, undoably.
+ Press(window, Key.R);
+ Assert.Equal(0, vm.Areas.Count);
+ Press(window, Key.Z, RawInputModifiers.Control);
+ Assert.Equal(2, vm.Areas.Count);
+
+ // P, B and + / − choose the cover.
+ Press(window, Key.B);
+ Assert.Equal(HideStyle.Blur, vm.Style);
+ Press(window, Key.P);
+ Assert.Equal(HideStyle.Pixelate, vm.Style);
+ var strength = vm.Strength;
+ Press(window, Key.OemPlus);
+ Assert.Equal(strength + 1, vm.Strength);
+ Press(window, Key.OemMinus);
+ Press(window, Key.OemMinus);
+ Assert.Equal(strength - 1, vm.Strength);
+ for (var i = 0; i < 20; i++)
+ Press(window, Key.OemMinus);
+ Assert.Equal(HideTool.MinStrength, vm.Strength);
+
+ // Enter saves: one conversion of the picture with both frames, as chosen, and the window closes.
+ vm.SetStyle(HideStyle.Blur);
+ vm.Strength = 8;
+ var closed = false;
+ window.Closed += (_, _) => closed = true;
+ Press(window, Key.Enter);
+ WaitFor(() => closed);
+ var (path, conversion) = Assert.Single(exported);
+ Assert.EndsWith("chat.png", path);
+ Assert.Equal(PresetKind.Convert, conversion.Kind);
+ Assert.Equal(BuiltInData.SameAsSource, conversion.TargetFormat);
+ Assert.Equal(HideStyle.Blur, conversion.Image.HideStyle);
+ Assert.Equal(8, conversion.Image.HideStrength);
+ Assert.Equal(2, conversion.Image.HiddenAreas!.Count);
+ Assert.Equal(402 / 1600.0, conversion.Image.HiddenAreas[0].X, 6);
+ });
+ }
+
+ [AvaloniaFact]
+ public void Escape_closes_without_saving_and_the_choice_is_remembered_in_the_tool()
+ {
+ WithWindow("en", "light", ["chat.png"], (window, vm, _, exported) =>
+ {
+ vm.Areas.Add(Frames[0]);
+ vm.SetStyle(HideStyle.Blur);
+ vm.Strength = 9;
+ var closed = false;
+ window.Closed += (_, _) => closed = true;
+ Press(window, Key.Escape);
+ Assert.True(closed);
+ Assert.Empty(exported);
+ var saved = AppHost.Get().FindPreset("tool-hide")!;
+ Assert.Equal((HideStyle.Blur, 9), (saved.Image.HideStyle, saved.Image.HideStrength));
+ Assert.Null(saved.Image.HiddenAreas);
+ });
+
+ // The next window opens with it.
+ WithWindow("en", "light", ["chat.png"], (_, vm, _, _) => Assert.Equal((HideStyle.Blur, 9), (vm.Style, vm.Strength)),
+ resetTool: false);
+ var store = AppHost.Get();
+ HideTool.Remember(store.FindPreset("tool-hide")!, HideStyle.Pixelate, HideTool.DefaultStrength);
+ store.SaveLibrary();
+ }
+
+ [AvaloniaTheory]
+ [InlineData("de", "light")]
+ [InlineData("ru", "dark")]
+ [InlineData("ne", "light")]
+ [InlineData("ar", "light")]
+ [InlineData("en", "light")]
+ public void At_its_smallest_the_toolbar_still_fits_the_window(string language, string look)
+ {
+ WithWindow(language, look, ["one.png", "two.png"], (window, vm, suffix, _) =>
+ {
+ window.Width = window.MinWidth;
+ window.Height = window.MinHeight;
+ vm.Areas.Add(Frames[0]);
+ WaitForCover(vm);
+ Settle(window);
+ // Every button of the toolbar is whole inside the window (also in a right-to-left one), Hide & Next too.
+ var buttons = window.GetVisualDescendants().OfType().Single(b => b.Name == "Toolbar")
+ .GetVisualDescendants().OfType