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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +